Add landing page
This commit is contained in:
parent
3fea484585
commit
bce86ee840
47 changed files with 7119 additions and 439 deletions
333
app/components/SeriesPassPurchase.vue
Normal file
333
app/components/SeriesPassPurchase.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<template>
|
||||
<div class="series-pass-purchase">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="p-6 bg-red-900/20 rounded-xl border border-red-800"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-red-300 mb-2">
|
||||
Unable to Load Series Pass
|
||||
</h3>
|
||||
<p class="text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="passInfo">
|
||||
<!-- Series Pass Card -->
|
||||
<EventSeriesTicketCard
|
||||
:ticket="passInfo.ticket"
|
||||
:availability="passInfo.availability"
|
||||
:available="passInfo.available"
|
||||
:already-registered="passInfo.alreadyRegistered"
|
||||
:is-member="passInfo.memberInfo?.isMember"
|
||||
:total-events="seriesInfo.totalEvents"
|
||||
:events="seriesEvents"
|
||||
:public-price="passInfo.publicPrice"
|
||||
class="mb-8"
|
||||
@join-waitlist="handleJoinWaitlist"
|
||||
/>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<div
|
||||
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
||||
class="bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl border border-ghost-600 dark:border-ghost-600 p-6"
|
||||
>
|
||||
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
||||
{{
|
||||
passInfo.ticket.isFree
|
||||
? "Register for Series"
|
||||
: "Purchase Series Pass"
|
||||
}}
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-[--ui-text] mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter your full name"
|
||||
:disabled="processing"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-[--ui-text] mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
:disabled="processing || isLoggedIn"
|
||||
size="lg"
|
||||
/>
|
||||
<p v-if="isLoggedIn" class="text-xs text-[--ui-text-muted] mt-2">
|
||||
Using your member email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Member Benefits Notice -->
|
||||
<div
|
||||
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
||||
class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
name="heroicons:sparkles"
|
||||
class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-semibold text-green-300 mb-1">
|
||||
Member Benefit
|
||||
</div>
|
||||
<div class="text-sm text-green-400">
|
||||
This series pass is free for Ghost Guild members!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<UButton
|
||||
type="submit"
|
||||
block
|
||||
size="xl"
|
||||
:disabled="processing || !form.name || !form.email"
|
||||
:loading="processing"
|
||||
>
|
||||
<template v-if="processing">
|
||||
{{ paymentProcessing ? "Processing Payment..." : "Registering..." }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
passInfo.ticket.isFree
|
||||
? "Complete Registration"
|
||||
: `Pay ${formatPrice(passInfo.ticket.price, passInfo.ticket.currency)}`
|
||||
}}
|
||||
</template>
|
||||
</UButton>
|
||||
|
||||
<p class="text-xs text-[--ui-text-muted] text-center">
|
||||
By registering, you'll be automatically registered for all
|
||||
{{ seriesInfo.totalEvents }} events in this series.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useHelcimPay } from "~/composables/useHelcimPay";
|
||||
|
||||
const props = defineProps({
|
||||
seriesId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
seriesInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
// Expected: { id, title, totalEvents, type }
|
||||
},
|
||||
seriesEvents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
// Expected: Array of event objects
|
||||
},
|
||||
userEmail: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
userName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
||||
|
||||
const toast = useToast();
|
||||
const { initializePayment, verifyPayment } = useHelcimPay();
|
||||
|
||||
// State
|
||||
const loading = ref(true);
|
||||
const processing = ref(false);
|
||||
const paymentProcessing = ref(false);
|
||||
const error = ref(null);
|
||||
const passInfo = ref(null);
|
||||
|
||||
const form = ref({
|
||||
name: props.userName || "",
|
||||
email: props.userEmail || "",
|
||||
});
|
||||
|
||||
const isLoggedIn = computed(() => !!props.userEmail);
|
||||
|
||||
// Fetch series pass info on mount
|
||||
onMounted(async () => {
|
||||
await fetchPassInfo();
|
||||
});
|
||||
|
||||
const fetchPassInfo = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await $fetch(
|
||||
`/api/series/${props.seriesId}/tickets/available`
|
||||
);
|
||||
|
||||
passInfo.value = response;
|
||||
|
||||
// Pre-fill form if member info available
|
||||
if (response.memberInfo?.isMember) {
|
||||
form.value.name = response.memberInfo.name || form.value.name;
|
||||
form.value.email = response.memberInfo.email || form.value.email;
|
||||
}
|
||||
|
||||
// Also fetch public price for comparison
|
||||
if (response.memberInfo?.isMember && response.ticket?.type === "member") {
|
||||
// Make another request to get public pricing
|
||||
try {
|
||||
const publicResponse = await $fetch(
|
||||
`/api/series/${props.seriesId}/tickets/available?forcePublic=true`
|
||||
);
|
||||
if (publicResponse.ticket?.price) {
|
||||
passInfo.value.publicPrice = publicResponse.ticket.price;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not fetch public price for comparison");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching series pass info:", err);
|
||||
error.value =
|
||||
err.data?.statusMessage || "Failed to load series pass information";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
processing.value = true;
|
||||
|
||||
try {
|
||||
let transactionId = null;
|
||||
|
||||
// If payment is required, initialize Helcim and process payment
|
||||
if (!passInfo.value.ticket.isFree) {
|
||||
paymentProcessing.value = true;
|
||||
|
||||
// Initialize Helcim payment for series pass
|
||||
await initializePayment(
|
||||
form.value.email,
|
||||
passInfo.value.ticket.price,
|
||||
passInfo.value.ticket.currency || "CAD",
|
||||
{
|
||||
type: "series_pass",
|
||||
seriesId: props.seriesId,
|
||||
seriesTitle: props.seriesInfo.title,
|
||||
}
|
||||
);
|
||||
|
||||
// Show Helcim modal and complete payment
|
||||
const paymentResult = await verifyPayment();
|
||||
|
||||
if (!paymentResult.success) {
|
||||
throw new Error("Payment was not completed");
|
||||
}
|
||||
|
||||
transactionId = paymentResult.transactionId;
|
||||
paymentProcessing.value = false;
|
||||
}
|
||||
|
||||
// Complete series pass purchase
|
||||
const purchaseResponse = await $fetch(
|
||||
`/api/series/${props.seriesId}/tickets/purchase`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
paymentId: transactionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Show success message
|
||||
toast.add({
|
||||
title: "Series Pass Purchased!",
|
||||
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
||||
color: "green",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Emit success event
|
||||
emit("purchase-success", purchaseResponse);
|
||||
|
||||
// Refresh pass info to show registered state
|
||||
await fetchPassInfo();
|
||||
} catch (err) {
|
||||
console.error("Error purchasing series pass:", err);
|
||||
|
||||
const errorMessage =
|
||||
err.data?.statusMessage ||
|
||||
err.message ||
|
||||
"Failed to complete series pass purchase";
|
||||
|
||||
toast.add({
|
||||
title: "Purchase Failed",
|
||||
description: errorMessage,
|
||||
color: "red",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
emit("purchase-error", errorMessage);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
paymentProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinWaitlist = async () => {
|
||||
// TODO: Implement waitlist functionality
|
||||
toast.add({
|
||||
title: "Waitlist Coming Soon",
|
||||
description: "The waitlist feature is coming soon!",
|
||||
color: "blue",
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (price, currency = "CAD") => {
|
||||
if (price === 0) return "Free";
|
||||
return new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(price);
|
||||
};
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue