From a80728f0a8c958c3423d573e2b2fc5fb867c19d0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 12:34:59 +0100 Subject: [PATCH] feat(signup): unify cadence UX across accept-invite, join, and account Extract shared SignupFlowOverlay component. Static "Monthly Contribution" label on all three contribution inputs (was misleadingly dynamic). "Per Year"/"Per Month" toggle copy; Per Year default on accept-invite, Per Month default on join. Live billing-summary card on both signup flows. Welcome-heading on dashboard via ?welcome=1 for new signups. $0-member polish on account page (hide payment-history + Solidarity Fund prompts). State-aware contribution-change hint. Invite accept now creates Helcim customer and sets auth cookie server-side for both free and paid branches. Pre-registrant invite + /join signup flows manually verified against Cleo Nguyen preReg and $0-$50 variants. --- app/components/SignupFlowOverlay.vue | 191 ++++++++++++++++++++ app/pages/accept-invite.vue | 253 ++++++++++++++++----------- app/pages/join.vue | 209 ++++++---------------- app/pages/member/account.vue | 28 ++- app/pages/member/dashboard.vue | 11 +- app/pages/welcome.vue | 2 +- docs/LAUNCH_READINESS.md | 59 +++---- scripts/mint-invite-link.cjs | 48 +++++ scripts/reset-invite.cjs | 34 ++++ server/api/invite/accept.post.js | 39 +++-- 10 files changed, 553 insertions(+), 321 deletions(-) create mode 100644 app/components/SignupFlowOverlay.vue create mode 100644 scripts/mint-invite-link.cjs create mode 100644 scripts/reset-invite.cjs diff --git a/app/components/SignupFlowOverlay.vue b/app/components/SignupFlowOverlay.vue new file mode 100644 index 0000000..f29559f --- /dev/null +++ b/app/components/SignupFlowOverlay.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/app/pages/accept-invite.vue b/app/pages/accept-invite.vue index d29bdbc..0e4aada 100644 --- a/app/pages/accept-invite.vue +++ b/app/pages/accept-invite.vue @@ -124,7 +124,39 @@
- + +
+
+ + +
+
+ + +
+
+
+ +
+
$ Pay what you can. If you can pay more, you're making room for someone who can't.

+
+
+

+ You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

+

+ Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

+
+
+
- -
-

Payment Information

-

- You're signing up for ${{ form.contributionAmount }} CAD / month. -

- -
{{ errorMessage }}
- - -

Click "Complete Payment" below to open the secure payment modal and verify your payment method.

-
- -
- - -
-
- - -
-

Welcome to Ghost Guild!

-

Your membership is active. Redirecting to your dashboard...

- Go to Dashboard -
+ +
@@ -221,6 +244,7 @@ import { definePageMeta({ layout: false }); const { checkMemberStatus } = useAuth(); +const { initializeHelcimPay, verifyPayment } = useHelcimPay(); const step = ref("verifying"); const errorMessage = ref(""); @@ -228,6 +252,10 @@ const isSubmitting = ref(false); const preRegId = ref(null); const preRegEmail = ref(""); const token = ref(""); +const cadence = ref("annual"); // 'monthly' | 'annual' + +// Flow overlay state — drives the post-submit full-viewport UI. +const flowState = ref("idle"); const form = reactive({ name: "", @@ -255,10 +283,29 @@ const needsPayment = computed(() => { const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); -// Helcim state for paid tiers -const memberId = ref(null); -const customerId = ref(null); -const customerCode = ref(null); +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; +}); + +const formatContributionAmount = (amount) => { + if (!amount || amount === 0) return "$0"; + const display = cadence.value === "annual" ? amount * 12 : amount; + const suffix = cadence.value === "annual" ? "/yr" : "/mo"; + return `$${display}${suffix}`; +}; + +const flowSummary = computed(() => ({ + name: form.name, + email: preRegEmail.value, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + +const closeFlowOverlay = () => { + flowState.value = "idle"; + errorMessage.value = ""; +}; // On mount: extract token from fragment, verify onMounted(async () => { @@ -294,9 +341,10 @@ const handleAccept = async () => { isSubmitting.value = true; errorMessage.value = ""; + flowState.value = "creating-customer"; try { - const result = await $fetch("/api/invite/accept", { + const accepted = await $fetch("/api/invite/accept", { method: "POST", body: { preRegistrationId: preRegId.value, @@ -311,90 +359,53 @@ const handleAccept = async () => { }, }); - memberId.value = result.member.id; - - if (result.requiresPayment) { - // Need to create Helcim customer + payment - await setupPayment(result.member); - } else { + if (!accepted.requiresPayment) { // Free tier — session cookie already set by accept endpoint await checkMemberStatus(); - step.value = "confirmation"; - setTimeout(() => navigateTo("/welcome"), 3000); + flowState.value = "success"; + setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); + return; } - } catch (err) { - errorMessage.value = - err.data?.statusMessage || "Failed to accept invitation. Please try again."; - } finally { - isSubmitting.value = false; - } -}; -const setupPayment = async (member) => { - try { - // Create Helcim customer for paid tier - const customerResult = await $fetch("/api/helcim/customer", { + // Paid tier: initialize HelcimPay session, auto-open modal + flowState.value = "opening-payment"; + await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0); + + const paymentResult = await verifyPayment(); + if (!paymentResult?.success) { + throw new Error("Payment was not completed."); + } + + flowState.value = "processing-payment"; + await $fetch("/api/helcim/verify-payment", { method: "POST", body: { - name: member.name, - email: member.email, - circle: member.circle, - contributionAmount: form.contributionAmount, + cardToken: paymentResult.cardToken, + customerId: accepted.customerId, }, }); - customerId.value = customerResult.customerId; - customerCode.value = customerResult.customerCode; + flowState.value = "creating-subscription"; + await $fetch("/api/helcim/subscription", { + method: "POST", + body: { + customerId: accepted.customerId, + customerCode: accepted.customerCode, + contributionAmount: form.contributionAmount, + cadence: cadence.value, + cardToken: paymentResult.cardToken, + }, + }); - // Initialize HelcimPay.js - const { initializeHelcimPay } = useHelcimPay(); - await initializeHelcimPay(customerId.value, customerCode.value, 0); - - step.value = "payment"; + await checkMemberStatus(); + flowState.value = "success"; + setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); } catch (err) { errorMessage.value = - err.data?.statusMessage || "Failed to set up payment. Please try again."; - } -}; - -const processPayment = async () => { - if (isSubmitting.value) return; - - isSubmitting.value = true; - errorMessage.value = ""; - - try { - const { verifyPayment } = useHelcimPay(); - const paymentResult = await verifyPayment(); - - if (paymentResult.success) { - // Verify payment on server - await $fetch("/api/helcim/verify-payment", { - method: "POST", - body: { - cardToken: paymentResult.cardToken, - customerId: customerId.value, - }, - }); - - // Create subscription - await $fetch("/api/helcim/subscription", { - method: "POST", - body: { - customerId: customerId.value, - customerCode: customerCode.value, - contributionAmount: form.contributionAmount, - cardToken: paymentResult.cardToken, - }, - }); - - await checkMemberStatus(); - step.value = "confirmation"; - setTimeout(() => navigateTo("/welcome"), 3000); - } - } catch (err) { - errorMessage.value = - err.message || "Payment verification failed. Please try again."; + err.data?.statusMessage || + err.message || + "Failed to accept invitation. Please try again."; + flowState.value = "error"; } finally { isSubmitting.value = false; } @@ -558,6 +569,26 @@ textarea.form-input { color: var(--ink-soft, currentColor); } +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -565,6 +596,12 @@ textarea.form-input { gap: 8px; } +.cadence-radios { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + .circle-radio { position: relative; } @@ -682,5 +719,9 @@ textarea.form-input { .circle-radios { grid-template-columns: 1fr; } + + .cadence-radios { + grid-template-columns: 1fr; + } } diff --git a/app/pages/join.vue b/app/pages/join.vue index 24cad1a..55c3c49 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -194,7 +194,7 @@ value="monthly" >
@@ -206,14 +206,14 @@ value="annual" >
$ @@ -240,6 +240,16 @@

{{ guidanceLabel }}

+
+
+

+ You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

+

+ Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

+
+
@@ -503,23 +441,18 @@ const needsPayment = computed(() => { const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); -const flowStepLabel = computed(() => { - switch (flowState.value) { - case "creating-customer": - case "opening-payment": - return "Step 2 of 3 — Payment"; - case "processing-payment": - case "creating-subscription": - return "Step 2 of 3 — Finalizing"; - case "success": - return "Step 3 of 3 — Welcome"; - case "error": - return "Something went wrong"; - default: - return ""; - } +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; }); +const flowSummary = computed(() => ({ + name: form.name, + email: form.email, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + const handleSubmit = async () => { if (isSubmitting.value || !isFormValid.value) return; @@ -918,6 +851,26 @@ onUnmounted(() => { color: var(--ink-soft, currentColor); } +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -1073,26 +1026,6 @@ onUnmounted(() => { max-width: 600px; } -/* ---- DETAILS LIST (confirmation) ---- */ -.details-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.details-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 13px; -} -.details-row dt { - color: var(--text-faint); -} -.details-row dd { - color: var(--text-bright); - font-weight: 500; -} - /* ---- PAYMENT INSTRUCTION ---- */ .payment-instruction { font-size: 13px; @@ -1183,48 +1116,4 @@ onUnmounted(() => { } } -.join-flow-overlay { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(42, 32, 21, 0.72); /* --parch @ 72% */ - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.join-flow-card { - background: var(--bg); - border: 1px dashed var(--border); - padding: 32px; - max-width: 520px; - width: 100%; - max-height: calc(100vh - 48px); - overflow-y: auto; -} - -.join-flow-step { - font-family: var(--font-body); - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-dim); - margin-bottom: 12px; -} - -.join-flow-heading { - font-family: var(--font-display); - font-size: 1.5rem; - color: var(--text-bright); - margin: 0 0 16px; -} - -.join-flow-body { - font-family: var(--font-body); - color: var(--text); - line-height: 1.5; - margin: 0; -} diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index cf0c3ed..5bccb66 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -72,9 +72,9 @@ - + @@ -200,11 +200,11 @@

Cancelling closes your account and ends access to member-only - spaces, including Slack. If you're cancelling because of a + spaces, including Slack.

@@ -242,7 +242,7 @@

$ @@ -269,8 +269,8 @@

{{ guidanceLabel }}

-
- Changes take effect on your next billing cycle +
+ {{ contributionChangeHint }}