ghostguild-org/app/pages/accept-invite.vue

686 lines
17 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" 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 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>
<!-- 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>
</div>
</template>
<script setup>
import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
definePageMeta({ layout: false });
const { checkMemberStatus } = useAuth();
const step = ref("verifying");
const errorMessage = ref("");
const isSubmitting = ref(false);
const preRegId = ref(null);
const preRegEmail = ref("");
const token = ref("");
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));
// Helcim state for paid tiers
const memberId = ref(null);
const customerId = ref(null);
const customerCode = ref(null);
// 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 = "";
try {
const result = 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,
},
});
memberId.value = result.member.id;
if (result.requiresPayment) {
// Need to create Helcim customer + payment
await setupPayment(result.member);
} else {
// Free tier — session cookie already set by accept endpoint
await checkMemberStatus();
step.value = "confirmation";
setTimeout(() => navigateTo("/welcome"), 3000);
}
} 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", {
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();
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.";
} 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);
}
/* ---- CIRCLE RADIOS ---- */
.circle-radios {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.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;
}
}
</style>