Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c7dca30d | |||
| 73c435eb90 | |||
| 7be2b49300 | |||
| e5c05cbece | |||
| c7f4d0248c | |||
| 4c5d67c321 | |||
| 9feb9e24b6 | |||
| 941faccb3f | |||
| 6a040afcc5 | |||
| a346a2197e | |||
| e268b424f5 | |||
| 20029d36df | |||
| 54d48ed679 | |||
| 28f9ecd1a3 | |||
| b6bb25e79e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+21
-20
@@ -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
@@ -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 — 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'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
@@ -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
@@ -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\">✓</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\">✓</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\">●</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\">●</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\">●</span><br><span style=\"font-size:0.7rem\">Limited</span></td>" +
|
||||
|
||||
@@ -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
@@ -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);
|
||||
})()")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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'm not building that.")
|
||||
)
|
||||
let principle4: String = el_div(
|
||||
"class=\"lf-principle reveal\" style=\"transition-delay:360ms\"",
|
||||
|
||||
+173
-15
@@ -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"
|
||||
+ "¤cy=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
@@ -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
@@ -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 →")
|
||||
el_button("class=\"pricing-cta-ghost\" data-checkout=\"free\"", "Preorder free tier →")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -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'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't. Runs on your machine. Remembers everything. Start free.",
|
||||
"/",
|
||||
"Every other AI forgets you. Neuron doesn'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't. Runs on your machine, builds a persistent memory over time, and gets sharper the longer you use it. Free tier available."
|
||||
)
|
||||
+ page_schema()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user