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.
727 lines
18 KiB
Vue
727 lines
18 KiB
Vue
<template>
|
|
<div class="accept-invite">
|
|
<!-- Verifying -->
|
|
<div v-if="step === 'verifying'" class="center-box">
|
|
<div class="spinner" />
|
|
<p>Verifying your invitation...</p>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="step === 'error'" class="center-box">
|
|
<h1>Invitation Error</h1>
|
|
<div class="error-box">{{ errorMessage }}</div>
|
|
<NuxtLink to="/" class="btn" style="margin-top: 16px">Go to Ghost Guild</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Accept Form -->
|
|
<div v-else-if="step === 'form'" class="form-container">
|
|
<h1>Accept Your Invitation</h1>
|
|
<p class="form-intro">
|
|
Welcome to Ghost Guild. Review your info below, choose your circle and contribution, and you're in.
|
|
</p>
|
|
|
|
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
|
|
|
|
<form @submit.prevent="handleAccept">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label class="form-label" for="accept-name">Name</label>
|
|
<input
|
|
id="accept-name"
|
|
v-model="form.name"
|
|
class="form-input"
|
|
type="text"
|
|
required
|
|
>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="accept-email">Email</label>
|
|
<input
|
|
id="accept-email"
|
|
:value="preRegEmail"
|
|
class="form-input"
|
|
type="email"
|
|
disabled
|
|
>
|
|
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="accept-pronouns">Pronouns</label>
|
|
<input
|
|
id="accept-pronouns"
|
|
v-model="form.pronouns"
|
|
class="form-input"
|
|
type="text"
|
|
placeholder="e.g. they/them, she/her"
|
|
>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="accept-location">City / Region</label>
|
|
<input
|
|
id="accept-location"
|
|
v-model="form.location"
|
|
class="form-input"
|
|
type="text"
|
|
placeholder="e.g. Vancouver, BC"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group full-width">
|
|
<label class="form-label">Circle</label>
|
|
<p class="field-note" style="margin-bottom: 8px">Which circle fits where you are right now?</p>
|
|
<div class="circle-radios">
|
|
<div class="circle-radio community">
|
|
<input
|
|
id="circle-community"
|
|
v-model="form.circle"
|
|
type="radio"
|
|
name="circle"
|
|
value="community"
|
|
>
|
|
<label for="circle-community">
|
|
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
|
|
<span class="circle-label-desc">Learning about co-ops</span>
|
|
</label>
|
|
</div>
|
|
<div class="circle-radio founder">
|
|
<input
|
|
id="circle-founder"
|
|
v-model="form.circle"
|
|
type="radio"
|
|
name="circle"
|
|
value="founder"
|
|
>
|
|
<label for="circle-founder">
|
|
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
|
|
<span class="circle-label-desc">Building your studio</span>
|
|
</label>
|
|
</div>
|
|
<div class="circle-radio practitioner">
|
|
<input
|
|
id="circle-practitioner"
|
|
v-model="form.circle"
|
|
type="radio"
|
|
name="circle"
|
|
value="practitioner"
|
|
>
|
|
<label for="circle-practitioner">
|
|
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
|
|
<span class="circle-label-desc">Leading and mentoring</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group full-width">
|
|
<label class="form-label" for="accept-motivation">What brings you to Ghost Guild?</label>
|
|
<textarea
|
|
id="accept-motivation"
|
|
v-model="form.motivation"
|
|
class="form-input"
|
|
rows="3"
|
|
placeholder="2-3 sentences about what you're looking for"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group full-width">
|
|
<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
|
|
id="accept-contribution"
|
|
v-model.number="form.contributionAmount"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
inputmode="numeric"
|
|
class="contribution-input"
|
|
>
|
|
</div>
|
|
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
|
<button
|
|
v-for="preset in CONTRIBUTION_PRESETS"
|
|
:key="preset.amount"
|
|
type="button"
|
|
class="contribution-preset-chip"
|
|
@click="form.contributionAmount = preset.amount"
|
|
>
|
|
${{ preset.amount }}
|
|
</button>
|
|
</div>
|
|
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</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 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
|
|
v-model="form.agreedToGuidelines"
|
|
type="checkbox"
|
|
>
|
|
<span>
|
|
I agree to the Ghost Guild
|
|
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<button
|
|
class="form-submit"
|
|
type="submit"
|
|
:disabled="!isFormValid || isSubmitting"
|
|
>
|
|
<span v-if="isSubmitting">Processing...</span>
|
|
<span v-else-if="needsPayment">Continue to Payment</span>
|
|
<span v-else>Accept Invitation</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</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>
|
|
|
|
<script setup>
|
|
import {
|
|
requiresPayment,
|
|
CONTRIBUTION_PRESETS,
|
|
getGuidanceLabel,
|
|
} from "~/config/contributions";
|
|
|
|
definePageMeta({ layout: false });
|
|
|
|
const { checkMemberStatus } = useAuth();
|
|
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
|
|
|
const step = ref("verifying");
|
|
const errorMessage = ref("");
|
|
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: "",
|
|
pronouns: "",
|
|
location: "",
|
|
circle: "community",
|
|
motivation: "",
|
|
contributionAmount: 15,
|
|
agreedToGuidelines: false,
|
|
});
|
|
|
|
const isFormValid = computed(() => {
|
|
return (
|
|
form.name &&
|
|
form.circle &&
|
|
Number.isInteger(form.contributionAmount) &&
|
|
form.contributionAmount >= 0 &&
|
|
form.agreedToGuidelines
|
|
);
|
|
});
|
|
|
|
const needsPayment = computed(() => {
|
|
return requiresPayment(form.contributionAmount);
|
|
});
|
|
|
|
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
|
|
|
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 () => {
|
|
const hash = window.location.hash?.slice(1);
|
|
if (!hash) {
|
|
step.value = "error";
|
|
errorMessage.value = "No invitation token found. Please check your email link.";
|
|
return;
|
|
}
|
|
|
|
token.value = hash;
|
|
|
|
try {
|
|
const result = await $fetch("/api/invite/verify", {
|
|
method: "POST",
|
|
body: { token: hash },
|
|
});
|
|
|
|
preRegId.value = result.preRegistrationId;
|
|
preRegEmail.value = result.email;
|
|
form.name = result.name || "";
|
|
form.location = result.city || "";
|
|
step.value = "form";
|
|
} catch (err) {
|
|
step.value = "error";
|
|
errorMessage.value =
|
|
err.data?.statusMessage || "This invitation link is invalid or has expired.";
|
|
}
|
|
});
|
|
|
|
const handleAccept = async () => {
|
|
if (isSubmitting.value || !isFormValid.value) return;
|
|
|
|
isSubmitting.value = true;
|
|
errorMessage.value = "";
|
|
flowState.value = "creating-customer";
|
|
|
|
try {
|
|
const accepted = await $fetch("/api/invite/accept", {
|
|
method: "POST",
|
|
body: {
|
|
preRegistrationId: preRegId.value,
|
|
name: form.name,
|
|
pronouns: form.pronouns || undefined,
|
|
location: form.location || undefined,
|
|
circle: form.circle,
|
|
motivation: form.motivation || undefined,
|
|
contributionAmount: form.contributionAmount,
|
|
agreedToGuidelines: form.agreedToGuidelines,
|
|
token: token.value,
|
|
},
|
|
});
|
|
|
|
if (!accepted.requiresPayment) {
|
|
// Free tier — session cookie already set by accept endpoint
|
|
await checkMemberStatus();
|
|
flowState.value = "success";
|
|
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
|
return;
|
|
}
|
|
|
|
// 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: {
|
|
cardToken: paymentResult.cardToken,
|
|
customerId: accepted.customerId,
|
|
},
|
|
});
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
await checkMemberStatus();
|
|
flowState.value = "success";
|
|
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
|
} catch (err) {
|
|
errorMessage.value =
|
|
err.data?.statusMessage ||
|
|
err.message ||
|
|
"Failed to accept invitation. Please try again.";
|
|
flowState.value = "error";
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.accept-invite {
|
|
min-height: 100vh;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: "Commit Mono", monospace;
|
|
}
|
|
|
|
.center-box {
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
padding: 80px 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
.center-box h1 {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.center-box p {
|
|
font-size: 13px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.form-container {
|
|
max-width: 560px;
|
|
margin: 0 auto;
|
|
padding: 48px 24px 80px;
|
|
}
|
|
|
|
.form-container h1 {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.form-intro {
|
|
font-size: 13px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 28px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.error-box {
|
|
padding: 12px 16px;
|
|
border: 1px dashed var(--ember);
|
|
color: var(--ember);
|
|
font-size: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* ---- FORM ---- */
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.form-group.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.form-input,
|
|
.form-select {
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 13px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.form-input:focus,
|
|
.form-select:focus {
|
|
border-color: var(--candle);
|
|
outline: none;
|
|
}
|
|
|
|
.form-input:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
textarea.form-input {
|
|
resize: vertical;
|
|
}
|
|
|
|
.field-note {
|
|
font-size: 10px;
|
|
color: var(--text-faint);
|
|
margin-top: 4px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
|
.contribution-input-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
.contribution-currency {
|
|
font-weight: 600;
|
|
}
|
|
.contribution-input {
|
|
flex: 1;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--parch);
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 1rem;
|
|
}
|
|
.contribution-input:focus {
|
|
outline: none;
|
|
border-color: var(--candle);
|
|
}
|
|
.contribution-presets {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.contribution-preset-chip {
|
|
padding: 0.25rem 0.75rem;
|
|
background: transparent;
|
|
border: 1px dashed var(--parch);
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
}
|
|
.contribution-preset-chip:hover {
|
|
border-style: solid;
|
|
border-color: var(--candle);
|
|
}
|
|
.contribution-guidance {
|
|
margin-top: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-style: italic;
|
|
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;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.cadence-radios {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 10px;
|
|
}
|
|
|
|
.circle-radio {
|
|
position: relative;
|
|
}
|
|
|
|
.circle-radio input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.circle-radio label {
|
|
display: block;
|
|
padding: 12px;
|
|
border: 1px dashed var(--border);
|
|
cursor: pointer;
|
|
text-align: center;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.circle-radio input:checked + label {
|
|
border-color: var(--candle);
|
|
border-style: solid;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.circle-label-name {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.circle-label-desc {
|
|
display: block;
|
|
font-size: 10px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
/* ---- CHECKBOX ---- */
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.checkbox-label input {
|
|
margin-top: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.checkbox-label a {
|
|
color: var(--candle);
|
|
}
|
|
|
|
/* ---- SUBMIT BUTTON ---- */
|
|
.form-submit {
|
|
display: inline-block;
|
|
padding: 10px 24px;
|
|
background: var(--candle);
|
|
color: var(--bg);
|
|
border: none;
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
text-align: center;
|
|
}
|
|
|
|
.form-submit:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.button-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.payment-instruction {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* ---- SPINNER ---- */
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px dashed var(--candle);
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 12px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* ---- RESPONSIVE ---- */
|
|
@media (max-width: 600px) {
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.circle-radios {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.cadence-radios {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|