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
|
|
@ -124,7 +124,39 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<span class="contribution-currency">$</span>
|
||||
<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>
|
||||
</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">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
|
|
@ -180,34 +223,14 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Payment Step -->
|
||||
<div v-else-if="step === 'payment'" class="form-container">
|
||||
<h1>Payment Information</h1>
|
||||
<p class="form-intro">
|
||||
You're signing up for ${{ form.contributionAmount }} CAD / month.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<!-- Flow overlay: covers the page through payment + redirect. -->
|
||||
<SignupFlowOverlay
|
||||
:state="flowState"
|
||||
:summary="flowSummary"
|
||||
:error-message="errorMessage"
|
||||
dashboard-href="/member/dashboard?welcome=1"
|
||||
@close="closeFlowOverlay"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue