ghostguild-org/app/pages/join.vue

786 lines
26 KiB
Vue

<template>
<div>
<!-- Page Header - Context aware -->
<PageHeader
v-if="!isAuthenticated"
title="Join Ghost Guild"
subtitle="Become a member of our community and start building a more worker-centric future for games."
theme="gray"
size="large"
/>
<PageHeader
v-else
title="You're Already a Member!"
:subtitle="`Welcome back, ${memberData?.name || 'member'}. You're already part of Ghost Guild in the ${memberData?.circle || 'community'} circle.`"
theme="gray"
size="large"
/>
<!-- Membership Sign Up Form -->
<section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Membership Sign Up
</h2>
<p class="text-lg text-[--ui-text]">
Choose your circle to connect with others at your stage. Choose your
contribution based on what you can afford. Everyone gets full
access.
</p>
</div>
<!-- Step Indicators -->
<div class="flex justify-center mb-8">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 1
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
1
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 1 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Information
</span>
</div>
<div v-if="needsPayment" class="w-16 h-1 bg-neutral-200">
<div
class="h-full bg-neutral-900 transition-all"
:style="{ width: currentStep >= 2 ? '100%' : '0%' }"
/>
</div>
<div v-if="needsPayment" class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 2
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
2
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 2 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Payment
</span>
</div>
<div class="w-16 h-1 bg-neutral-200">
<div
class="h-full bg-neutral-900 transition-all"
:style="{ width: currentStep >= 3 ? '100%' : '0%' }"
/>
</div>
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 3
? 'bg-neutral-900 text-neutral-50'
: 'bg-neutral-200 text-neutral-500',
]"
>
<span v-if="needsPayment">3</span>
<span v-else>2</span>
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 3 ? 'text-[--ui-text]' : 'text-neutral-500'
"
>
Confirmation
</span>
</div>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="mb-6">
<UAlert color="red" :title="errorMessage" />
</div>
<!-- Step 1: Information -->
<div v-if="currentStep === 1" class="bg-[--ui-bg-elevated]">
<UForm :state="form" class="space-y-8" @submit="handleSubmit">
<!-- Personal Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Full Name" name="name" required>
<UInput
v-model="form.name"
placeholder="Enter your full name"
size="xl"
class="w-full"
/>
</UFormField>
<UFormField label="Email Address" name="email" required>
<UInput
v-model="form.email"
type="email"
size="xl"
class="w-full"
placeholder="Enter your email address"
/>
</UFormField>
</div>
<!-- Circle Selection -->
<div>
<h3 class="text-lg font-semibold mb-4">Choose Your Circle</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label
v-for="option in circleOptions"
:key="option.value"
class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md"
:class="
form.circle === option.value
? 'border-neutral-900 bg-[--ui-bg]'
: 'border-neutral-200 hover:border-neutral-400'
"
>
<input
v-model="form.circle"
type="radio"
:value="option.value"
name="circle"
class="mb-3"
/>
<div class="font-medium text-lg mb-2">{{ option.label }}</div>
<div class="text-sm text-[--ui-text-muted]">
{{ option.description }}
</div>
</label>
</div>
<p class="text-sm text-[--ui-text-muted] mt-3 italic">
Not sure? Start with Community - you can always move.
</p>
</div>
<!-- Contribution Selection -->
<div>
<UFormField
label="Choose Your Monthly Contribution"
name="contributionTier"
required
>
<USelect
v-model="form.contributionTier"
:items="contributionOptions"
value-key="value"
size="xl"
class="w-full"
/>
</UFormField>
</div>
<!-- Submit Button -->
<div class="flex justify-center pt-6">
<UButton
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
size="xl"
class="px-12"
>
{{
needsPayment ? "Continue to Payment" : "Complete Registration"
}}
</UButton>
</div>
</UForm>
</div>
<!-- Step 2: Payment -->
<div
v-if="currentStep === 2"
class="bg-[--ui-bg-elevated] rounded-xl p-8"
>
<div class="mb-6">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Payment Information
</h3>
<p class="text-[--ui-text-muted]">
You're signing up for the {{ selectedTier.label }} plan
</p>
<p class="text-lg font-semibold text-[--ui-text] mt-2">
${{ selectedTier.amount }} CAD / month
</p>
</div>
<!-- Payment Instructions -->
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6">
<p class="text-[--ui-text]">
Click "Complete Payment" below to open the secure payment modal
and verify your payment method.
</p>
</div>
<!-- Action Buttons -->
<div class="flex justify-between pt-6">
<UButton
variant="outline"
size="lg"
@click="goBack"
:disabled="isSubmitting"
>
Back
</UButton>
<UButton size="lg" :loading="isSubmitting" @click="processPayment">
Complete Payment
</UButton>
</div>
</div>
<!-- Step 3: Confirmation -->
<div
v-if="currentStep === 3"
class="bg-[--ui-bg-elevated] rounded-xl p-8"
>
<div class="text-center">
<div
class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"
>
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-2xl font-bold text-[--ui-text] mb-4">
Welcome to Ghost Guild!
</h3>
<div v-if="successMessage" class="mb-6">
<UAlert color="green" :title="successMessage" />
</div>
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6 text-left">
<h4 class="font-semibold mb-3">Membership Details:</h4>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-[--ui-text-muted]">Name:</dt>
<dd class="font-medium">{{ form.name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[--ui-text-muted]">Email:</dt>
<dd class="font-medium">{{ form.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[--ui-text-muted]">Circle:</dt>
<dd class="font-medium capitalize">{{ form.circle }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[--ui-text-muted]">Contribution:</dt>
<dd class="font-medium">{{ selectedTier.label }}</dd>
</div>
<div v-if="customerCode" class="flex justify-between">
<dt class="text-[--ui-text-muted]">Member ID:</dt>
<dd class="font-medium">{{ customerCode }}</dd>
</div>
</dl>
</div>
<p class="text-[--ui-text-muted] mb-4">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p>
<div class="bg-[--ui-bg] rounded-lg p-4 mb-8">
<p class="text-[--ui-text] text-center">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<UButton to="/member/dashboard" size="lg" class="px-8">
Go to Dashboard Now
</UButton>
<UButton variant="outline" size="lg" @click="resetForm">
Register Another Member
</UButton>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Member Info Section - Shows for logged-in members -->
<section v-if="isAuthenticated" class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl">
<div class="bg-[--ui-bg-elevated] rounded-xl p-8 mb-8">
<h2 class="text-2xl font-bold text-[--ui-text] mb-6">
Your Membership
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-[--ui-bg] rounded-lg p-6">
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
Circle
</h3>
<p class="text-xl font-semibold text-[--ui-text] capitalize">
{{ memberData?.circle || "Community" }}
</p>
</div>
<div class="bg-[--ui-bg] rounded-lg p-6">
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
Contribution
</h3>
<p class="text-xl font-semibold text-[--ui-text]">
${{ memberData?.contributionTier || "0" }} CAD/month
</p>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<UButton to="/member/dashboard" size="lg">
Go to Dashboard
</UButton>
<UButton to="/member/profile" variant="outline" size="lg">
Edit Profile
</UButton>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6">
<h3 class="text-lg font-semibold text-[--ui-text] mb-3">
Want to change your circle or contribution?
</h3>
<p class="text-[--ui-text] mb-4">
You can update your circle and adjust your monthly contribution at
any time from your profile settings.
</p>
<UButton to="/member/profile" variant="soft" color="primary">
Update Membership Settings
</UButton>
</div>
</UContainer>
</section>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
How Ghost Guild Works
</h2>
<p class="text-lg text-[--ui-text]">
Every member gets everything. Your circle helps you find relevant
content and peers. Your contribution helps sustain our solidarity
economy.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Full Access -->
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Full Access
</h3>
<ul class="text-[--ui-text] space-y-2">
<li>Complete resource library</li>
<li>All workshops and events</li>
<li>Slack community</li>
<li>Voting rights</li>
<li>Peer support opportunities</li>
</ul>
</div>
<!-- Circle-Specific Guidance -->
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Circle-Specific Guidance
</h3>
<ul class="text-[--ui-text] space-y-2">
<li>Resources for your stage</li>
<li>Connection with peers</li>
<li>Workshop recommendations</li>
<li>Support for your challenges</li>
</ul>
</div>
</div>
</div>
</UContainer>
</section>
<!-- How to Join -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="space-y-8">
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
1
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Pick your circle
</h3>
<p class="text-[--ui-text]">
Where are you in your co-op journey? Select based on where you
are in your cooperative journey - exploring, building, or
practicing. Not sure? Start with Community.
</p>
</div>
</div>
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
2
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Choose your contribution
</h3>
<p class="text-[--ui-text]">
What can you afford? ($0-50+/month) Choose based on your
financial capacity. From $0 for those who need support to $50+
for those who can sponsor others. You can adjust anytime.
</p>
</div>
</div>
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
3
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Join the community
</h3>
<p class="text-[--ui-text]">
Get access to everything. Fill out your profile, agree to our
community guidelines, and complete payment (if applicable).
You'll get access to our community as soon as we review your
application.
</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { getCircleOptions } from "~/config/circles";
import {
getContributionOptions,
requiresPayment,
getContributionTierByValue,
} from "~/config/contributions";
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
// Check authentication status on mount
onMounted(async () => {
await checkMemberStatus();
});
// Form state
const form = reactive({
email: "",
name: "",
circle: "community",
contributionTier: "15",
billingAddress: {
street: "",
city: "",
province: "",
postalCode: "",
country: "CA",
},
});
// UI state
const isSubmitting = ref(false);
const currentStep = ref(1); // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation
const errorMessage = ref("");
const successMessage = ref("");
// Helcim state
const customerId = ref(null);
const customerCode = ref(null);
const subscriptionData = ref(null);
const paymentToken = ref(null);
// Circle options from central config
const circleOptions = getCircleOptions();
// Contribution options from central config
const contributionOptions = getContributionOptions();
// Initialize composables
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
// Form validation
const isFormValid = computed(() => {
return form.name && form.email && form.circle && form.contributionTier;
});
// Check if payment is required
const needsPayment = computed(() => {
return requiresPayment(form.contributionTier);
});
// Get selected tier info
const selectedTier = computed(() => {
return getContributionTierByValue(form.contributionTier);
});
// Step 1: Create customer
const handleSubmit = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
// Create customer in Helcim
const response = await $fetch("/api/helcim/customer", {
method: "POST",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionTier: form.contributionTier,
billingAddress: form.billingAddress,
},
});
if (response.success) {
console.log("Customer response:", response);
customerId.value = response.customerId;
customerCode.value = response.customerCode;
// Token is now set as httpOnly cookie by the server
// No need to manually set cookie on client side
// Move to next step
if (needsPayment.value) {
currentStep.value = 2;
// Debug log
console.log(
"Customer ID:",
customerId.value,
"Customer Code:",
customerCode.value,
);
// Initialize HelcimPay.js session for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0);
} else {
// For free tier, create subscription directly
await createSubscription();
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to dashboard after a short delay
setTimeout(() => {
navigateTo("/member/dashboard");
}, 3000); // 3 second delay to show success message
}
}
} catch (error) {
console.error("Error creating customer:", error);
errorMessage.value =
error.data?.message || "Failed to create account. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Step 2: Process payment
const processPayment = async () => {
if (isSubmitting.value) return;
console.log("Starting payment process...");
isSubmitting.value = true;
errorMessage.value = "";
try {
console.log("Calling verifyPayment()...");
// Verify payment through HelcimPay.js
const paymentResult = await verifyPayment();
console.log("Payment result from HelcimPay:", paymentResult);
if (paymentResult.success) {
paymentToken.value = paymentResult.cardToken;
console.log("Payment successful, cardToken:", paymentResult.cardToken);
console.log("Calling verify-payment endpoint...");
// Verify payment on server
const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
console.log("Payment verification result:", verifyResult);
console.log("Calling createSubscription...");
// Create subscription (don't let subscription errors prevent form progression)
const subscriptionResult = await createSubscription(
paymentResult.cardToken,
);
if (!subscriptionResult || !subscriptionResult.success) {
console.warn(
"Subscription creation failed but payment succeeded:",
subscriptionResult?.error,
);
// Still progress to success page since payment worked
currentStep.value = 3;
successMessage.value =
"Payment successful! Subscription setup may need manual completion.";
}
}
} catch (error) {
console.error("Payment process error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
errorMessage.value =
error.message || "Payment verification failed. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Create subscription
const createSubscription = async (cardToken = null) => {
try {
console.log("Creating subscription with:", {
customerId: customerId.value,
contributionTier: form.contributionTier,
cardToken: cardToken ? "present" : "null",
});
const response = await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: form.contributionTier,
cardToken: cardToken,
},
});
console.log("Subscription creation response:", response);
if (response.success) {
subscriptionData.value = response.subscription;
console.log("Moving to step 3 - success!");
currentStep.value = 3;
successMessage.value = "Your membership has been activated successfully!";
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to dashboard after a short delay
setTimeout(() => {
navigateTo("/member/dashboard");
}, 3000); // 3 second delay to show success message
} else {
throw new Error("Subscription creation failed - response not successful");
}
} catch (error) {
console.error("Subscription creation error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
console.error(
"Subscription creation completely failed, but payment was successful",
);
// Don't throw error - let the calling function handle progression
return {
success: false,
error:
error.data?.message || error.message || "Failed to create subscription",
};
}
};
// Go back to previous step
const goBack = () => {
if (currentStep.value > 1) {
currentStep.value--;
errorMessage.value = "";
}
};
// Reset form
const resetForm = () => {
currentStep.value = 1;
customerId.value = null;
customerCode.value = null;
subscriptionData.value = null;
paymentToken.value = null;
errorMessage.value = "";
successMessage.value = "";
form.email = "";
form.name = "";
form.circle = "community";
form.contributionTier = "15";
};
// Cleanup on unmount
onUnmounted(() => {
cleanupHelcimPay();
});
</script>