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

View File

@@ -1,32 +1,165 @@
<template>
<component :is="tag" class="btn">
<svg-btn-outline />
<component
:class="[`btn p-xs size-${size}`, { secondary, number, hover }]"
:is="component"
:href="href"
:field="linkField"
>
<span class="text" v-if="linkField">
<span>{{ linkField.text }}</span>
<span>{{ linkField.text }}</span>
</span>
<span class="text" v-else-if="slots.default">
<span><slot /></span>
<span><slot /></span>
</span>
<slot />
<span v-if="loading" class="loading">
<svg-util-spinner />
</span>
</component>
</template>
<script setup>
import SvgBtnOutline from '@/components/svg/BtnOutline.vue'
import { useSlots, computed, resolveComponent } from 'vue'
import SvgUtilSpinner from '@/components/svg/util/Spinner.vue'
const props = defineProps({
tag: {
// Will render ADiv
href: [Object, String],
// Will render PrismicLink
linkField: Object,
tag: String,
size: {
type: String,
default: 'button',
default: () => 'small',
},
icon: {
type: String,
default: () => '',
},
secondary: {
type: Boolean,
default: () => false,
},
number: {
type: Boolean,
default: () => false,
},
loading: {
type: Boolean,
default: () => false,
},
hover: {
type: Boolean,
default: () => true,
},
})
const slots = useSlots()
const component = computed(() => {
if (props.tag) return props.tag
if (props.href) return 'a'
if (props.linkField) return resolveComponent('prismic-link')
return 'button'
})
</script>
<style lang="scss">
.btn {
text-transform: uppercase;
padding: desktop-vw(5px) desktop-vw(16px) desktop-vw(6px);
color: var(--grey-100);
display: inline-flex;
align-items: center;
justify-content: center;
padding: desktop-vw(5px) desktop-vw(20px);
border: 1px solid var(--theme-fg);
background: var(--theme-fg);
color: var(--theme-bg);
position: relative;
cursor: pointer;
transition:
color var(--td) var(--te),
background var(--td) var(--te);
@include text-flip(var(--td), var(--te));
&:hover {
color: var(--theme-accent);
@include mobile {
padding: mobile-vw(5px) mobile-vw(20px);
}
.text span {
display: block;
}
.loading {
position: absolute;
inset: 0;
background: inherit;
display: flex;
align-items: center;
svg {
width: 1em;
height: auto;
}
}
.icon {
margin-left: desktop-vw(6px);
width: desktop-vw(12px);
height: auto;
@include mobile {
margin-left: mobile-vw(6px);
width: mobile-vw(12px);
}
}
&.size-large {
font-size: desktop-vw(28px);
padding: desktop-vw(8.7px) desktop-vw(34.7px);
@include mobile {
font-size: mobile-vw(28px);
padding: mobile-vw(8.7px) mobile-vw(34.7px);
}
}
&.secondary {
background: var(--theme-bg);
color: var(--theme-fg);
}
&.number {
padding: desktop-vw(5px) desktop-vw(10px);
@include mobile {
padding: mobile-vw(5px) mobile-vw(10px);
}
}
&.hover {
&:hover,
&.active,
&.router-link-active,
&.router-link-exact-active {
color: var(--theme-fg);
background: var(--theme-bg);
&.secondary {
background: var(--theme-fg);
color: var(--theme-bg);
}
}
}
&:not(.hover) {
cursor: default;
}
&:disabled,
&.disabled {
opacity: 0.16;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<main class="column-layout">
<div class="columns">
<lenis class="column one" instance="column-one">
<slot name="column-one" />
</lenis>
<lenis class="column two" instance="column-two">
<slot name="column-two" />
</lenis>
</div>
</main>
</template>
<script setup>
import Lenis from './Lenis.vue'
</script>
<style lang="scss">
.column-layout {
/* padding-top: var(--header-height); */
/* height: var(--win-height); */
height: 100vh;
overflow: hidden;
.breadcrumbs {
padding-top: desktop-vw(28px);
padding-bottom: desktop-vw(28px);
}
.columns {
grid-template-columns: desktop-vw(480px) 1fr;
display: grid;
border-top: 1px solid var(--theme-fg);
.column {
height: 100vh;
overflow: scroll;
&.one {
border-right: 1px solid var(--theme-fg);
}
&.three {
border-left: 1px solid var(--theme-fg);
}
}
}
@include mobile {
height: auto;
margin-top: mobile-vw(20px);
.columns {
grid-template-columns: 1fr;
padding-bottom: 35vh;
.column {
height: auto;
overflow: hidden;
&.one {
border-top: 1px solid var(--theme-fg);
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 35vh;
z-index: 5;
background: var(--theme-bg);
overflow: scroll;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="number-input">
<label>Quantity (Max: {{ max }})</label>
<div class="input-wrap">
<button @click="decrement">-</button>
<input
v-model="model"
:id="ID"
type="number"
:min="min"
:max="max"
/>
<button @click="increment">+</button>
</div>
</div>
</template>
<script setup>
const ID = Math.random().toString(36).substring(2)
const props = defineProps({
min: {
type: Number,
default: () => 1,
},
max: {
type: Number,
default: () => 10,
},
})
const model = defineModel()
const increment = () => {
if (model.value < props.max) {
model.value++
}
}
const decrement = () => {
if (model.value > props.min) {
model.value--
}
}
</script>
<style lang="scss">
.number-input {
label {
display: block;
margin-bottom: desktop-vw(20px);
}
.input-wrap {
grid-template-columns: auto 1fr auto;
display: grid;
width: 100%;
border: 1px solid var(--theme-fg);
button {
background: var(--theme-fg);
color: var(--theme-bg);
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
}
input {
pointer-events: none;
text-align: center;
}
}
}
</style>

173
components/Slider.vue Normal file
View File

@@ -0,0 +1,173 @@
<template>
<div class="slider">
<div class="slider-container" ref="emblaNode">
<div class="slides">
<slot />
</div>
</div>
<div v-if="controls" class="controls">
<button @click="onPrev">
<arrow />
</button>
<button @click="onNext">
<arrow />
</button>
</div>
<div v-if="nav && slideCount > 1" class="nav">
<btn
v-for="i in slideCount"
@click="onNavClick(i)"
:secondary="activeIndex !== i"
number
>{{ i }}</btn
>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import Btn from '@/components/Btn.vue'
import Arrow from '@/components/svg/util/Arrow.vue'
import embla from 'embla-carousel-vue'
import ClassNames from 'embla-carousel-class-names'
import Autoplay from 'embla-carousel-autoplay'
const props = defineProps({
emblaOptions: {
type: Object,
default: () => {},
},
autoplay: {
type: Boolean,
default: () => false,
},
autoplayOptions: {
type: Object,
default: () => {},
},
controls: {
type: Boolean,
default: () => false,
},
nav: {
type: Boolean,
default: () => false,
},
slideCount: {
type: Number,
default: () => 0,
},
})
const plugins = computed(() => {
const base = [ClassNames()]
if (props.autoplay) {
base.push(Autoplay(props.autoplayOptions))
}
return base
})
const [emblaNode, emblaApi] = embla(props.emblaOptions, plugins.value)
defineExpose({
api: emblaApi,
})
const activeIndex = ref(1)
const onNext = () => {
emblaApi.value.scrollNext()
}
const onPrev = () => {
emblaApi.value.scrollPrev()
}
const onNavClick = (i) => {
activeIndex.value = i
emblaApi.value.scrollTo(i - 1)
}
watch(emblaApi, () => {
if (!emblaApi.value) return
emblaApi.value.on('select', () => {
activeIndex.value = emblaApi.value.selectedScrollSnap() + 1
})
})
</script>
<style lang="scss">
.slider {
position: relative;
.slider-container {
max-width: 100vw;
overflow: hidden;
}
.slides {
display: flex;
> * {
flex-shrink: 0;
}
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--layout-margin);
position: absolute;
inset: 0;
pointer-events: none;
button {
pointer-events: all;
border-radius: 50%;
background: var(--black);
color: var(--white);
width: desktop-vw(40px);
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
svg {
width: desktop-vw(21px);
height: auto;
}
&:first-child {
transform: scaleX(-1);
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
@include mobile {
button {
width: mobile-vw(32px);
svg {
width: mobile-vw(16px);
}
}
}
}
.nav {
position: absolute;
left: 0;
bottom: desktop-vw(25px);
right: 0;
display: flex;
justify-content: center;
@include mobile {
bottom: mobile-vw(25px);
}
}
}
</style>

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>

View File

@@ -0,0 +1,27 @@
<template>
<img
class="prismic-image"
:alt="alt"
:width="width"
:height="height"
:src="src"
/>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
field: {
type: [String, Object],
required: true,
},
})
const src = computed(() =>
typeof props.field == 'string' ? props.field : props.field?.url,
)
const width = computed(() => props.field?.width || '')
const height = computed(() => props.field?.height || '')
const alt = computed(() => props.field?.alt || '')
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
:class="[
'prismic-media',
{ 'fill-space': fillSpace },
`fit-${fit}`,
{ 'video-loaded': videoSrc && videoLoaded },
]"
:style="{ '--aspect': cmpAspect + '%' }"
>
<div class="image-sizer">
<transition :name="transition">
<img
v-show="loaded"
ref="image"
:src="imageSrc"
:srcset="srcset"
:width="width"
:height="height"
:alt="imageAlt"
@load="loaded = true"
/>
</transition>
<template v-if="videoSrc.length">
<video
v-show="videoLoaded"
:height="height"
:width="width"
:src="videoSrc"
:muted="muted"
:autoplay="autoplay"
ref="video"
playsinline
loop
@canplay="videoLoaded = true"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const props = defineProps({
image: {
type: Object,
default: () => {},
required: true,
},
video: [Object, String],
aspect: {
type: [String, Number],
default: () => -1,
},
transition: {
type: String,
default: () => 'fade',
},
fillSpace: {
type: Boolean,
default: () => false,
},
fit: {
type: String,
default: () => 'cover',
},
muted: {
type: Boolean,
default: () => true,
},
autoplay: {
type: Boolean,
default: () => true,
},
})
const image = ref()
const video = ref()
const loaded = ref(false)
const videoLoaded = ref(false)
// Set loaded to true if image is in cache
onMounted(async () => {
await new Promise((res) => setTimeout(res, 50))
if (image.value?.complete) loaded.value = true
if (video.value?.readyState >= 3) videoLoaded.value = true
})
const imageSrc = computed(() => props.image?.url || '')
const imageAlt = computed(() => props.image?.alt || '')
const videoSrc = computed(() => props.video?.url || props.video || '')
const width = computed(() => props.image?.dimensions?.width || 0)
const height = computed(() => props.image?.dimensions?.height || 0)
const cmpAspect = computed(() => {
// calculate if no aspect provided
if (props.aspect === -1) {
return (height.value / width.value) * 100
}
// otherwise, parse provided aspect, handling both 56.25 and 0.5625 style
const toParse = parseFloat(props.aspect)
return toParse <= 1 ? toParse * 100 : toParse
})
const srcset = computed(() => {
return [400, 800, 1024, 1280, 1536, 2048, 2560]
.map((size) => {
const w = size === null ? width.value : size
const h = Math.round(width.value / (cmpAspect.value / 100))
return imageSrc.value + `&w=${w} ${w}w`
})
.join(', ')
})
</script>
<style lang="scss">
.prismic-media {
position: relative;
width: 100%;
.image-sizer {
overflow: hidden;
padding-bottom: var(--aspect);
& > * {
position: absolute;
height: 100%;
width: 100%;
left: 0;
top: 0;
}
}
// fill space
&.fill-space {
position: absolute;
bottom: 0;
right: 0;
left: 0;
top: 0;
.image-sizer {
padding-bottom: 0;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
// fits
&.fit-cover .image-sizer > * {
object-fit: cover;
}
&.fit-contain .image-sizer > * {
object-fit: contain;
}
/* Hide background image on video load */
&.video-loaded img {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="prismic-rich-text" v-html="html" />
</template>
<script setup>
import { computed } from 'vue'
import * as prismicH from '@prismicio/helpers'
const props = defineProps({
field: {
type: Object,
required: true,
},
})
const html = computed(() => prismicH.asHTML(props.field))
</script>

View File

@@ -0,0 +1,50 @@
<template>
<section
class="slices-carousel"
:data-slice-type="slice.slice_type"
:data-slice-variation="slice.variation"
>
<slider :emblaOptions="{ loop: true }" controls>
<prismic-media
class="item"
v-for="item in items"
:image="item.image"
:video="item.video"
aspect="100"
/>
</slider>
</section>
</template>
<script setup>
import { computed } from 'vue'
import Slider from '@/components/Slider.vue'
import PrismicMedia from '@/components/prismic/Media.vue'
const props = defineProps({
slice: Object,
})
const items = computed(() => props.slice?.primary?.items || [])
</script>
<style lang="scss">
.slices-carousel {
margin-top: desktop-vw(100px);
margin-bottom: desktop-vw(100px);
position: relative;
.item {
width: desktop-vw(640px);
}
@include mobile {
margin-top: mobile-vw(100px);
margin-bottom: mobile-vw(100px);
.item {
width: mobile-vw(300px);
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<section
class="slices-content"
:data-slice-type="slice.slice_type"
:data-slice-variation="slice.variation"
>
<prismic-rich-text class="body entry" :field="slice.primary.body" />
</section>
</template>
<script setup>
import PrismicRichText from '@/components/prismic/RichText.vue'
const props = defineProps({
slice: Object,
})
</script>
<style lang="scss">
.slices-content {
margin-top: desktop-vw(31px);
margin-bottom: desktop-vw(31px);
.body {
width: desktop-vw(640px);
margin: auto;
h2 {
text-align: center;
margin-bottom: desktop-vw(31px);
@include h5;
}
p,
a,
ul {
text-transform: none;
}
p {
text-align: justify;
}
}
@include mobile {
margin-top: mobile-vw(31px);
margin-bottom: mobile-vw(31px);
.body {
width: 100%;
padding: 0 var(--layout-margin);
h2 {
margin-bottom: mobile-vw(31px);
}
}
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<section
:class="[
'slices-full-bleed-media',
{ diptych: items.length === 2, inset: slice.primary.inset },
]"
:data-slice-type="slice.slice_type"
:data-slice-variation="slice.variation"
>
<prismic-media v-for="item in items" :image="item.image" />
</section>
</template>
<script setup>
import { computed } from 'vue'
import PrismicMedia from '@/components/prismic/Media.vue'
const props = defineProps({
slice: Object,
})
const items = computed(() => props.slice?.primary?.items || [])
</script>
<style lang="scss">
.slices-full-bleed-media {
margin-top: desktop-vw(100px);
margin-bottom: desktop-vw(100px);
display: flex;
width: 100%;
.prismic-media {
flex: 1;
}
&.diptych {
.prismic-media {
border: 1px solid var(--black);
}
}
&.inset {
padding: 0 desktop-vw(16px);
gap: desktop-vw(26px);
.prismic-media {
border: none;
}
}
@include mobile {
margin-top: mobile-vw(100px);
margin-bottom: mobile-vw(100px);
flex-direction: column;
&.inset {
padding: 0 var(--layout-margin);
gap: mobile-vw(29px);
}
}
}
</style>

32
components/slices/Map.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<component
v-for="(slice, index) in slices"
:key="index"
:is="mapComponent(slice)"
:slice="slice"
/>
</template>
<script setup>
import Carousel from '@/components/slices/Carousel.vue'
import Content from '@/components/slices/Content.vue'
import FullBleedMedia from '@/components/slices/FullBleedMedia.vue'
import Quote from '@/components/slices/Quote.vue'
const MAP = {
carousel: Carousel,
content: Content,
full_bleed_media: FullBleedMedia,
quote: Quote,
}
const props = defineProps({
slices: Array,
})
const mapComponent = (slice) => {
if (!slice?.slice_type) return
return MAP[slice.slice_type]
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<section
class="slices-quote"
:data-slice-type="slice.slice_type"
:data-slice-variation="slice.variation"
>
<div class="quote-wrap">
<blockquote class="quote q1">{{ slice.primary.text }}</blockquote>
<span class="attribution">
&mdash;&nbsp;{{ slice.primary.attribution }}
</span>
</div>
</section>
</template>
<script setup>
const props = defineProps({
slice: Object,
})
</script>
<style lang="scss">
.slices-quote {
margin-top: desktop-size(50px);
margin-bottom: desktop-size(50px);
.quote-wrap {
width: desktop-vw(640px);
margin: auto;
.attribution {
display: block;
margin-top: desktop-vw(13px);
}
}
@include mobile {
margin-top: mobile-size(50px);
margin-bottom: mobile-size(50px);
.quote-wrap {
width: 100%;
padding: 0 var(--layout-margin);
.attribution {
margin-top: mobile-vw(13px);
}
}
}
}
</style>

View File

@@ -1,67 +0,0 @@
<template>
<div class="svg-btn-outline">
<svg
class="side left"
width="16"
height="28"
viewBox="0 0 16 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
stroke="currentColor"
/>
</svg>
<div class="fill">
<div class="borders" />
</div>
<svg
class="side right"
width="16"
height="28"
viewBox="0 0 16 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
stroke="currentColor"
/>
</svg>
</div>
</template>
<style lang="scss">
.svg-btn-outline {
grid-template-columns: auto 1fr auto;
display: grid;
align-items: center;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
.side {
height: 100%;
&.right {
transform: scaleX(-1);
}
}
.fill {
height: 100%;
position: relative;
.borders {
position: absolute;
inset: 0 -1px;
border: solid currentColor;
border-width: 1px 0;
}
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
<template>
<svg
class="svg-arrow"
width="23"
height="24"
viewBox="0 0 23 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.621 22.3448L20.9658 12L10.621 1.65519"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M20.9658 12L0.276165 12"
stroke="currentColor"
stroke-width="2"
/>
</svg>
</template>

View File

@@ -0,0 +1,32 @@
<template>
<svg
class="svg-util-spinner"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="margin: auto; display: block"
width="18px"
height="18px"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<circle
cx="50"
cy="50"
r="40"
stroke-width="4"
stroke="currentColor"
stroke-dasharray="62 62"
fill="none"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
repeatCount="indefinite"
dur="1s"
keyTimes="0;1"
values="0 50 50;360 50 50"
></animateTransform>
</circle>
</svg>
</template>