detail page port

This commit is contained in:
nicwands
2026-05-29 11:22:56 -04:00
parent b85d28c142
commit e22d75c50a
65 changed files with 7006 additions and 4044 deletions

141
components/event/Info.vue Normal file
View File

@@ -0,0 +1,141 @@
<template>
<div v-if="event" class="event-info">
<h1 class="title h4">{{ event.title }}</h1>
<p class="date p-s">{{ date }}</p>
<prismic-rich-text class="description" :field="event.description" />
<event-rsvp
v-if="rsvpExpanded"
class="theme-dark"
:event-name="event.title"
:event-date="date"
@success="rsvpExpanded = false"
/>
<div v-else class="btn-wrap">
<btn
v-if="type === 'rsvp'"
class="cta"
:hover="false"
@click="rsvpExpanded = true"
>
RSVP
</btn>
</div>
<div v-if="type === 'product' && product" class="purchase-wrap">
<number-input
v-model="ticketQuantity"
:min="1"
:max="maxTicketQuantity"
/>
<btn :href="checkoutUrl" :disabled="!checkoutUrl"
>Purchase Ticket</btn
>
</div>
</div>
</template>
<script setup>
import { format } from 'fecha'
import { ref, computed } from 'vue'
import Btn from '@/components/Btn.vue'
import NumberInput from '@/components/NumberInput.vue'
import EventRsvp from '@/components/event/Rsvp.vue'
import PrismicRichText from '@/components/prismic/RichText.vue'
import { resolveEventType } from '@/libs/resolveEventType'
const DEFAULT_MAX_TICKET_QUANTITY = 4
const props = defineProps({ event: Object, product: Object || undefined })
const rsvpExpanded = ref(false)
const ticketQuantity = ref(1)
const date = computed(() => {
if (!props.event?.date) return ''
const d = new Date(props.event.date)
return format(d, 'MM.DD.YY')
})
const type = computed(() => {
const prismicType = props.event?.event_type?.trim()
return resolveEventType(prismicType)
})
// Product checkout
const maxTicketQuantity = computed(
() => props.event?.max_ticket_quantity || DEFAULT_MAX_TICKET_QUANTITY,
)
const checkoutUrl = computed(() => {
const edges = props.product?.product?.variants?.edges ?? []
const variantNode = edges
.map((edge) => edge?.node)
.find((node) => node?.availableForSale)
if (!variantNode?.id) return null
const numericId = variantNode.id.split('/').pop()
if (!numericId) return null
const quantity = Math.max(
1,
Math.min(ticketQuantity.value ?? 1, maxTicketQuantity.value),
)
return `https://swangent.myshopify.com/cart/${numericId}:${quantity}`
})
</script>
<style lang="scss">
.event-info {
padding: desktop-vw(60px) var(--layout-margin);
.title {
margin-bottom: desktop-vw(20px);
}
.date {
margin-bottom: desktop-vw(30px);
}
.description {
margin-bottom: desktop-vw(30px);
width: desktop-vw(330px);
p {
@include p-xxs;
}
}
.btn-wrap {
display: flex;
flex-direction: column;
gap: 0.5em;
.btn {
cursor: pointer;
}
}
.purchase-wrap {
.btn {
width: 100%;
margin-top: desktop-vw(20px);
}
}
@include mobile {
padding: mobile-vw(30px) var(--layout-margin);
.title {
margin-bottom: mobile-vw(20px);
}
.date {
margin-bottom: mobile-vw(30px);
}
.description {
margin-bottom: mobile-vw(30px);
width: mobile-vw(330px);
}
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="event-recirc">
<h3 class="header p-xs">Upcoming Events</h3>
<event-list-item v-for="event in events" :event="event" type="recirc" />
</div>
</template>
<script setup>
const props = defineProps({ events: Array })
</script>
<style lang="scss">
.event-recirc {
.header {
background: var(--theme-fg);
color: var(--theme-bg);
padding: desktop-vw(6.5px) desktop-vw(14px);
}
@include mobile {
.header {
padding: mobile-vw(6.5px) mobile-vw(14px);
}
}
}
</style>

119
components/event/Rsvp.vue Normal file
View File

@@ -0,0 +1,119 @@
<template>
<div class="event-form">
<div v-if="success" class="success">
<p>Thank you</p>
</div>
<form v-else @submit.prevent="onRsvpSubmit">
<input v-model="name" type="text" placeholder="Name" required />
<input
v-model="email"
type="email"
placeholder="Email"
required
validate
/>
<btn type="submit" :loading="loading" secondary>Submit</btn>
</form>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import Btn from '@/components/Btn.vue'
import useKlaviyo from '@/composables/useKlaviyo'
const props = defineProps({
eventName: String,
eventDate: String,
})
const emit = defineEmits(['success'])
const { loading, success, error, subscribe, track } = useKlaviyo()
// Global "All RSVPs" list ID lives on the Events index doc
// const { data: eventsIndex } = await usePrisSingle('events')
// const allRsvpsListId = computed(
// () => eventsIndex.value?.data?.all_rsvps_klaviyo_list_id,
// )
const allRsvpsListId = ''
const name = ref('')
const email = ref('')
const onRsvpSubmit = async () => {
if (allRsvpsListId.value) {
await subscribe({
listId: allRsvpsListId.value,
email: email.value,
name: name.value,
source: 'website_event_rsvp',
})
}
// Append-only event for per-event segmentation in Klaviyo
try {
await track({
email: email.value,
metric: 'Submitted RSVP',
properties: {
event_name: props.eventName,
event_date: props.eventDate,
},
})
} catch (err) {
console.error('Failed to track RSVP event', err)
}
}
// Reset after success
watch(success, () => {
if (success.value) {
setTimeout(() => {
emit('success')
success.value = false
name.value = ''
email.value = ''
}, 2000)
}
})
</script>
<style lang="scss">
.event-form {
width: desktop-vw(390px);
height: 100%;
.success {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--theme-fg);
color: var(--theme-bg);
height: desktop-vw(78.5px);
}
input {
background: var(--theme-fg);
color: var(--theme-bg);
display: block;
width: 100%;
border: solid var(--theme-bg);
border-width: 1px 1px 0 1px;
text-align: center;
}
.btn {
width: 100%;
border: 1px solid var(--theme-bg);
}
@include mobile {
width: 100%;
.success {
height: mobile-vw(70px);
}
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="event-list">
<div class="filter-bar">
<h3 class="h4">Upcoming Events</h3>
<div class="filters">
<btn
:class="{ active: filter === 'current' }"
@click="onFilterClick('current')"
>Current</btn
>
<btn
:class="{ active: filter === 'past' }"
@click="onFilterClick('past')"
>Past</btn
>
</div>
</div>
<event-list-item
v-for="event in filteredEvents"
:event="event"
@hover="(e) => emit('eventHover', e)"
@blur="(e) => emit('eventBlur', e)"
/>
</div>
</template>
<script setup>
const props = defineProps({ events: Array })
const emit = defineEmits(['eventHover', 'eventBlur'])
const { filter, filteredEvents } = useFilteredEvents(props.events)
const onFilterClick = (label) => {
navigateTo({
query: {
filter: label,
},
})
}
</script>
<style lang="scss">
.event-list {
margin-top: desktop-vw(78px);
.filter-bar {
padding: desktop-vw(20px) var(--layout-margin);
display: flex;
justify-content: space-between;
.filters {
display: flex;
gap: desktop-vw(15px);
}
}
@include mobile {
margin-top: mobile-vw(75px);
.filter-bar {
padding: mobile-vw(20px) var(--layout-margin);
display: block;
.filters {
margin-top: mobile-vw(15px);
gap: mobile-vw(15px);
}
}
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div
v-if="event"
:class="[
`event-list-item type-${type}`,
{ 'mobile-active': mobileActive },
]"
@mouseenter="emit('hover', event.id)"
@mouseleave="emit('blur', event.id)"
ref="container"
>
<prismic-media
v-if="type === 'recirc'"
class="preview-image"
:image="event.preview_image"
:video="event.preview_video"
desktopSize="25vw"
/>
<p class="title p-l">{{ event.title }}</p>
<p class="date p-l">{{ date }}</p>
<prismic-rich-text
v-if="type === 'row'"
class="description entry"
:field="event.description"
/>
<div v-if="rsvpExpanded && type === 'row'" class="btn-wrap">
<event-rsvp
:event-name="event.title"
:event-date="date"
@success="rsvpExpanded = false"
/>
</div>
<div v-else class="btn-wrap">
<btn
v-if="eventType === 'rsvp' && type === 'row'"
class="btn-rsvp"
secondary
:hover="false"
@click="rsvpExpanded = true"
>RSVP</btn
>
<smart-link :field="link">
<btn secondary :hover="false">{{ link.text || 'View' }}</btn>
</smart-link>
</div>
</div>
</template>
<script setup>
import { resolveEventType } from '~/libs/resolveEventType'
import { format } from 'fecha'
const props = defineProps({
event: Object,
type: {
type: String,
default: () => 'row',
},
})
const emit = defineEmits(['hover', 'blur'])
const rsvpExpanded = ref(false)
const date = computed(() => {
if (!props.event?.date) return ''
const d = new Date(props.event.date)
return format(d, 'MM.DD.YY')
})
const link = computed(() => {
return {
...props.event,
type: 'event',
link_type: 'Document',
text: 'View Event',
}
})
const eventType = computed(() => {
const prismicType = props.event?.event_type?.trim()
return resolveEventType(prismicType)
})
// Mobile active states
const container = ref()
const mobileActive = ref(false)
useIntersectionObserver(
container,
([{ isIntersecting }]) => {
if (isIntersecting) {
emit('hover', props.event.id)
mobileActive.value = true
} else {
emit('blur', props.event.id)
mobileActive.value = false
}
},
{
rootMargin: '-70% 0px -30% 0px',
threshold: 0,
}
)
</script>
<style lang="scss">
.event-list-item {
&.type-row {
border-top: 1px solid var(--theme-fg);
padding: desktop-vw(32px) var(--layout-margin);
grid-template-columns: repeat(6, 1fr);
display: grid;
.title {
grid-column: 1 / span 2;
width: desktop-vw(600px);
}
.btn-wrap {
grid-column: 5 / span 2;
display: flex;
justify-content: flex-end;
align-items: flex-start;
}
.smart-link {
text-decoration: none !important;
}
.smart-link:not(.has-link) {
pointer-events: none;
}
.btn-rsvp.btn:not(.hover),
.has-link .btn:not(.hover) {
cursor: pointer;
}
.btn-rsvp {
margin-left: desktop-vw(15px);
margin-right: desktop-vw(15px);
}
@include hover {
background: var(--theme-fg);
color: var(--theme-bg);
}
@include mobile {
padding: mobile-vw(30px) var(--layout-margin);
grid-template-columns: 1fr auto;
.title {
grid-column: 1;
width: 100%;
}
.description {
grid-column: 1/-1;
margin-top: mobile-vw(31px);
width: mobile-vw(280px);
}
.btn-wrap {
grid-column: 1 / -1;
grid-row: 3;
justify-content: flex-start;
margin-top: mobile-vw(31px);
}
&.mobile-active {
background: var(--theme-fg);
color: var(--theme-bg);
}
}
}
&.type-recirc {
grid-template-columns: auto auto 1fr;
display: grid;
align-items: center;
column-gap: desktop-vw(20px);
border-bottom: 1px solid var(--theme-fg);
.preview-image {
grid-column: 1/-1;
border-top: 1px solid var(--theme-fg);
border-bottom: 1px solid var(--theme-fg);
}
p {
@include p-xs;
}
.btn-wrap {
grid-column: 1;
grid-row: 2;
.btn {
border-width: 0 1px 0 0;
background: var(--theme-fg);
color: var(--theme-bg);
}
}
.date {
grid-column: 2;
grid-row: 2;
}
.title {
grid-column: 3;
grid-row: 2;
}
}
}
</style>