ghostguild-org/app/components/SeriesPassPurchase.vue
Jennie Robinson Faber 15329e3e84 refactor(events): gate member benefits on hasMemberAccess
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
2026-04-18 17:06:17 +01:00

352 lines
10 KiB
Vue

<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"
/>
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
Unable to Load Series Pass
</h3>
<p class="text-ember-400">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="passInfo">
<!-- Already Registered State -->
<div v-if="passInfo.alreadyRegistered" class="dashed-box p-6">
<div class="section-label mb-2">Series Pass</div>
<p class="text-[--text]">You're registered for this series.</p>
<p v-if="passInfo.registration?.eventsIncluded !== undefined" class="text-[--text-dim] text-sm mt-1">
Registered for {{ passInfo.registration.eventsIncluded }} event{{ passInfo.registration.eventsIncluded !== 1 ? 's' : '' }} in this series.
</p>
</div>
<!-- Series Pass Card (only when ticket data is available) -->
<EventSeriesTicketCard
v-else-if="passInfo.ticket"
:ticket="passInfo.ticket"
:availability="passInfo.availability"
:available="passInfo.available"
:already-registered="false"
: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-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-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 class="space-y-6" @submit.prevent="handleSubmit">
<!-- 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-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
>
<div class="flex items-start gap-3">
<Icon
name="heroicons:sparkles"
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
/>
<div>
<div class="font-semibold text-candlelight-300 mb-1">
Member Benefit
</div>
<div class="text-sm text-candlelight-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 { initializeTicketPayment, 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, then re-fetch if userEmail becomes available (auth loads after mount)
onMounted(async () => {
await fetchPassInfo();
});
watch(() => props.userEmail, async (newEmail, oldEmail) => {
if (newEmail && !oldEmail) {
form.value.email = newEmail;
form.value.name = props.userName || form.value.name;
await fetchPassInfo();
}
});
const fetchPassInfo = async () => {
loading.value = true;
error.value = null;
try {
const email = form.value.email || props.userEmail;
const url = email
? `/api/series/${props.seriesId}/tickets/available?email=${encodeURIComponent(email)}`
: `/api/series/${props.seriesId}/tickets/available`;
const response = await $fetch(url);
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 initializeTicketPayment(
props.seriesId,
form.value.email,
passInfo.value.ticket.price,
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 purchaseBody = {
name: form.value.name,
email: form.value.email,
ticketType: passInfo.value.ticket.type,
};
if (transactionId) purchaseBody.paymentId = transactionId;
const purchaseResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/purchase`,
{
method: "POST",
body: purchaseBody,
}
);
// 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>