feat: pre-registrant management and invitation system
Admin interface to review, filter, and batch-invite the 95 pre-registrants from Baby Ghosts. Accept-invitation page pre-fills their data and collects circle, pronouns, motivation, contribution tier, and agreement before creating their member record.
This commit is contained in:
parent
bab53cec9e
commit
501be10bfe
15 changed files with 1896 additions and 1 deletions
617
app/pages/accept-invite.vue
Normal file
617
app/pages/accept-invite.vue
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<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"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label" for="accept-tier">Monthly Contribution</label>
|
||||
<select
|
||||
id="accept-tier"
|
||||
v-model="form.contributionTier"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="0">$0/mo -- Access is a right</option>
|
||||
<option value="5">$5/mo -- A small gesture</option>
|
||||
<option value="15">$15/mo -- Sustaining (suggested)</option>
|
||||
<option value="30">$30/mo -- Supporting</option>
|
||||
<option value="50">$50/mo -- Solidarity</option>
|
||||
</select>
|
||||
<p class="field-note">Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.agreedToTerms"
|
||||
/>
|
||||
<span>
|
||||
I've read and agree to the
|
||||
<NuxtLink to="/agreement" target="_blank">Member Agreement</NuxtLink>
|
||||
and
|
||||
<NuxtLink to="/guidelines" target="_blank">Code of Conduct</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.contributionTier }} 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 } 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: "",
|
||||
contributionTier: "15",
|
||||
agreedToTerms: false,
|
||||
});
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.name && form.circle && form.contributionTier && form.agreedToTerms;
|
||||
});
|
||||
|
||||
const needsPayment = computed(() => {
|
||||
return requiresPayment(form.contributionTier);
|
||||
});
|
||||
|
||||
// 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,
|
||||
contributionTier: form.contributionTier,
|
||||
agreedToTerms: form.agreedToTerms,
|
||||
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,
|
||||
contributionTier: form.contributionTier,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
contributionTier: form.contributionTier,
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---- 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue