Add landing page

This commit is contained in:
Jennie Robinson Faber 2025-11-03 11:17:51 +00:00
parent 3fea484585
commit bce86ee840
47 changed files with 7119 additions and 439 deletions

View file

@ -217,6 +217,42 @@
</div>
</div>
<!-- Series Ticketing Info -->
<div
v-if="series.tickets?.enabled"
class="px-6 py-3 bg-blue-50 dark:bg-blue-950/20 border-t border-default"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Icon name="heroicons:ticket" class="w-5 h-5 text-blue-600" />
<div>
<span class="text-sm font-medium text-default">
Series Pass Ticketing Enabled
</span>
<p class="text-xs text-dimmed">
<span v-if="series.tickets.public?.available">
Public: ${{ series.tickets.public.price || 0 }}
</span>
<span v-if="series.tickets.member?.available" class="ml-2">
| Members:
{{
series.tickets.member.isFree
? "Free"
: `$${series.tickets.member.price || 0}`
}}
</span>
</p>
</div>
</div>
<button
@click="manageSeriesTickets(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Manage Tickets
</button>
</div>
</div>
<!-- Series Actions -->
<div class="px-6 py-3 bg-muted border-t border-default">
<div class="flex justify-between items-center">
@ -224,6 +260,12 @@
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="manageSeriesTickets(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Ticketing
</button>
<button
@click="editSeries(series)"
class="text-sm text-primary hover:text-primary font-medium"
@ -459,6 +501,384 @@
</div>
</div>
</div>
<!-- Series Ticketing Modal -->
<div
v-if="editingTicketsSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-highlighted">
Series Pass Ticketing
</h3>
<p class="text-sm text-muted">{{ editingTicketsData.title }}</p>
</div>
<button
@click="cancelTicketsEdit"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<!-- Enable Ticketing Toggle -->
<div class="p-4 bg-muted rounded-lg">
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Enable Series Pass Ticketing</span
>
<p class="text-xs text-dimmed">
Allow users to purchase a pass for all events in this series
</p>
</div>
</label>
</div>
<div v-if="editingTicketsData.tickets.enabled" class="space-y-6">
<!-- Ticketing Behavior -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-highlighted">
Ticketing Behavior
</h4>
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.requiresSeriesTicket"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Require Series Pass</span
>
<p class="text-xs text-dimmed">
Users must buy the series pass; individual event tickets are
not available
</p>
</div>
</label>
<label class="flex items-start cursor-pointer">
<input
v-model="
editingTicketsData.tickets.allowIndividualEventTickets
"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Allow Individual Event Tickets</span
>
<p class="text-xs text-dimmed">
Users can attend single events without buying the full
series pass
</p>
</div>
</label>
</div>
<!-- Member Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Member Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.member.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.member.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.member.name"
placeholder="e.g., Member Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<div class="flex items-center gap-2">
<UInput
v-model.number="editingTicketsData.tickets.member.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="editingTicketsData.tickets.member.isFree"
class="flex-1 w-full"
/>
<label
class="flex items-center whitespace-nowrap cursor-pointer"
>
<input
v-model="editingTicketsData.tickets.member.isFree"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
<span class="ml-1 text-xs text-muted">Free</span>
</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.member.description"
placeholder="Describe what's included with the member series pass..."
:rows="2"
class="w-full"
/>
</div>
</div>
</div>
<!-- Public Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Public Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.public.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.public.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.public.name"
placeholder="e.g., Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<UInput
v-model.number="editingTicketsData.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.public.description"
placeholder="Describe what's included with the public series pass..."
:rows="2"
class="w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Quantity Available</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.quantity
"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.public.sold || 0 }} sold,
{{ editingTicketsData.tickets.public.reserved || 0 }}
reserved
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Price (Optional)</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.earlyBirdPrice
"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div
v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0"
>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Deadline</label
>
<UInput
v-model="
editingTicketsData.tickets.public.earlyBirdDeadline
"
type="datetime-local"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Price increases to ${{
editingTicketsData.tickets.public.price
}}
after this date
</p>
</div>
</div>
</div>
<!-- Capacity Management -->
<div class="border border-default rounded-lg p-4">
<h4 class="text-sm font-semibold text-highlighted mb-4">
Capacity Management
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Total Capacity</label
>
<UInput
v-model.number="editingTicketsData.tickets.capacity.total"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Maximum series pass holders across all types
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Currently Reserved</label
>
<UInput
v-model.number="
editingTicketsData.tickets.capacity.reserved
"
type="number"
min="0"
disabled
class="w-full bg-accented"
/>
<p class="text-xs text-dimmed mt-1">
Auto-calculated during checkout
</p>
</div>
</div>
</div>
<!-- Waitlist Configuration -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">Waitlist</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Enable Waitlist</span>
<input
v-model="editingTicketsData.tickets.waitlist.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div v-if="editingTicketsData.tickets.waitlist.enabled">
<label class="block text-sm font-medium text-default mb-2"
>Max Waitlist Size</label
>
<UInput
v-model.number="editingTicketsData.tickets.waitlist.maxSize"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }}
people currently on waitlist
</p>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
<button
@click="cancelTicketsEdit"
class="px-4 py-2 text-muted hover:text-default"
>
Cancel
</button>
<button
@click="saveTicketsEdit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Ticketing Settings
</button>
</div>
</div>
</div>
</div>
</template>
@ -477,6 +897,43 @@ const editingSeriesData = ref({
type: "workshop_series",
totalEvents: null,
});
const editingTicketsSeriesId = ref(null);
const editingTicketsData = ref({
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
});
// Fetch series data
const {
@ -715,6 +1172,116 @@ const deleteSeries = async (series) => {
}
};
// Ticketing management functions
const manageSeriesTickets = (series) => {
editingTicketsSeriesId.value = series.id;
// Deep clone the series data to avoid mutating the original
editingTicketsData.value = {
title: series.title,
tickets: {
enabled: series.tickets?.enabled || false,
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
allowIndividualEventTickets:
series.tickets?.allowIndividualEventTickets !== false,
currency: series.tickets?.currency || "CAD",
member: {
available: series.tickets?.member?.available !== false,
isFree: series.tickets?.member?.isFree !== false,
price: series.tickets?.member?.price || 0,
name: series.tickets?.member?.name || "Member Series Pass",
description: series.tickets?.member?.description || "",
},
public: {
available: series.tickets?.public?.available || false,
name: series.tickets?.public?.name || "Series Pass",
description: series.tickets?.public?.description || "",
price: series.tickets?.public?.price || 0,
quantity: series.tickets?.public?.quantity || null,
sold: series.tickets?.public?.sold || 0,
reserved: series.tickets?.public?.reserved || 0,
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
? new Date(series.tickets.public.earlyBirdDeadline)
.toISOString()
.slice(0, 16)
: "",
},
capacity: {
total: series.tickets?.capacity?.total || null,
reserved: series.tickets?.capacity?.reserved || 0,
},
waitlist: {
enabled: series.tickets?.waitlist?.enabled || false,
maxSize: series.tickets?.waitlist?.maxSize || null,
entries: series.tickets?.waitlist?.entries || [],
},
},
};
};
const cancelTicketsEdit = () => {
editingTicketsSeriesId.value = null;
editingTicketsData.value = {
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
};
};
const saveTicketsEdit = async () => {
try {
// Update the series with new ticketing configuration
await $fetch("/api/admin/series/tickets", {
method: "PUT",
body: {
id: editingTicketsSeriesId.value,
tickets: editingTicketsData.value.tickets,
},
});
await refresh();
cancelTicketsEdit();
alert("Ticketing settings updated successfully");
} catch (error) {
console.error("Failed to update ticketing settings:", error);
alert(
`Failed to update ticketing settings: ${error.data?.statusMessage || error.message}`,
);
}
};
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering