ghostguild-org/app/components/SeriesPassPurchase.vue
Jennie Robinson Faber 1da76b11cb fix(series): replace phantom Tailwind on SeriesPassPurchase
Error state and main registration card swap bg-ember-*/border-ember-* and
bg-guild-*/border-guild-* utilities for design tokens in a scoped style
block. Error state uses the codebase's --ember + 8% color-mix pattern;
registration card uses --surface + dashed --border per the zine spec.
2026-04-29 20:22:35 +01:00

371 lines
11 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="error-state p-6">
<h3 class="error-state__heading text-lg font-semibold mb-2">
Unable to Load Series Pass
</h3>
<p class="error-state__body">{{ 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="registration-form 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"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<div class="flex items-start gap-3">
<Icon
name="heroicons:sparkles"
class="w-5 h-5 flex-shrink-0 mt-0.5"
style="color: var(--candle)"
/>
<div>
<div class="font-semibold mb-1" style="color: var(--candle)">
Member Benefit
</div>
<div class="text-sm" style="color: var(--candle)">
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.
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
</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 { initializeSeriesTicketPayment, 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 initializeSeriesTicketPayment(
props.seriesId,
form.value.email,
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,
}
);
// Refresh client auth state if server signed us in (guest upgrade)
if (purchaseResponse?.signedIn) {
await useAuth().checkMemberStatus();
}
// 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",
duration: 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",
duration: 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>
<style scoped>
.error-state {
background: color-mix(in srgb, var(--ember) 8%, transparent);
border: 1px dashed var(--ember);
}
.error-state__heading,
.error-state__body {
color: var(--ember);
}
.registration-form {
background: var(--surface);
border: 1px dashed var(--border);
}
</style>