Compare commits

..

15 Commits

Author SHA1 Message Date
will.anderson 37c7dca30d Fix $0 PaymentIntent: remove setup_future_usage (invalid with amount=0)
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m32s
2026-05-11 19:55:44 -05:00
will.anderson 73c435eb90 Merge pull request 'fix: free plan $0 PaymentIntent for age verification' (#125) from fix/free-plan-payment-intent into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m14s
2026-05-12 00:46:01 +00:00
will.anderson 7be2b49300 Free plan: use $0 PaymentIntent instead of SetupIntent for age verification
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m45s
All plans now use the same payment intent flow. Free creates a $0 PI
with payment_method_types[]=card and setup_future_usage=off_session.
No charge, card saved. Removes setup_mode=true for free plan.

Fix submit button label: show 'Verify age & get started' for free
instead of 'Complete purchase'. Retire checkout-free.el.
2026-05-11 19:45:39 -05:00
will.anderson e5c05cbece Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 19:16:18 -05:00
will.anderson c7f4d0248c Merge pull request 'fix: free checkout Stripe SetupIntent + remove no-card-required copy' (#123) from fix/free-checkout-stripe into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m14s
2026-05-12 00:16:12 +00:00
will.anderson 4c5d67c321 fix: free checkout requires Stripe SetupIntent for age verification; update copy
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m43s
2026-05-11 19:15:57 -05:00
will.anderson 9feb9e24b6 Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 18:56:43 -05:00
will.anderson 941faccb3f Merge pull request 'fix: force full build when no diff or stage-latest missing' (#121) from fix/stage-full-build into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m16s
2026-05-11 23:56:35 +00:00
will.anderson 6a040afcc5 fix: force full build when no diff or stage-latest missing
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m43s
2026-05-11 18:56:18 -05:00
will.anderson a346a2197e Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 18:46:33 -05:00
will.anderson e268b424f5 Merge pull request 'ci: touch dist to trigger stage rebuild' (#119) from ci/touch-dist into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m2s
2026-05-11 23:46:17 +00:00
will.anderson 20029d36df ci: touch dist to trigger stage rebuild
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m35s
2026-05-11 18:45:57 -05:00
will.anderson 54d48ed679 ci: trigger rebuild after registry cleanup 2026-05-11 17:33:53 -05:00
will.anderson 28f9ecd1a3 Merge pull request 'fix: heading and button elements pass children unescaped' (#113) from fix/force-full-rebuild into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m0s
2026-05-11 22:21:41 +00:00
will.anderson b6bb25e79e fix: heading and button elements pass children unescaped
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m32s
el_h1/h2/h3/h4 and el_button were calling el_escape() on their
content, converting any HTML children (e.g. <span class="gold">)
into literal entity text on screen.

These functions accept composed HTML children, not raw text — they
should pass the argument through like el_div/el_p/el_span do.
el_text, el_attr, el_title, el_textarea, and el_img keep escaping
(they handle actual text/attribute values, not HTML children).
2026-05-11 17:21:19 -05:00
14 changed files with 320 additions and 71 deletions
+6 -1
View File
@@ -82,7 +82,12 @@ jobs:
echo "Changed files:"
echo "$CHANGED"
NON_ASSET=$(echo "$CHANGED" | grep -v '^src/assets/' | grep -v '^src/shares/' | grep -v '^src/index\.html' | grep -v '^src/about\.html' | grep -v '^src/terms\.html' | grep -v '^src/enterprise-terms\.html' | grep -v '^src/llms\.txt' | grep -v '^migrations/' | grep -v '^scripts/' | grep -v '^tests/' | grep -v '^\.gitea/' | grep -v '^$' || true)
if [ -z "$NON_ASSET" ] && [ "$CHANGED" != "unknown" ]; then
if [ -z "$CHANGED" ] || [ "$CHANGED" = "unknown" ]; then
# No diff (workflow_dispatch with no new commits, or git error).
# Registry may not have a stage-latest base image, so force full build.
echo "asset_only=false" >> "$GITHUB_OUTPUT"
echo "=> No changed files detected (workflow_dispatch?), forcing full build"
elif [ -z "$NON_ASSET" ]; then
echo "asset_only=true" >> "$GITHUB_OUTPUT"
echo "=> Asset-only change detected, will use fast path"
else
+1
View File
@@ -9,6 +9,7 @@
#
# CI pre-build steps (in stage.yaml):
# - neuron-web: built by `elb build` → dist/neuron-landing
# Last rebuilt: 2026-05-11
FROM ubuntu:24.04
+21 -20
View File
@@ -1,3 +1,4 @@
// elhtml_impl.c — El HTML element stubs.
#include <stdint.h>
#include <stdlib.h>
#include "el_runtime.h"
@@ -19,11 +20,11 @@ el_val_t el_li(el_val_t attrs, el_val_t children);
el_val_t el_p(el_val_t attrs, el_val_t children);
el_val_t el_span(el_val_t attrs, el_val_t children);
el_val_t el_form(el_val_t attrs, el_val_t children);
el_val_t el_h1(el_val_t attrs, el_val_t text);
el_val_t el_h2(el_val_t attrs, el_val_t text);
el_val_t el_h3(el_val_t attrs, el_val_t text);
el_val_t el_h4(el_val_t attrs, el_val_t text);
el_val_t el_button(el_val_t attrs, el_val_t label);
el_val_t el_h1(el_val_t attrs, el_val_t children);
el_val_t el_h2(el_val_t attrs, el_val_t children);
el_val_t el_h3(el_val_t attrs, el_val_t children);
el_val_t el_h4(el_val_t attrs, el_val_t children);
el_val_t el_button(el_val_t attrs, el_val_t children);
el_val_t el_a(el_val_t href, el_val_t attrs, el_val_t children);
el_val_t el_input(el_val_t type_attr, el_val_t attrs);
el_val_t el_textarea(el_val_t attrs, el_val_t value);
@@ -176,43 +177,43 @@ el_val_t el_form(el_val_t attrs, el_val_t children) {
return 0;
}
el_val_t el_h1(el_val_t attrs, el_val_t text) {
el_val_t el_h1(el_val_t attrs, el_val_t children) {
if (str_eq(attrs, EL_STR(""))) {
return el_str_concat(el_str_concat(EL_STR("<h1>"), el_escape(text)), EL_STR("</h1>"));
return el_str_concat(el_str_concat(EL_STR("<h1>"), children), EL_STR("</h1>"));
}
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h1 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h1>"));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h1 "), attrs), EL_STR(">")), children), EL_STR("</h1>"));
return 0;
}
el_val_t el_h2(el_val_t attrs, el_val_t text) {
el_val_t el_h2(el_val_t attrs, el_val_t children) {
if (str_eq(attrs, EL_STR(""))) {
return el_str_concat(el_str_concat(EL_STR("<h2>"), el_escape(text)), EL_STR("</h2>"));
return el_str_concat(el_str_concat(EL_STR("<h2>"), children), EL_STR("</h2>"));
}
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h2 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h2>"));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h2 "), attrs), EL_STR(">")), children), EL_STR("</h2>"));
return 0;
}
el_val_t el_h3(el_val_t attrs, el_val_t text) {
el_val_t el_h3(el_val_t attrs, el_val_t children) {
if (str_eq(attrs, EL_STR(""))) {
return el_str_concat(el_str_concat(EL_STR("<h3>"), el_escape(text)), EL_STR("</h3>"));
return el_str_concat(el_str_concat(EL_STR("<h3>"), children), EL_STR("</h3>"));
}
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h3 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h3>"));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h3 "), attrs), EL_STR(">")), children), EL_STR("</h3>"));
return 0;
}
el_val_t el_h4(el_val_t attrs, el_val_t text) {
el_val_t el_h4(el_val_t attrs, el_val_t children) {
if (str_eq(attrs, EL_STR(""))) {
return el_str_concat(el_str_concat(EL_STR("<h4>"), el_escape(text)), EL_STR("</h4>"));
return el_str_concat(el_str_concat(EL_STR("<h4>"), children), EL_STR("</h4>"));
}
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h4 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h4>"));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h4 "), attrs), EL_STR(">")), children), EL_STR("</h4>"));
return 0;
}
el_val_t el_button(el_val_t attrs, el_val_t label) {
el_val_t el_button(el_val_t attrs, el_val_t children) {
if (str_eq(attrs, EL_STR(""))) {
return el_str_concat(el_str_concat(EL_STR("<button type=\"button\">"), el_escape(label)), EL_STR("</button>"));
return el_str_concat(el_str_concat(EL_STR("<button type=\"button\">"), children), EL_STR("</button>"));
}
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<button type=\"button\" "), attrs), EL_STR(">")), el_escape(label)), EL_STR("</button>"));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<button type=\"button\" "), attrs), EL_STR(">")), children), EL_STR("</button>"));
return 0;
}
+30 -1
View File
@@ -651,7 +651,8 @@ fn account_roadmap_phase2() -> String {
el_li("", "Process and knowledge packets") +
el_li("", "Local inference via Ollama") +
el_li("", "Additional connectors &mdash; GitHub, Notion, Linear, more") +
el_li("", "Mobile companion app")
el_li("", "Mobile companion app") +
el_li("", "Family accounts")
)
)
}
@@ -691,6 +692,33 @@ fn account_roadmap_section() -> String {
)
}
fn account_family_section() -> String {
let child_attest: String = "<label style=\"display:flex;gap:.5rem;align-items:flex-start;cursor:pointer;margin:.75rem 0\">" +
"<input type=\"checkbox\" id=\"family-attest\" style=\"margin-top:.2rem;accent-color:var(--navy)\">" +
"<span style=\"font-size:.8rem;color:var(--t2)\">I confirm this person is a minor under my care and is using this account under my supervision.</span>" +
"</label>"
el_div(
"id=\"family-section\" style=\"display:none\"",
el_div(
"class=\"card-dark\"",
el_div(
"class=\"acct-section-header\"",
el_p("class=\"card-label\"", "Family members") +
el_p("style=\"font-size:.8125rem;font-weight:300;color:var(--t2);line-height:1.65\"", "Add up to 5 children to your Founding Member plan. $10/month per child.")
) +
el_div("id=\"family-list\"", "") +
el_div(
"id=\"add-child-form\"",
el_input("email", "id=\"child-email\" placeholder=\"Child&#39;s email\" class=\"acct-input\"") +
el_input("number", "id=\"child-dob-year\" placeholder=\"Birth year (e.g. 2012)\" min=\"1990\" max=\"2007\" class=\"acct-input\"") +
child_attest +
el_button("class=\"btn-primary\" onclick=\"addFamilyMember()\" style=\"padding:.75rem 1.5rem\"", "Add family member") +
el_p("id=\"family-msg\" style=\"display:none;font-size:.8rem;margin-top:.5rem\"", "")
)
)
)
}
fn account_badge_section() -> String {
el_div(
"id=\"badge-section\" style=\"display:none\"",
@@ -924,6 +952,7 @@ fn account_dashboard_section() -> String {
account_plan_card() +
account_api_keys_section() +
account_roadmap_section() +
account_family_section() +
account_badge_section() +
account_timeline_card() +
account_download_card() +
+1 -1
View File
@@ -98,7 +98,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
el_li("", "Persistent memory - never resets")
+ el_li("", "Local inference via Ollama (coming)")
+ el_li("", "Bring your own API keys")
+ el_li("", "Plugin marketplace access (coming soon)")
+ el_li("", "3 marketplace plugins included")
+ el_li("", "Core built-in capabilities")
+ el_li("", "2 devices included")
} else {
+2 -2
View File
@@ -129,8 +129,8 @@ fn comparison_rows() -> String {
"<td style=\"text-align:center;padding:1rem;color:var(--t3)\"><span style=\"font-weight:500;color:var(--t2)\">$30/mo</span><br><span style=\"font-size:0.7rem\">Microsoft 365</span></td>" +
"</tr>"
let row10: String = "<tr>" +
"<td style=\"padding:1rem 1.5rem 1rem 0;color:var(--t1);font-weight:500\">Free trial</td>" +
"<td style=\"text-align:center;padding:1rem\"><span style=\"color:#008040;font-weight:700;font-size:1.1rem\">&#10003;</span><br><span style=\"font-size:0.7rem;color:var(--t3);font-weight:400\">Full app, 14 days</span></td>" +
"<td style=\"padding:1rem 1.5rem 1rem 0;color:var(--t1);font-weight:500\">Free tier</td>" +
"<td style=\"text-align:center;padding:1rem\"><span style=\"color:#008040;font-weight:700;font-size:1.1rem\">&#10003;</span><br><span style=\"font-size:0.7rem;color:var(--t3);font-weight:400\">Full app, forever</span></td>" +
"<td style=\"text-align:center;padding:1rem;color:var(--t3)\"><span style=\"color:#D4A017\">&#9679;</span><br><span style=\"font-size:0.7rem\">GPT-3.5 only</span></td>" +
"<td style=\"text-align:center;padding:1rem;color:var(--t3)\"><span style=\"color:#D4A017\">&#9679;</span><br><span style=\"font-size:0.7rem\">Limited</span></td>" +
"<td style=\"text-align:center;padding:1rem;color:var(--t3)\"><span style=\"color:#D4A017\">&#9679;</span><br><span style=\"font-size:0.7rem\">Limited</span></td>" +
+71 -2
View File
@@ -1,5 +1,5 @@
// account-dashboard.el -- Account dashboard: session check, plan card.
// Handles onAuthStateChange and plan card rendering.
// account-dashboard.el -- Account dashboard: session check, plan card, family.
// Handles onAuthStateChange, plan card rendering, family member management.
// Compiled with: elc --target=js --bundle --minify --obfuscate
//
// Required globals: window.NEURON_CFG.supabase_url, window.NEURON_CFG.supabase_anon_key
@@ -127,8 +127,77 @@ fn main() -> Void {
var roadmapSection = document.getElementById('roadmap-section');
if (plan === 'founding' && roadmapSection) roadmapSection.style.display = '';
if (plan === 'founding') {
var famSection = document.getElementById('family-section');
if (famSection) famSection.style.display = 'block';
var session = await sb.auth.getSession();
var userEmail = session.data.session && session.data.session.user ? session.data.session.user.email : '';
if (userEmail) loadFamilyMembers(userEmail);
}
}
async function loadFamilyMembers(parentEmail) {
var r = await fetch('/api/family/members?parent_email=' + encodeURIComponent(parentEmail));
var members = await r.json();
var list = document.getElementById('family-list');
if (!list) return;
if (!members || !members.length) {
list.innerHTML = '<p style=\"color:var(--t3);font-size:.875rem;margin-bottom:1rem\">No family members yet.</p>';
return;
}
list.innerHTML = members.map(function(m) {
return '<div style=\"display:flex;justify-content:space-between;align-items:center;padding:.75rem 0;border-bottom:1px solid var(--border)\">'
+ '<div><p style=\"font-size:.875rem;color:var(--t1)\">' + m.child_email + '</p>'
+ '<p style=\"font-size:.75rem;color:var(--t3);text-transform:uppercase;letter-spacing:.06em\">' + m.status + '</p></div>'
+ '<button onclick=\"removeFamilyMember(\'' + m.child_email + '\')\" style=\"background:none;border:none;color:var(--t3);cursor:pointer;font-size:.75rem\">Remove</button>'
+ '</div>';
}).join('');
}
window.addFamilyMember = async function() {
var email = document.getElementById('child-email').value.trim();
var year = document.getElementById('child-dob-year').value;
var attest = document.getElementById('family-attest').checked;
var msg = document.getElementById('family-msg');
if (!email || !year || !attest) {
msg.style.display='block'; msg.style.color='#c44'; msg.textContent='Please fill in all fields and confirm the attestation.'; return;
}
if (parseInt(year) < 2008) {
msg.style.display='block'; msg.style.color='#c44'; msg.textContent='Child must be under 18. Birth year must be 2008 or later.'; return;
}
var session = await sb.auth.getSession();
var parentEmail = session.data.session && session.data.session.user ? session.data.session.user.email : '';
var r = await fetch('/api/family/invite', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({parent_email: parentEmail, child_email: email, child_dob_year: parseInt(year), attested: true})
});
var d = await r.json();
msg.style.display = 'block';
if (d.ok) {
msg.style.color = 'var(--navy)';
msg.textContent = 'Invitation sent to ' + email + '. They will receive an email to set up their account.';
document.getElementById('child-email').value = '';
document.getElementById('child-dob-year').value = '';
document.getElementById('family-attest').checked = false;
loadFamilyMembers(parentEmail);
} else {
msg.style.color = '#c44';
msg.textContent = d.error || 'Something went wrong.';
}
};
window.removeFamilyMember = async function(childEmail) {
var session = await sb.auth.getSession();
var parentEmail = session.data.session && session.data.session.user ? session.data.session.user.email : '';
await fetch('/api/family/remove', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({parent_email: parentEmail, child_email: childEmail})
});
loadFamilyMembers(parentEmail);
};
function renderUserChip(user) {
var email = user.email || '';
var avatarEl = document.getElementById('user-avatar-el');
+3 -17
View File
@@ -1,20 +1,6 @@
// checkout-free.el -- Free plan: show success panel after auth completes.
// Watches the auth-badge element; when it becomes visible, hides the auth
// section and shows the free-success panel. No card required for free tier.
// Compiled with: elc --target=js --bundle --minify --obfuscate
// checkout-free.el -- RETIRED. Free plan now uses the standard Stripe
// payment flow (checkout-stripe.el) with a $0 PaymentIntent for age
// verification. This file is no longer compiled or loaded.
fn main() -> Void {
native_js("(function() {
var success = document.getElementById('free-success');
var auth = document.getElementById('auth-section');
if (!success) return;
var timer = setInterval(function() {
var badge = document.getElementById('auth-badge');
if (badge && badge.offsetParent !== null) {
if (auth) auth.style.display = 'none';
success.style.display = '';
clearInterval(timer);
}
}, 150);
})()")
}
+3 -3
View File
@@ -31,8 +31,8 @@ fn main() -> Void {
if (spinner) spinner.style.display = loading ? '' : 'none';
}
// Free plan has no payment form — bail out entirely.
if (str_eq(PLAN, 'free')) return;
// Free plan: Stripe SetupIntent for age verification (card saved, never charged).
// Falls through to the same Stripe init path — server returns setup_mode=true for free.
window._neuronMode = 'payment';
var paymentEl = null;
@@ -101,7 +101,7 @@ fn main() -> Void {
if (submitLabel) {
submitLabel.textContent = window._neuronMode === 'setup'
? 'Save my card - no charge today →'
: 'Complete purchase →';
: PLAN === 'free' ? 'Verify age & get started →' : 'Complete purchase →';
}
waitForStripe(function() {
if (!stripe) stripe = Stripe(STRIPE_PK);
+1 -1
View File
@@ -55,7 +55,7 @@ fn local_first() -> String {
let principle3: String = el_div(
"class=\"lf-principle reveal\" style=\"transition-delay:240ms\"",
el_p("class=\"lf-principle-label\"", "No ads. Ever.") +
el_p("class=\"lf-principle-body\"", "Not during your trial. Not on paid. Not in any future version. Ads require surveillance. Surveillance requires your data. I&#39;m not building that.")
el_p("class=\"lf-principle-body\"", "Not on the free tier. Not on paid. Not in any future version. Ads require surveillance. Surveillance requires your data. I&#39;m not building that.")
)
let principle4: String = el_div(
"class=\"lf-principle reveal\" style=\"transition-delay:360ms\"",
+173 -15
View File
@@ -628,7 +628,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
"Founding Member Checkout — Neuron"
} else {
if str_eq(plan, "free") {
"Start Your Free Trial — Neuron"
"Get Started Free — Neuron"
} else {
"Professional Plan Checkout — Neuron"
}
@@ -637,7 +637,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
"Secure your Founding Member spot. Pay once, $199 lifetime — Neuron inference included at launch, priced below the major APIs. First 1,000 only."
} else {
if str_eq(plan, "free") {
"Start your 14-day free trial. No credit card required. Your AI that remembers you runs on your machine, never resets."
"Create your free Neuron account. A card verifies you're 18+ — you won't be charged. Your AI that remembers you, runs on your machine, never resets."
} else {
"Subscribe to Neuron Professional for $19/month. The AI that remembers you — persistent memory, runs locally, bring your own API keys."
}
@@ -697,23 +697,21 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
}
}
// Free tier: creates a SetupIntent for age verification (18+ requirement).
// No charge but the user must provide a valid payment method.
// Free tier: $0 PaymentIntent for age verification (18+ requirement).
// Verifies card is valid. No charge, no capture.
// Note: setup_future_usage cannot be used with amount=0.
if str_eq(plan, "free") {
let free_si_body: String = "automatic_payment_methods[enabled]=true"
+ "&usage=off_session"
let free_pi_body: String = "amount=0"
+ "&currency=usd"
+ "&payment_method_types[]=card"
+ "&metadata[plan]=free"
+ "&metadata[purpose]=age_verification"
let free_si_body = if !str_eq(pi_cus_id, "") { free_si_body + "&customer=" + pi_cus_id } else { free_si_body }
let free_si_resp: String = http_post_form_auth(
"https://api.stripe.com/v1/setup_intents",
free_si_body,
let free_pi_body = if !str_eq(pi_cus_id, "") { free_pi_body + "&customer=" + pi_cus_id } else { free_pi_body }
let free_pi_resp: String = http_post_form_auth(
"https://api.stripe.com/v1/payment_intents",
free_pi_body,
auth_header)
if str_starts_with(free_si_resp, "{") {
let inner: String = str_slice(free_si_resp, 1, str_len(free_si_resp))
return "{\"setup_mode\":true,\"plan\":\"free\"," + inner
}
return free_si_resp
return free_pi_resp
}
// Setup-mode path: save payment method, do not charge. Only valid
@@ -1991,6 +1989,164 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
return badge_css + badge_html
}
// Family plan POST /api/family/invite
if str_eq(path, "/api/family/invite") {
if !str_eq(method, "POST") {
return "{\"error\":\"POST required\"}"
}
let parent_email: String = json_get(body, "parent_email")
let child_email: String = json_get(body, "child_email")
let child_dob_year_str: String = json_get(body, "child_dob_year")
let attested_str: String = json_get(body, "attested")
if str_eq(parent_email, "") || str_eq(child_email, "") || str_eq(child_dob_year_str, "") {
return "{\"error\":\"parent_email, child_email, and child_dob_year are required\"}"
}
if !str_eq(attested_str, "true") {
return "{\"error\":\"attestation required\"}"
}
let child_dob_year: Int = str_to_int(child_dob_year_str)
if child_dob_year < 2008 {
return "{\"error\":\"Child must be under 18. Birth year must be 2008 or later.\"}"
}
// Check existing family member count
let fam_sb_url: String = state_get("__supabase_project_url__")
let fam_sb_key: String = state_get("__supabase_service_key__")
if str_eq(fam_sb_key, "") {
return "{\"error\":\"not configured\"}"
}
let pe_safe: String = str_replace(str_replace(parent_email, "\\", "\\\\"), "\"", "\\\"")
let ce_safe: String = str_replace(str_replace(child_email, "\\", "\\\\"), "\"", "\\\"")
let members_resp: String = http_get_auth(
fam_sb_url + "/rest/v1/family_members?parent_email=eq." + parent_email + "&status=neq.cancelled&select=child_email",
fam_sb_key
)
let member_count: Int = json_array_len(members_resp)
if member_count >= 5 {
return "{\"error\":\"Maximum of 5 family members reached.\"}"
}
// Check child not already added
let dup_resp: String = http_get_auth(
fam_sb_url + "/rest/v1/family_members?parent_email=eq." + parent_email + "&child_email=eq." + child_email + "&select=child_email",
fam_sb_key
)
let dup_count: Int = json_array_len(dup_resp)
if dup_count > 0 {
return "{\"error\":\"This child email is already in your family plan.\"}"
}
// Create Stripe customer for child
let fam_stripe_key: String = state_get("__stripe_secret_key__")
if str_eq(fam_stripe_key, "") {
return "{\"error\":\"Stripe not configured\"}"
}
let child_email_safe: String = str_replace(child_email, "@", "%40")
let customer_body: String = "email=" + child_email_safe
let customer_resp: String = http_post_auth("https://api.stripe.com/v1/customers", fam_stripe_key, customer_body)
let child_customer_id: String = json_get(customer_resp, "id")
if str_eq(child_customer_id, "") {
return "{\"error\":\"Failed to create Stripe customer\"}"
}
// Create Stripe subscription for child
let price_id: String = state_get("__stripe_price_family_child__")
if str_eq(price_id, "") {
return "{\"error\":\"Family child price not configured\"}"
}
let parent_email_safe: String = str_replace(parent_email, "@", "%40")
let sub_body: String = "customer=" + child_customer_id
+ "&items[0][price]=" + price_id
+ "&trial_period_days=7"
+ "&metadata[parent_email]=" + parent_email_safe
+ "&metadata[type]=family_child"
let sub_resp: String = http_post_auth("https://api.stripe.com/v1/subscriptions", fam_stripe_key, sub_body)
let sub_id: String = json_get(sub_resp, "id")
if str_eq(sub_id, "") {
return "{\"error\":\"Failed to create Stripe subscription\"}"
}
// Generate invite token
let ts_str: String = int_to_str(unix_timestamp())
let email_prefix: String = str_slice(child_email, 0, 4)
let invite_token: String = ts_str + email_prefix
// Insert into family_members table
let fam_row: String = "{\"parent_email\":\"" + pe_safe + "\",\"parent_user_id\":\"\",\"child_email\":\"" + ce_safe + "\",\"child_dob_year\":" + child_dob_year_str + ",\"status\":\"invited\",\"stripe_subscription_id\":\"" + sub_id + "\",\"invite_token\":\"" + invite_token + "\"}"
let fam_insert_resp: String = supabase_insert(fam_sb_url, fam_sb_key, "family_members", fam_row)
println("[family/invite] insert -> " + fam_insert_resp)
// Send invite email
let invite_text: String = "You have been invited to join Neuron by " + parent_email
+ ". Visit https://neurontechnologies.ai/account to set up your account."
let invite_send: String = send_email(
"Neuron <no-reply@neurontechnologies.ai>",
child_email,
"You have been invited to Neuron",
"",
invite_text
)
println("[family/invite] " + invite_send)
return "{\"ok\":true,\"invite_token\":\"" + invite_token + "\"}"
}
// Family plan GET /api/family/members
if str_starts_with(path, "/api/family/members") {
let fml_sb_url: String = state_get("__supabase_project_url__")
let fml_sb_key: String = state_get("__supabase_service_key__")
if str_eq(fml_sb_key, "") {
return "[]"
}
// Extract parent_email from query string
let fml_parent_email: String = ""
let q_idx: Int = str_index_of(path, "parent_email=")
if q_idx >= 0 {
let fml_parent_email = str_slice(path, q_idx + 13, str_len(path))
}
if str_eq(fml_parent_email, "") {
return "{\"error\":\"parent_email required\"}"
}
let fml_resp: String = http_get_auth(
fml_sb_url + "/rest/v1/family_members?parent_email=eq." + fml_parent_email + "&select=child_email,status,child_dob_year,created_at",
fml_sb_key
)
if str_eq(fml_resp, "") {
return "[]"
}
return fml_resp
}
// Family plan POST /api/family/remove
if str_eq(path, "/api/family/remove") {
if !str_eq(method, "POST") {
return "{\"error\":\"POST required\"}"
}
let rem_parent_email: String = json_get(body, "parent_email")
let rem_child_email: String = json_get(body, "child_email")
if str_eq(rem_parent_email, "") || str_eq(rem_child_email, "") {
return "{\"error\":\"parent_email and child_email required\"}"
}
let rem_sb_url: String = state_get("__supabase_project_url__")
let rem_sb_key: String = state_get("__supabase_service_key__")
if str_eq(rem_sb_key, "") {
return "{\"error\":\"not configured\"}"
}
// Fetch the subscription ID
let sub_lookup: String = http_get_auth(
rem_sb_url + "/rest/v1/family_members?parent_email=eq." + rem_parent_email + "&child_email=eq." + rem_child_email + "&select=stripe_subscription_id",
rem_sb_key
)
let rem_sub_id: String = json_get(sub_lookup, "0.stripe_subscription_id")
// Cancel Stripe subscription if present
if !str_eq(rem_sub_id, "") {
let rem_stripe_key: String = state_get("__stripe_secret_key__")
if !str_eq(rem_stripe_key, "") {
let cancel_resp: String = http_post_auth("https://api.stripe.com/v1/subscriptions/" + rem_sub_id + "/cancel", rem_stripe_key, "")
println("[family/remove] cancel sub " + rem_sub_id + " -> " + cancel_resp)
}
}
// Update status to cancelled
let rpe_safe: String = str_replace(str_replace(rem_parent_email, "\\", "\\\\"), "\"", "\\\"")
let rce_safe: String = str_replace(str_replace(rem_child_email, "\\", "\\\\"), "\"", "\\\"")
let cancel_row: String = "{\"status\":\"cancelled\"}"
supabase_insert(rem_sb_url, rem_sb_key, "family_members?parent_email=eq." + rem_parent_email + "&child_email=eq." + rem_child_email, cancel_row)
println("[family/remove] cancelled " + rce_safe + " from " + rpe_safe)
return "{\"ok\":true}"
}
// API key provisioning POST /api/api-keys
// Returns user's stored provider keys (masked) for display on /account.
// Body: { access_token: "<jwt>" }
@@ -2187,6 +2343,7 @@ let stripe_key: String = env("STRIPE_SECRET_KEY")
let stripe_pub_key: String = env("STRIPE_PUBLISHABLE_KEY")
let stripe_price_founding: String = env("STRIPE_PRICE_FOUNDING")
let stripe_price_professional: String = env("STRIPE_PRICE_PROFESSIONAL")
let family_child_price: String = env("STRIPE_PRICE_FAMILY_CHILD")
let stripe_webhook_secret: String = env("STRIPE_WEBHOOK_SECRET")
let license_api_url: String = env("NEURON_LICENSE_API_URL")
let resend_api_key: String = env("RESEND_API_KEY")
@@ -2237,6 +2394,7 @@ state_set("__stripe_secret_key__", stripe_key)
state_set("__stripe_publishable_key__", stripe_pub_key)
state_set("__stripe_price_founding__", stripe_price_founding)
state_set("__stripe_price_professional__", stripe_price_professional)
state_set("__stripe_price_family_child__", family_child_price)
state_set("__license_api_url__", license_api_url)
state_set("__resend_api_key__", resend_api_key)
state_set("__supabase_anon_key__", supabase_anon_key)
+1 -1
View File
@@ -77,7 +77,7 @@ fn marketplace_categories() -> String {
el_div(
"class=\"marketplace-categories reveal\" style=\"transition-delay:320ms\"",
marketplace_tags_block("Connectors (rolling out)", connectors) +
marketplace_tags_block("Connectors - day one", connectors) +
marketplace_tags_block("Following launch", following) +
el_div(
"",
+5 -5
View File
@@ -1,4 +1,4 @@
// components/pricing.el - Three pricing cards: Free trial / Professional / Founding Member.
// components/pricing.el - Three pricing cards: Free / Professional / Founding Member.
// Accepts the founding counter values as parameters so main.el can inject
// server-side rendered counts without JS.
@@ -36,7 +36,7 @@ fn pricing_free_features() -> String {
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Local inference via Ollama (coming)")) +
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Neuron Inference included when it launches - Q3 2026")) +
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Unlimited projects")) +
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Plugin marketplace access (coming soon)")) +
el_li("", el_span("class=\"dash\"", "-") + el_span("", "3 marketplace plugins included")) +
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Core built-in capabilities"))
}
@@ -89,14 +89,14 @@ fn pricing(sold: Int, total: Int) -> String {
el_div(
"class=\"pricing-price-row\"",
el_span("class=\"pricing-price\"", "$0") +
el_span("class=\"pricing-cadence\"", "for 14 days")
el_span("class=\"pricing-cadence\"", "forever")
) +
el_p("class=\"pricing-tagline\"", "Try everything free for 14 days. No card required.") +
el_p("class=\"pricing-tagline\"", "Start building your memory. Card required for age verification — you won't be charged.") +
el_ul("class=\"pricing-features\"", pricing_free_features()) +
el_div("style=\"flex:1\"", "") +
el_div(
"class=\"pricing-cta pricing-cta-ghost\"",
el_button("class=\"pricing-cta-ghost\" data-checkout=\"free\"", "Start free trial &#8594;")
el_button("class=\"pricing-cta-ghost\" data-checkout=\"free\"", "Preorder free tier &#8594;")
)
)
+2 -2
View File
@@ -78,9 +78,9 @@ fn page_head() -> String {
return page_head_base()
+ page_seo_block(
"Neuron — The AI That Remembers You",
"Every AI resets when you close the tab. Neuron doesn&#39;t. Runs on your machine. Remembers everything. Start with a 14-day free trial — no credit card required.",
"Every AI resets when you close the tab. Neuron doesn&#39;t. Runs on your machine. Remembers everything. Start free.",
"/",
"Every other AI forgets you. Neuron doesn&#39;t. Runs on your machine, builds a persistent memory over time, and gets sharper the longer you use it. 14-day free trial."
"Every other AI forgets you. Neuron doesn&#39;t. Runs on your machine, builds a persistent memory over time, and gets sharper the longer you use it. Free tier available."
)
+ page_schema()
}