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:
Will Anderson
2026-05-02 10:10:36 -05:00
parent 5e6b28b0e8
commit cae5028130
2 changed files with 103 additions and 26 deletions
+22 -5
View File
@@ -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();