auto-signup webhook + chat polish (10q, bold-white, last-q awareness)
Webhook handler now reacts to payment_intent.succeeded and
setup_intent.succeeded (in addition to the legacy
checkout.session.completed) and auto-provisions a Supabase account
for every successful purchase via /auth/v1/invite. The buyer gets a
magic-link email; landing on /account?welcome=1 with that link signs
them in and the plan card renders.
Stripe Customer is updated with metadata.supabase_user_id so the
cross-reference is durable. New stub: supabase_admin_invite() in
web_stubs.c (POST {project_url}/auth/v1/invite with service-key
bearer auth + apikey header).
Chat widget:
* MAX 5 → 10 questions per session
* countdown is now bold white at all times; the red threshold
at <=5 was loud and made the chat feel rationed
* greeted-once flag in localStorage so reopening the panel
doesn't replay the canned hello (only first open greets)
* questions_remaining + is_last_question travel to the soul on
each turn so it can close in voice on the final turn instead
of leaving the visitor at a hard cap
Soul side of the last-turn handshake is still a TODO - the wrapper
plumbs the fields through but soul-demo.el has to be updated to
read and act on them.
This commit is contained in:
+81
-21
@@ -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)
|
||||
|
||||
+22
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user