detail page port
This commit is contained in:
141
components/event/Info.vue
Normal file
141
components/event/Info.vue
Normal 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>
|
||||
27
components/event/Recirc.vue
Normal file
27
components/event/Recirc.vue
Normal 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
119
components/event/Rsvp.vue
Normal 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>
|
||||
73
components/event/list/Index.vue
Normal file
73
components/event/list/Index.vue
Normal 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>
|
||||
207
components/event/list/Item.vue
Normal file
207
components/event/list/Item.vue
Normal 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>
|
||||
Reference in New Issue
Block a user