diff --git a/src/main.el b/src/main.el index b3fb60c..e094698 100644 --- a/src/main.el +++ b/src/main.el @@ -848,8 +848,15 @@ fn handle_request(method: String, path: String, body: String) -> String { let ec_str: String = json_get(body, "ec") let an_safe: String = if str_eq(an_raw, "") { "[]" } else { an_raw } let ec_safe: String = if str_eq(ec_str, "") { "0" } else { ec_str } + // questions_remaining + is_last_question let the soul close + // the conversation in voice when the visitor is on their last + // turn instead of leaving them at a hard rate-limit wall. + let qrem_str: String = json_get(body, "questions_remaining") + let qrem_safe: String = if str_eq(qrem_str, "") { "10" } else { qrem_str } + let is_last_str: String = json_get(body, "is_last_question") + let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" } // Build inner content with history and engram context for thread context - let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + "}}" + let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + "}}" // Escape inner for the outer content field let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"") // Build dharma envelope with per-user channel @@ -884,14 +891,40 @@ fn handle_request(method: String, path: String, body: String) -> String { } // ── Stripe webhook ──────────────────────────────────────────────────────── + // Handles three event types: + // checkout.session.completed - legacy hosted-checkout flow + // payment_intent.succeeded - integrated Elements + charge-now + // setup_intent.succeeded - integrated Elements + hold-until-launch + // + // For every successful purchase the handler: + // 1. Bumps the founding counter (if plan=founding) and persists + // 2. Upserts the waitlist row (email-keyed) + // 3. Auto-provisions a Supabase account via /auth/v1/invite — sends a + // magic-link invite email so the buyer can sign in and see their + // plan on /account. Idempotent: existing users get a fresh link. + // 4. Forwards to license API for key provisioning when configured. if str_eq(path, "/api/webhooks/stripe") { - if str_contains(body, "checkout.session.completed") { - // Extract customer email from session - let customer_email: String = json_get(body, "customer_details.email") + let is_session_done: Bool = str_contains(body, "checkout.session.completed") + let is_pi_done: Bool = str_contains(body, "payment_intent.succeeded") + let is_si_done: Bool = str_contains(body, "setup_intent.succeeded") + + if is_session_done || is_pi_done || is_si_done { + // Pull email/name/customer_id - fields differ slightly across event + // types, walk a few candidates. + let customer_email: String = json_get(body, "receipt_email") + if str_eq(customer_email, "") { let customer_email = json_get(body, "customer_details.email") } + if str_eq(customer_email, "") { let customer_email = json_get(body, "billing_details.email") } let customer_name: String = json_get(body, "customer_details.name") + if str_eq(customer_name, "") { let customer_name = json_get(body, "billing_details.name") } let customer_id: String = json_get(body, "customer") - // Increment founding counter and add to waitlist - if str_contains(body, "\"founding\"") { + + // Plan inference from metadata + let plan: String = "free" + if str_contains(body, "\"plan\":\"founding\"") { plan = "founding" } + if str_contains(body, "\"plan\":\"professional\"") { plan = "professional" } + + // 1. Founding counter bump (one-shot) + waitlist + if str_eq(plan, "founding") && (is_session_done || is_pi_done) { let current_sold: Int = get_sold() let new_sold: Int = current_sold + 1 state_set("__founding_sold__", int_to_str(new_sold)) @@ -901,23 +934,50 @@ fn handle_request(method: String, path: String, body: String) -> String { waitlist_upsert(customer_email, customer_name, "founding", "stripe-purchase", "", "", new_sold) } } - // Professional preorder - if str_contains(body, "\"professional\"") { - if !str_eq(customer_email, "") { - waitlist_upsert(customer_email, customer_name, "professional", "stripe-purchase", "", "", 0) + if str_eq(plan, "professional") && !str_eq(customer_email, "") { + waitlist_upsert(customer_email, customer_name, "professional", "stripe-purchase", "", "", 0) + } + + // 2. Stamp stripe_customer_id on the waitlist row + let wb_sb_url: String = state_get("__supabase_project_url__") + let wb_sb_key: String = state_get("__supabase_service_key__") + if !str_eq(customer_email, "") && !str_eq(customer_id, "") && !str_eq(wb_sb_key, "") { + let cid_safe: String = str_replace(customer_id, "\"", "\\\"") + let update_row: String = "{\"stripe_customer_id\":\"" + cid_safe + "\"}" + let _wl_resp: String = supabase_insert(wb_sb_url, wb_sb_key, "waitlist?email=eq." + customer_email + "&on_conflict=email,plan", update_row) + } + + // 3. Auto-provision Supabase account + send magic-link invite + // so the buyer can sign in. Stripe Customer is updated with + // metadata.supabase_user_id so the cross-reference is durable + // even if the email changes later. + if !str_eq(customer_email, "") && !str_eq(wb_sb_url, "") && !str_eq(wb_sb_key, "") { + let email_safe: String = str_replace(customer_email, "\"", "\\\"") + let name_safe: String = str_replace(customer_name, "\"", "\\\"") + let plan_safe: String = str_replace(plan, "\"", "\\\"") + let cid_safe2: String = str_replace(customer_id, "\"", "\\\"") + let invite_body: String = "{\"email\":\"" + email_safe + "\"" + + ",\"data\":{\"name\":\"" + name_safe + "\"" + + ",\"plan\":\"" + plan_safe + "\"" + + ",\"stripe_customer_id\":\"" + cid_safe2 + "\"}" + + ",\"redirect_to\":\"https://neurontechnologies.ai/account?welcome=1\"}" + let invite_resp: String = supabase_admin_invite(wb_sb_url, wb_sb_key, invite_body) + println("[webhook] supabase invite for " + customer_email + ": " + invite_resp) + // If the response includes a user id, propagate it back to + // the Stripe customer so future operations have the link. + let new_user_id: String = json_get(invite_resp, "id") + if !str_eq(new_user_id, "") && !str_eq(customer_id, "") { + let stripe_key: String = state_get("__stripe_secret_key__") + if !str_eq(stripe_key, "") { + let stripe_auth: String = "Bearer " + stripe_key + let cust_url: String = "https://api.stripe.com/v1/customers/" + customer_id + let cust_body: String = "metadata[supabase_user_id]=" + new_user_id + let _cust_resp: String = http_post_form_auth(cust_url, cust_body, stripe_auth) + } } } - // Save stripe_customer_id to waitlist row - if !str_eq(customer_email, "") && !str_eq(customer_id, "") { - let wb_sb_url: String = state_get("__supabase_project_url__") - let wb_sb_key: String = state_get("__supabase_service_key__") - if !str_eq(wb_sb_key, "") { - let cid_safe: String = str_replace(customer_id, "\"", "\\\"") - let update_row: String = "{\"stripe_customer_id\":\"" + cid_safe + "\"}" - supabase_insert(wb_sb_url, wb_sb_key, "waitlist?email=eq." + customer_email + "&on_conflict=email,plan", update_row) - } - } - // Forward to license API for key provisioning. + + // 4. Forward to license API for key provisioning let license_api: String = state_get("__license_api_url__") if !str_eq(license_api, "") { let resp: String = http_post(license_api + "/api/v1/webhooks/stripe", body) diff --git a/src/styles.el b/src/styles.el index a150af6..bc81332 100644 --- a/src/styles.el +++ b/src/styles.el @@ -2063,7 +2063,7 @@ fn page_close() -> String { var turnstileWidgetId = null; var turnstileVerified = false; var isOpen = false; - var MAX = 5; + var MAX = 10; // Persistent session storage - survives page refreshes function loadSession() { @@ -2136,7 +2136,11 @@ fn page_close() -> String { if (!el) return; var remaining = MAX - msgCount; el.textContent = remaining + ' question' + (remaining === 1 ? '' : 's') + ' left'; - el.style.color = remaining <= 5 ? '#c44' : 'rgba(255,255,255,0.45)'; + // Always bold white. The number itself counts down naturally; the + // colour-shift was loud and made the chat feel rationed even when + // there were plenty of turns left. + el.style.color = '#ffffff'; + el.style.fontWeight = '700'; } window.neuronDemoReset = function() { @@ -2161,7 +2165,10 @@ fn page_close() -> String { if (btn) btn.style.display = isOpen ? 'none' : ''; var msgs = document.getElementById('neuron-demo-messages'); if (isOpen && turnstileVerified && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) { - // Restore previous conversation if it exists + // Restore previous conversation from localStorage so the visitor + // picks up where they left off. Only greet once - the `greeted` flag + // sticks in the session so reopening the panel doesn't replay the + // canned hello. if (session.messages && session.messages.length > 0) { session.messages.forEach(function(m) { addMsg(m.role, m.text, true); }); var remaining = MAX - msgCount; @@ -2169,8 +2176,10 @@ fn page_close() -> String { var input = document.getElementById('neuron-demo-text'); if (input) { input.disabled = true; input.placeholder = 'Interaction limit reached'; } } - } else { + } else if (!session.greeted) { addMsg('ai', 'Hey. What is on your mind?', true); + session.greeted = true; + try { localStorage.setItem('neuron_demo_session', JSON.stringify(session)); } catch(e) {} } } var input = document.getElementById('neuron-demo-text'); @@ -2333,6 +2342,12 @@ fn page_close() -> String { }); // Activate local engram nodes relevant to this message var activated_nodes = _ra(session._m, msg); + // questions_remaining tells the soul how many turns the visitor has + // LEFT after this one. is_last_question is true when this turn is + // the visitor final turn under the rate limit, so the soul can + // close the conversation in voice instead of leaving them on a hard cap. + var questionsRemaining = (MAX - msgCount) - 1; + if (questionsRemaining < 0) questionsRemaining = 0; var r = await fetch('/api/demo', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -2342,7 +2357,9 @@ fn page_close() -> String { cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '', uid: session.uid || '', activated_nodes: activated_nodes, - engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0 + engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0, + questions_remaining: questionsRemaining, + is_last_question: questionsRemaining === 0 }) }); var d = await r.json();