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.
This commit is contained in:
parent
493be2f3bc
commit
a80728f0a8
10 changed files with 553 additions and 321 deletions
191
app/components/SignupFlowOverlay.vue
Normal file
191
app/components/SignupFlowOverlay.vue
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="state !== 'idle'" class="signup-flow-overlay">
|
||||||
|
<div class="signup-flow-card">
|
||||||
|
<div class="signup-flow-step">{{ stepLabel }}</div>
|
||||||
|
|
||||||
|
<template v-if="isProgress">
|
||||||
|
<h2 class="signup-flow-heading">{{ progressHeading }}</h2>
|
||||||
|
<p class="signup-flow-body">
|
||||||
|
Please don't close this window. This usually takes a few seconds.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="state === 'success'">
|
||||||
|
<h2 class="signup-flow-heading">Welcome to Ghost Guild!</h2>
|
||||||
|
<DashedBox :hoverable="false">
|
||||||
|
<div class="section-label" style="margin-bottom: 12px">
|
||||||
|
Membership Details
|
||||||
|
</div>
|
||||||
|
<dl class="details-list">
|
||||||
|
<div class="details-row">
|
||||||
|
<dt>Name</dt><dd>{{ summary?.name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details-row">
|
||||||
|
<dt>Email</dt><dd>{{ summary?.email }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details-row">
|
||||||
|
<dt>Circle</dt><dd class="capitalize">{{ summary?.circle }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details-row">
|
||||||
|
<dt>Contribution</dt><dd>{{ summary?.contribution }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</DashedBox>
|
||||||
|
<p class="signup-flow-body" style="margin-top: 16px">
|
||||||
|
We've sent a confirmation email to {{ summary?.email }}. Redirecting
|
||||||
|
you to your dashboard...
|
||||||
|
</p>
|
||||||
|
<div class="button-row" style="margin-top: 20px">
|
||||||
|
<NuxtLink :to="dashboardHref" class="btn btn-primary">
|
||||||
|
Go to Dashboard Now
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="state === 'error'">
|
||||||
|
<h2 class="signup-flow-heading">We couldn't complete your signup</h2>
|
||||||
|
<div v-if="errorMessage" class="error-box">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="button-row" style="margin-top: 20px">
|
||||||
|
<button class="btn" @click="$emit('close')">
|
||||||
|
Back to form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
state: { type: String, required: true },
|
||||||
|
summary: { type: Object, default: null },
|
||||||
|
errorMessage: { type: String, default: "" },
|
||||||
|
dashboardHref: { type: String, default: "/welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["close"]);
|
||||||
|
|
||||||
|
const PROGRESS_STATES = [
|
||||||
|
"creating-customer",
|
||||||
|
"opening-payment",
|
||||||
|
"processing-payment",
|
||||||
|
"creating-subscription",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isProgress = computed(() => PROGRESS_STATES.includes(props.state));
|
||||||
|
|
||||||
|
const progressHeading = computed(() => {
|
||||||
|
switch (props.state) {
|
||||||
|
case "creating-customer": return "Creating your account...";
|
||||||
|
case "opening-payment": return "Opening secure payment...";
|
||||||
|
case "processing-payment": return "Confirming your card...";
|
||||||
|
case "creating-subscription": return "Activating your membership...";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepLabel = computed(() => {
|
||||||
|
switch (props.state) {
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signup-flow-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: rgba(42, 32, 21, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-flow-heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-flow-body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
border: 1px dashed var(--ember);
|
||||||
|
color: var(--ember);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -124,7 +124,39 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label" for="accept-contribution">Monthly Contribution</label>
|
<label class="form-label">Billing Cadence</label>
|
||||||
|
<div class="cadence-radios">
|
||||||
|
<div class="circle-radio">
|
||||||
|
<input
|
||||||
|
id="accept-cadence-annual"
|
||||||
|
v-model="cadence"
|
||||||
|
type="radio"
|
||||||
|
name="cadence"
|
||||||
|
value="annual"
|
||||||
|
>
|
||||||
|
<label for="accept-cadence-annual">
|
||||||
|
<span class="circle-label-name">Per Year</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="circle-radio">
|
||||||
|
<input
|
||||||
|
id="accept-cadence-monthly"
|
||||||
|
v-model="cadence"
|
||||||
|
type="radio"
|
||||||
|
name="cadence"
|
||||||
|
value="monthly"
|
||||||
|
>
|
||||||
|
<label for="accept-cadence-monthly">
|
||||||
|
<span class="circle-label-name">Per Month</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label" for="accept-contribution">
|
||||||
|
Monthly Contribution
|
||||||
|
</label>
|
||||||
<div class="contribution-input-row">
|
<div class="contribution-input-row">
|
||||||
<span class="contribution-currency">$</span>
|
<span class="contribution-currency">$</span>
|
||||||
<input
|
<input
|
||||||
|
|
@ -152,6 +184,17 @@
|
||||||
<p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p>
|
<p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.contributionAmount > 0" class="form-group full-width">
|
||||||
|
<div class="billing-summary">
|
||||||
|
<p class="billing-summary-line">
|
||||||
|
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
||||||
|
</p>
|
||||||
|
<p class="billing-summary-line">
|
||||||
|
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input
|
<input
|
||||||
|
|
@ -180,34 +223,14 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Step -->
|
<!-- Flow overlay: covers the page through payment + redirect. -->
|
||||||
<div v-else-if="step === 'payment'" class="form-container">
|
<SignupFlowOverlay
|
||||||
<h1>Payment Information</h1>
|
:state="flowState"
|
||||||
<p class="form-intro">
|
:summary="flowSummary"
|
||||||
You're signing up for ${{ form.contributionAmount }} CAD / month.
|
:error-message="errorMessage"
|
||||||
</p>
|
dashboard-href="/member/dashboard?welcome=1"
|
||||||
|
@close="closeFlowOverlay"
|
||||||
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
|
/>
|
||||||
|
|
||||||
<DashedBox :hoverable="false">
|
|
||||||
<p class="payment-instruction">Click "Complete Payment" below to open the secure payment modal and verify your payment method.</p>
|
|
||||||
</DashedBox>
|
|
||||||
|
|
||||||
<div class="button-row" style="margin-top: 24px;">
|
|
||||||
<button class="btn" :disabled="isSubmitting" @click="step = 'form'">Back</button>
|
|
||||||
<button class="form-submit" :disabled="isSubmitting" @click="processPayment">
|
|
||||||
<span v-if="isSubmitting">Processing...</span>
|
|
||||||
<span v-else>Complete Payment</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation -->
|
|
||||||
<div v-else-if="step === 'confirmation'" class="center-box">
|
|
||||||
<h1>Welcome to Ghost Guild!</h1>
|
|
||||||
<p>Your membership is active. Redirecting to your dashboard...</p>
|
|
||||||
<NuxtLink to="/welcome" class="btn btn-primary" style="margin-top: 16px">Go to Dashboard</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -221,6 +244,7 @@ import {
|
||||||
definePageMeta({ layout: false });
|
definePageMeta({ layout: false });
|
||||||
|
|
||||||
const { checkMemberStatus } = useAuth();
|
const { checkMemberStatus } = useAuth();
|
||||||
|
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
||||||
|
|
||||||
const step = ref("verifying");
|
const step = ref("verifying");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
|
@ -228,6 +252,10 @@ const isSubmitting = ref(false);
|
||||||
const preRegId = ref(null);
|
const preRegId = ref(null);
|
||||||
const preRegEmail = ref("");
|
const preRegEmail = ref("");
|
||||||
const token = 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({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -255,10 +283,29 @@ const needsPayment = computed(() => {
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||||
|
|
||||||
// Helcim state for paid tiers
|
const firstCharge = computed(() => {
|
||||||
const memberId = ref(null);
|
const amount = form.contributionAmount || 0;
|
||||||
const customerId = ref(null);
|
return cadence.value === "annual" ? amount * 12 : amount;
|
||||||
const customerCode = ref(null);
|
});
|
||||||
|
|
||||||
|
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
|
// On mount: extract token from fragment, verify
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -294,9 +341,10 @@ const handleAccept = async () => {
|
||||||
|
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
flowState.value = "creating-customer";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await $fetch("/api/invite/accept", {
|
const accepted = await $fetch("/api/invite/accept", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
preRegistrationId: preRegId.value,
|
preRegistrationId: preRegId.value,
|
||||||
|
|
@ -311,90 +359,53 @@ const handleAccept = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
memberId.value = result.member.id;
|
if (!accepted.requiresPayment) {
|
||||||
|
|
||||||
if (result.requiresPayment) {
|
|
||||||
// Need to create Helcim customer + payment
|
|
||||||
await setupPayment(result.member);
|
|
||||||
} else {
|
|
||||||
// Free tier — session cookie already set by accept endpoint
|
// Free tier — session cookie already set by accept endpoint
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
step.value = "confirmation";
|
flowState.value = "success";
|
||||||
setTimeout(() => navigateTo("/welcome"), 3000);
|
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) => {
|
// Paid tier: initialize HelcimPay session, auto-open modal
|
||||||
try {
|
flowState.value = "opening-payment";
|
||||||
// Create Helcim customer for paid tier
|
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0);
|
||||||
const customerResult = await $fetch("/api/helcim/customer", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
name: member.name,
|
|
||||||
email: member.email,
|
|
||||||
circle: member.circle,
|
|
||||||
contributionAmount: form.contributionAmount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
customerId.value = customerResult.customerId;
|
|
||||||
customerCode.value = customerResult.customerCode;
|
|
||||||
|
|
||||||
// Initialize HelcimPay.js
|
|
||||||
const { initializeHelcimPay } = useHelcimPay();
|
|
||||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
|
||||||
|
|
||||||
step.value = "payment";
|
|
||||||
} 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();
|
const paymentResult = await verifyPayment();
|
||||||
|
if (!paymentResult?.success) {
|
||||||
|
throw new Error("Payment was not completed.");
|
||||||
|
}
|
||||||
|
|
||||||
if (paymentResult.success) {
|
flowState.value = "processing-payment";
|
||||||
// Verify payment on server
|
|
||||||
await $fetch("/api/helcim/verify-payment", {
|
await $fetch("/api/helcim/verify-payment", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken: paymentResult.cardToken,
|
||||||
customerId: customerId.value,
|
customerId: accepted.customerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create subscription
|
flowState.value = "creating-subscription";
|
||||||
await $fetch("/api/helcim/subscription", {
|
await $fetch("/api/helcim/subscription", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
customerId: customerId.value,
|
customerId: accepted.customerId,
|
||||||
customerCode: customerCode.value,
|
customerCode: accepted.customerCode,
|
||||||
contributionAmount: form.contributionAmount,
|
contributionAmount: form.contributionAmount,
|
||||||
|
cadence: cadence.value,
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken: paymentResult.cardToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
step.value = "confirmation";
|
flowState.value = "success";
|
||||||
setTimeout(() => navigateTo("/welcome"), 3000);
|
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value =
|
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 {
|
} finally {
|
||||||
isSubmitting.value = false;
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -558,6 +569,26 @@ textarea.form-input {
|
||||||
color: var(--ink-soft, currentColor);
|
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 ---- */
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -565,6 +596,12 @@ textarea.form-input {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cadence-radios {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.circle-radio {
|
.circle-radio {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
@ -682,5 +719,9 @@ textarea.form-input {
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cadence-radios {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@
|
||||||
value="monthly"
|
value="monthly"
|
||||||
>
|
>
|
||||||
<label for="cadence-monthly">
|
<label for="cadence-monthly">
|
||||||
<span class="circle-label-name">Monthly</span>
|
<span class="circle-label-name">Per Month</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle-radio">
|
<div class="circle-radio">
|
||||||
|
|
@ -206,14 +206,14 @@
|
||||||
value="annual"
|
value="annual"
|
||||||
>
|
>
|
||||||
<label for="cadence-annual">
|
<label for="cadence-annual">
|
||||||
<span class="circle-label-name">Annual</span>
|
<span class="circle-label-name">Per Year</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="join-contribution">
|
<label class="form-label" for="join-contribution">
|
||||||
{{ cadence === 'annual' ? 'Annual' : 'Monthly' }} Contribution
|
Monthly Contribution
|
||||||
</label>
|
</label>
|
||||||
<div class="contribution-input-row">
|
<div class="contribution-input-row">
|
||||||
<span class="contribution-currency">$</span>
|
<span class="contribution-currency">$</span>
|
||||||
|
|
@ -240,6 +240,16 @@
|
||||||
</div>
|
</div>
|
||||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="form.contributionAmount > 0" class="form-group">
|
||||||
|
<div class="billing-summary">
|
||||||
|
<p class="billing-summary-line">
|
||||||
|
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
||||||
|
</p>
|
||||||
|
<p class="billing-summary-line">
|
||||||
|
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input
|
<input
|
||||||
|
|
@ -334,84 +344,12 @@
|
||||||
<!-- Flow overlay: covers the page from form submit through redirect.
|
<!-- Flow overlay: covers the page from form submit through redirect.
|
||||||
Lives outside v-if/v-else so it survives the auth state flip that
|
Lives outside v-if/v-else so it survives the auth state flip that
|
||||||
fires after checkMemberStatus() at the end of createSubscription. -->
|
fires after checkMemberStatus() at the end of createSubscription. -->
|
||||||
<Teleport to="body">
|
<SignupFlowOverlay
|
||||||
<div v-if="flowState !== 'idle'" class="join-flow-overlay">
|
:state="flowState"
|
||||||
<div class="join-flow-card">
|
:summary="flowSummary"
|
||||||
<div class="join-flow-step">{{ flowStepLabel }}</div>
|
:error-message="errorMessage"
|
||||||
|
@close="closeFlowOverlay"
|
||||||
<!-- Progress states -->
|
/>
|
||||||
<template
|
|
||||||
v-if="[
|
|
||||||
'creating-customer',
|
|
||||||
'opening-payment',
|
|
||||||
'processing-payment',
|
|
||||||
'creating-subscription',
|
|
||||||
].includes(flowState)"
|
|
||||||
>
|
|
||||||
<h2 class="join-flow-heading">
|
|
||||||
{{
|
|
||||||
flowState === "creating-customer"
|
|
||||||
? "Creating your account..."
|
|
||||||
: flowState === "opening-payment"
|
|
||||||
? "Opening secure payment..."
|
|
||||||
: flowState === "processing-payment"
|
|
||||||
? "Confirming your card..."
|
|
||||||
: "Activating your membership..."
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
<p class="join-flow-body">
|
|
||||||
Please don't close this window. This usually takes a few seconds.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Success state -->
|
|
||||||
<template v-if="flowState === 'success'">
|
|
||||||
<h2 class="join-flow-heading">Welcome to Ghost Guild!</h2>
|
|
||||||
<DashedBox :hoverable="false">
|
|
||||||
<div class="section-label" style="margin-bottom: 12px">
|
|
||||||
Membership Details
|
|
||||||
</div>
|
|
||||||
<dl class="details-list">
|
|
||||||
<div class="details-row">
|
|
||||||
<dt>Name</dt><dd>{{ form.name }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="details-row">
|
|
||||||
<dt>Email</dt><dd>{{ form.email }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="details-row">
|
|
||||||
<dt>Circle</dt><dd class="capitalize">{{ form.circle }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="details-row">
|
|
||||||
<dt>Contribution</dt><dd>{{ formatContributionAmount(form.contributionAmount) }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</DashedBox>
|
|
||||||
<p class="join-flow-body" style="margin-top: 16px">
|
|
||||||
We've sent a confirmation email to {{ form.email }}. Redirecting
|
|
||||||
you to your dashboard...
|
|
||||||
</p>
|
|
||||||
<div class="button-row" style="margin-top: 20px">
|
|
||||||
<NuxtLink to="/welcome" class="form-submit">
|
|
||||||
Go to Dashboard Now
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<template v-if="flowState === 'error'">
|
|
||||||
<h2 class="join-flow-heading">We couldn't complete your signup</h2>
|
|
||||||
<div v-if="errorMessage" class="error-box">
|
|
||||||
{{ errorMessage }}
|
|
||||||
</div>
|
|
||||||
<div class="button-row" style="margin-top: 20px">
|
|
||||||
<button class="btn" @click="closeFlowOverlay">
|
|
||||||
Back to form
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -503,23 +441,18 @@ const needsPayment = computed(() => {
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||||
|
|
||||||
const flowStepLabel = computed(() => {
|
const firstCharge = computed(() => {
|
||||||
switch (flowState.value) {
|
const amount = form.contributionAmount || 0;
|
||||||
case "creating-customer":
|
return cadence.value === "annual" ? amount * 12 : amount;
|
||||||
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 flowSummary = computed(() => ({
|
||||||
|
name: form.name,
|
||||||
|
email: form.email,
|
||||||
|
circle: form.circle,
|
||||||
|
contribution: formatContributionAmount(form.contributionAmount),
|
||||||
|
}));
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value || !isFormValid.value) return;
|
if (isSubmitting.value || !isFormValid.value) return;
|
||||||
|
|
||||||
|
|
@ -918,6 +851,26 @@ onUnmounted(() => {
|
||||||
color: var(--ink-soft, currentColor);
|
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 ---- */
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -1073,26 +1026,6 @@ onUnmounted(() => {
|
||||||
max-width: 600px;
|
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 ---- */
|
||||||
.payment-instruction {
|
.payment-instruction {
|
||||||
font-size: 13px;
|
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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
<!-- PAYMENT HISTORY (only when a Helcim customer exists) -->
|
<!-- PAYMENT HISTORY (only when there's actually a paid plan) -->
|
||||||
<PageSection
|
<PageSection
|
||||||
v-if="memberData.helcimCustomerId"
|
v-if="memberData.helcimCustomerId && (memberData.contributionAmount || 0) > 0"
|
||||||
divider="top"
|
divider="top"
|
||||||
>
|
>
|
||||||
<div class="section-label">Payment history</div>
|
<div class="section-label">Payment history</div>
|
||||||
|
|
@ -200,11 +200,11 @@
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<p>
|
<p>
|
||||||
Cancelling closes your account and ends access to member-only
|
Cancelling closes your account and ends access to member-only
|
||||||
spaces, including Slack. If you're cancelling because of a
|
spaces, including Slack.<template v-if="(memberData.contributionAmount || 0) > 0"> If you're cancelling because of a
|
||||||
money issue, the
|
money issue, the
|
||||||
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
|
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
|
||||||
and the $0 tier are always available — reach out before you
|
and the $0 tier are always available — reach out before you
|
||||||
go.
|
go.</template>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="showCancelConfirm" class="cancel-confirm">
|
<div v-if="showCancelConfirm" class="cancel-confirm">
|
||||||
<p class="cancel-confirm-prompt">
|
<p class="cancel-confirm-prompt">
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="account-contribution">
|
<label class="form-label" for="account-contribution">
|
||||||
{{ cadence === 'annual' ? 'Annual' : 'Monthly' }} Contribution
|
Monthly Contribution
|
||||||
</label>
|
</label>
|
||||||
<div class="contribution-input-row">
|
<div class="contribution-input-row">
|
||||||
<span class="contribution-currency">$</span>
|
<span class="contribution-currency">$</span>
|
||||||
|
|
@ -269,8 +269,8 @@
|
||||||
</div>
|
</div>
|
||||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="tier-hint">
|
<div v-if="contributionChangeHint" class="tier-hint">
|
||||||
Changes take effect on your next billing cycle
|
{{ contributionChangeHint }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
|
|
@ -367,6 +367,20 @@ const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||||
|
|
||||||
|
const contributionChangeHint = computed(() => {
|
||||||
|
const current = Number(memberData.value?.contributionAmount || 0);
|
||||||
|
const next = Number(form.contributionAmount || 0);
|
||||||
|
if (current === next) return "";
|
||||||
|
if (current === 0 && next > 0) {
|
||||||
|
const firstCharge = cadence.value === "annual" ? next * 12 : next;
|
||||||
|
return `You'll be charged $${firstCharge} today to start your subscription.`;
|
||||||
|
}
|
||||||
|
if (current > 0 && next === 0) {
|
||||||
|
return "Your paid subscription will be cancelled.";
|
||||||
|
}
|
||||||
|
return "Changes apply on your next billing cycle.";
|
||||||
|
});
|
||||||
|
|
||||||
const currentContributionLabel = computed(() => {
|
const currentContributionLabel = computed(() => {
|
||||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
const amount = Number(memberData.value?.contributionAmount || 0);
|
||||||
if (!amount) return '$0';
|
if (!amount) return '$0';
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
<MemberStatusBanner />
|
<MemberStatusBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
|
<PageHeader :title="welcomeTitle">
|
||||||
<div class="dashboard-meta">
|
<div class="dashboard-meta">
|
||||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
||||||
|
|
@ -221,6 +221,15 @@
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||||
useMemberStatus();
|
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 { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
|
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
await navigateTo('/member/dashboard', { redirectCode: 301 })
|
await navigateTo('/member/dashboard?welcome=1', { redirectCode: 301 })
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- `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.**
|
- 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,38 +89,23 @@ 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:**
|
- [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. Monthly cadence, non-preset amount (e.g. `$7`).
|
1. `$0` Monthly — Member created with no Helcim subscription.
|
||||||
2. Annual cadence, a preset amount (e.g. `$15`, expected Helcim `recurringAmount: 180`).
|
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:**
|
- [ ] **Edit flows — `/member/account` as an active paid member:**
|
||||||
- `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`.
|
|
||||||
|
|
||||||
**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).
|
- Raise amount ($17 → $30). Confirm `updateHelcimSubscription` called with `recurringAmount: 30` (Monthly) or `360` (Annual).
|
||||||
- Lower amount ($30 → $5). Same assertion at the new values.
|
- Lower amount ($30 → $5). Same assertion at the new values.
|
||||||
- Switch cadence (Monthly $17 ↔ Annual $17). Confirm `billingCadence` updated and `recurringAmount` re-derived.
|
- ~~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).
|
||||||
|
|
||||||
**Admin flow — `/admin/members/[id]` edit:**
|
- [ ] **Admin flow — `/admin/members/[id]` edit:**
|
||||||
- `contributionAmount` input accepts any non-negative whole dollar. Save writes Number to Mongo.
|
- `contributionAmount` input accepts any non-negative whole dollar. Save writes Number to Mongo.
|
||||||
- No chip UI here (admin is plain number input by design).
|
- No chip UI here (admin is plain number input by design).
|
||||||
|
|
||||||
|
|
|
||||||
48
scripts/mint-invite-link.cjs
Normal file
48
scripts/mint-invite-link.cjs
Normal file
|
|
@ -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 <base-url> [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()
|
||||||
|
})()
|
||||||
34
scripts/reset-invite.cjs
Normal file
34
scripts/reset-invite.cjs
Normal file
|
|
@ -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()
|
||||||
|
})()
|
||||||
|
|
@ -2,7 +2,9 @@ import jwt from 'jsonwebtoken'
|
||||||
import PreRegistration from '../../models/preRegistration.js'
|
import PreRegistration from '../../models/preRegistration.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
|
import { setAuthCookie } from '../../utils/auth.js'
|
||||||
import { assignMemberNumber } from '../../utils/memberNumber.js'
|
import { assignMemberNumber } from '../../utils/memberNumber.js'
|
||||||
|
import { createHelcimCustomer } from '../../utils/helcim.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await validateBody(event, inviteAcceptSchema)
|
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' })
|
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
|
// Create the member
|
||||||
const member = await Member.create({
|
const member = await Member.create({
|
||||||
email: preReg.email,
|
email: preReg.email,
|
||||||
|
|
@ -46,6 +60,7 @@ export default defineEventHandler(async (event) => {
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
bio: body.motivation || undefined,
|
bio: body.motivation || undefined,
|
||||||
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
|
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
|
||||||
|
helcimCustomerId: helcimCustomer?.id,
|
||||||
agreement: { acceptedAt: new Date() },
|
agreement: { acceptedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -65,22 +80,12 @@ export default defineEventHandler(async (event) => {
|
||||||
preRegistrationId: preReg._id,
|
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) {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
requiresPayment: false,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
requiresPayment: true,
|
requiresPayment: true,
|
||||||
|
customerId: helcimCustomer.id,
|
||||||
|
customerCode: helcimCustomer.customerCode,
|
||||||
member: {
|
member: {
|
||||||
id: member._id,
|
id: member._id,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue