${{ memberData?.contributionAmount ?? 0 }} CAD/mo
@@ -221,6 +221,15 @@
const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
useMemberStatus();
+
+const route = useRoute();
+const isNewSignup = computed(() => route.query.welcome === "1");
+const welcomeTitle = computed(() => {
+ const name = memberData.value?.name || "";
+ return isNewSignup.value
+ ? `Welcome to Ghost Guild, ${name}`
+ : `Welcome back, ${name}`;
+});
const { completePayment, isProcessingPayment } = useMemberPayment();
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
diff --git a/app/pages/welcome.vue b/app/pages/welcome.vue
index 5022c3d..6a5db0b 100644
--- a/app/pages/welcome.vue
+++ b/app/pages/welcome.vue
@@ -1,3 +1,3 @@
diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md
index a787c19..c83d8b9 100644
--- a/docs/LAUNCH_READINESS.md
+++ b/docs/LAUNCH_READINESS.md
@@ -11,6 +11,20 @@ Single source of truth for work that must happen before cutover. P0 blocks launc
- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, noted in the deploy checklist for visibility.
- `main` is now caught up locally (2026-04-20): `feature/helcim-plan-consolidation` (40 commits) and `feature/contribution-amount-redesign` (17 commits) fast-forwarded in. Not pushed — site is not on Netlify yet.
- Helcim plan consolidation migration ran against prod 2026-04-18 (Monthly plan id `50302`, Annual plan id `50303`). **Contribution-amount migration has NOT yet been run against prod.**
+- Cadence/contribution UX unified across signup + edit surfaces 2026-04-20. Uncommitted in working tree — see "Cadence UX refinements" below.
+
+### Cadence UX refinements (2026-04-20, uncommitted)
+
+Shipped across `accept-invite.vue`, `join.vue`, `member/account.vue`, `welcome.vue`, `member/dashboard.vue`, and a new shared `SignupFlowOverlay.vue`:
+
+- **Shared SignupFlowOverlay component.** Extracted from `/join` progress overlay; now used by both `/join` and `/accept-invite`.
+- **Static "Monthly Contribution" label** on all three contribution inputs (previously dynamic — flipped to "Annual Contribution" when annual cadence was selected, which was misleading because the stored value is always the monthly base).
+- **"Per Year" / "Per Month"** toggle copy (was "Annual" / "Monthly"). On `/accept-invite`, Per Year is now the default; `/join` stays on Per Month by default.
+- **Live billing-summary card** below the contribution input on both signup flows — reads e.g. "You'll be charged $180 today ($15/month × 12). Then $180 every year, until you cancel."
+- **Welcome heading on dashboard** for new signups: `/member/dashboard?welcome=1` renders "Welcome to Ghost Guild, {name}" instead of "Welcome back, {name}". `/welcome` redirect now always carries the param; `/accept-invite` navigates to the dashboard with the param directly.
+- **$0 member polish on `/member/account`**: Payment History section hidden when `contributionAmount === 0` (stops showing "first charge will appear after your next billing cycle" to members with no charges). Solidarity-Fund sentence in the Danger Zone also hidden at $0.
+- **State-aware contribution-change hint** on `/member/account`: "You'll be charged $X today to start your subscription." ($0 → paid) / "Your paid subscription will be cancelled." (paid → $0) / "Changes apply on your next billing cycle." (paid → paid, different amount).
+- **Server-side invite accept** now creates the Helcim customer and sets the auth cookie before returning, for both free and paid branches.
---
@@ -75,40 +89,25 @@ Cannot be verified by Vitest. Both require a real browser + real Helcim test car
---
-- [ ] **Pre-registrant invite → accept flow with a paid contribution amount.** Exercises Helcim customer creation during acceptance. Un-deferred 2026-04-20 — the contribution-amount refactor that was expected to replace this flow has landed on `main`, so the flow is in its final shape.
+- [x] **Pre-registrant invite → accept flow with a paid contribution amount.** ✅ Passed 2026-04-20 — both Monthly $7 and Annual $15 variants completed end-to-end. DB verified programmatically: `contributionAmount` stored as Number, `billingCadence` correct, `helcimCustomerId` + `helcimSubscriptionId` populated, `status: active`, no `contributionTier` field, preReg transitioned to `accepted` with `memberId` set.
- **Setup:** In admin UI or mongosh, pick a `PreRegistration` entry (or insert one with a throwaway email). From `/admin/pre-registrants`, send an invite. In a second browser/incognito, open the invite email and click through to `/accept-invite?token=...`.
+- **Contribution-amount redesign end-to-end.** Covers the full surface of the `contributionTier` → `contributionAmount` rename.
- **Test — run twice:**
- 1. Monthly cadence, non-preset amount (e.g. `$7`).
- 2. Annual cadence, a preset amount (e.g. `$15`, expected Helcim `recurringAmount: 180`).
+ - [x] **Signup flows — `/join`:** ✅ Passed 2026-04-20. All 5 variants ran functionally clean (welcome-heading regression was caught, fixed via `?welcome=1` propagation through `/welcome`, not retested — trusted):
+ 1. `$0` Monthly — Member created with no Helcim subscription.
+ 2. `$5` Monthly (preset) — Helcim `recurringAmount: 5`.
+ 3. `$17` Monthly (non-preset) — Helcim `recurringAmount: 17`, `$15` chip label via `findLast`.
+ 4. `$17` Annual — Helcim `recurringAmount: 204`, `billingCadence: 'annual'`, Mongo stores monthly-equivalent `17`.
+ 5. `$50` Annual (top preset) — Helcim `recurringAmount: 600`.
- **Expect:**
- - `Member` doc created with `contributionAmount: 7` (Number, not String), correct `billingCadence`, `helcimCustomerId` populated, `status: 'active'` (or `pending_payment` if B1 hasn't been implemented yet — either is acceptable here, the point is a clean create).
- - Helcim customer exists and has a subscription with `recurringAmount` = amount (Monthly) or amount × 12 (Annual).
- - No `contributionTier` String field on the new Member doc.
- - Welcome email delivered via Resend.
- - Auto-login succeeds and lands on `/member/dashboard`.
+ - [ ] **Edit flows — `/member/account` as an active paid member:**
+ - Raise amount ($17 → $30). Confirm `updateHelcimSubscription` called with `recurringAmount: 30` (Monthly) or `360` (Annual).
+ - Lower amount ($30 → $5). Same assertion at the new values.
+ - ~~Switch cadence (Monthly $17 ↔ Annual $17).~~ **Deferred from launch.** Server (`update-contribution.post.js:184-189`) explicitly rejects cadence changes on existing subscriptions; no UI toggle exists on `/member/account`. Re-scope post-launch if/when we want to support cadence switch (would need Helcim subscription replacement flow, not a plain update).
- **Key files if debugging:** `server/api/invite/accept.post.js`, `app/pages/accept-invite.vue`, `server/api/helcim/customer.post.js`.
-
-- [ ] **Contribution-amount redesign end-to-end.** Covers the full surface of the `contributionTier` → `contributionAmount` rename.
-
- **Signup flows — `/join`:**
- 1. `$0` Monthly — should create Member with no Helcim subscription, `contributionAmount: 0`.
- 2. `$5` Monthly (preset) — Helcim subscription `recurringAmount: 5`.
- 3. `$17` Monthly (non-preset, between $15 and $30 chips) — Helcim subscription `recurringAmount: 17`, UI shows the `$15` chip's label via `findLast`.
- 4. `$17` Annual — Helcim subscription `recurringAmount: 204`, `billingCadence: 'annual'`, Mongo `contributionAmount: 17` (stores monthly-equivalent).
- 5. `$50` Annual (top preset) — Helcim subscription `recurringAmount: 600`.
-
- **Edit flows — `/member/account` as an active paid member:**
- - Raise amount ($17 → $30). Confirm `updateHelcimSubscription` called with `recurringAmount: 30` (Monthly) or `360` (Annual).
- - Lower amount ($30 → $5). Same assertion at the new values.
- - Switch cadence (Monthly $17 ↔ Annual $17). Confirm `billingCadence` updated and `recurringAmount` re-derived.
-
- **Admin flow — `/admin/members/[id]` edit:**
- - `contributionAmount` input accepts any non-negative whole dollar. Save writes Number to Mongo.
- - No chip UI here (admin is plain number input by design).
+ - [ ] **Admin flow — `/admin/members/[id]` edit:**
+ - `contributionAmount` input accepts any non-negative whole dollar. Save writes Number to Mongo.
+ - No chip UI here (admin is plain number input by design).
**Assert across all flows:**
- Mongo `contributionAmount` is always `Number`, never `String`.
diff --git a/scripts/mint-invite-link.cjs b/scripts/mint-invite-link.cjs
new file mode 100644
index 0000000..1b00c24
--- /dev/null
+++ b/scripts/mint-invite-link.cjs
@@ -0,0 +1,48 @@
+require('dotenv').config()
+const mongoose = require('mongoose')
+const jwt = require('jsonwebtoken')
+const { randomUUID } = require('crypto')
+
+const BASE_URL = process.argv[2]
+const EMAIL = process.argv[3] || 'jennie+cleonguyen@machinemagic.co'
+
+if (!BASE_URL) {
+ console.error('Usage: node scripts/mint-invite-link.cjs [email]')
+ process.exit(1)
+}
+
+const secret = process.env.NUXT_JWT_SECRET || process.env.JWT_SECRET
+if (!secret) {
+ console.error('Missing NUXT_JWT_SECRET / JWT_SECRET in .env')
+ process.exit(1)
+}
+
+;(async () => {
+ await mongoose.connect(process.env.MONGODB_URI)
+ const db = mongoose.connection.db
+
+ const preReg = await db.collection('preregistrations').findOne({ email: EMAIL })
+ if (!preReg) {
+ console.error(`No preregistration found for ${EMAIL}`)
+ await mongoose.disconnect()
+ process.exit(1)
+ }
+
+ const jti = randomUUID()
+ const token = jwt.sign(
+ { preRegistrationId: preReg._id.toString(), jti, type: 'prereg-invite' },
+ secret,
+ { expiresIn: '48h' },
+ )
+
+ await db.collection('preregistrations').updateOne(
+ { _id: preReg._id },
+ { $set: { magicLinkJti: jti, magicLinkJtiUsed: false, status: 'invited' } },
+ )
+
+ const link = `${BASE_URL.replace(/\/$/, '')}/accept-invite#${token}`
+ console.log('\nFresh invite link for', EMAIL, ':\n')
+ console.log(link, '\n')
+
+ await mongoose.disconnect()
+})()
diff --git a/scripts/reset-invite.cjs b/scripts/reset-invite.cjs
new file mode 100644
index 0000000..40588dd
--- /dev/null
+++ b/scripts/reset-invite.cjs
@@ -0,0 +1,34 @@
+require('dotenv').config()
+const mongoose = require('mongoose')
+
+;(async () => {
+ await mongoose.connect(process.env.MONGODB_URI)
+ const db = mongoose.connection.db
+
+ const email = 'jennie+cleonguyen@machinemagic.co'
+
+ const memberRes = await db.collection('members').deleteOne({ email })
+ console.log(`Deleted ${memberRes.deletedCount} member(s)`)
+
+ const preRegRes = await db.collection('preregistrations').updateOne(
+ { email },
+ {
+ $set: { status: 'pending', magicLinkJtiUsed: false },
+ $unset: { acceptedAt: '', memberId: '' },
+ }
+ )
+ console.log(`Reset ${preRegRes.modifiedCount} preRegistration(s)`)
+
+ const member = await db.collection('members').findOne({ email })
+ console.log('\nMember state after reset:')
+ console.log(JSON.stringify(member, null, 2))
+
+ const preReg = await db.collection('preregistrations').findOne(
+ { email },
+ { projection: { email: 1, status: 1, acceptedAt: 1, memberId: 1, magicLinkJtiUsed: 1 } }
+ )
+ console.log('\nPreRegistration state after reset:')
+ console.log(JSON.stringify(preReg, null, 2))
+
+ await mongoose.disconnect()
+})()
diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js
index a78764a..93b46e6 100644
--- a/server/api/invite/accept.post.js
+++ b/server/api/invite/accept.post.js
@@ -2,7 +2,9 @@ import jwt from 'jsonwebtoken'
import PreRegistration from '../../models/preRegistration.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
+import { setAuthCookie } from '../../utils/auth.js'
import { assignMemberNumber } from '../../utils/memberNumber.js'
+import { createHelcimCustomer } from '../../utils/helcim.js'
export default defineEventHandler(async (event) => {
const body = await validateBody(event, inviteAcceptSchema)
@@ -36,6 +38,18 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 409, statusMessage: 'A member with this email already exists' })
}
+ // For paid invites, create the Helcim customer up front so we can store the ID
+ // on the Member at creation time. Done before Member.create so a Helcim failure
+ // doesn't leave us with an orphan Member doc.
+ let helcimCustomer = null
+ if (body.contributionAmount > 0) {
+ helcimCustomer = await createHelcimCustomer({
+ customerType: 'PERSON',
+ contactName: body.name,
+ email: preReg.email,
+ })
+ }
+
// Create the member
const member = await Member.create({
email: preReg.email,
@@ -46,6 +60,7 @@ export default defineEventHandler(async (event) => {
contributionAmount: body.contributionAmount,
bio: body.motivation || undefined,
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
+ helcimCustomerId: helcimCustomer?.id,
agreement: { acceptedAt: new Date() },
})
@@ -65,22 +80,12 @@ export default defineEventHandler(async (event) => {
preRegistrationId: preReg._id,
})
- // For free tier, issue session and redirect to welcome
+ // Issue session cookie so the member is authenticated for any follow-up calls
+ // (Helcim initialize-payment for paid flow, dashboard fetches for free flow).
+ setAuthCookie(event, member)
+
+ // For free tier, redirect to welcome
if (body.contributionAmount === 0) {
- const sessionToken = jwt.sign(
- { memberId: member._id, email: member.email, tv: member.tokenVersion },
- config.jwtSecret,
- { expiresIn: '7d' },
- )
-
- setCookie(event, 'auth-token', sessionToken, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 60 * 60 * 24 * 7,
- })
-
return {
success: true,
requiresPayment: false,
@@ -96,10 +101,12 @@ export default defineEventHandler(async (event) => {
}
}
- // For paid tiers, return member info so frontend can proceed to Helcim payment
+ // For paid tiers, return member + Helcim customer so frontend can proceed to payment
return {
success: true,
requiresPayment: true,
+ customerId: helcimCustomer.id,
+ customerCode: helcimCustomer.customerCode,
member: {
id: member._id,
email: member.email,