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>

44
composables/prismic.js Normal file
View File

@@ -0,0 +1,44 @@
import * as prismic from '@prismicio/client'
// === Cached API of any pris method === //
const cache = {}
export const cacheResult = (method, ...args) => {
const key = method + JSON.stringify(args || [])
// If not in cache,
// fetch and store
if (!cache[key]) {
cache[key] = () => {
const client = prismic.createClient('swang')
return client[method](...args).catch((err) => {
console.log(`Error getting Prismic result ${key}:`, err)
throw err
})
}
}
return { key, execute: cache[key] }
}
// Get single doc
export const usePrisSingle = (type, options = {}) => {
const { key, execute } = cacheResult('getSingle', type, options)
return useAsyncData(key, execute, { deep: false })
}
// Get settings (assumed single doc)
export const usePrisSettings = () => {
return usePrisSingle('settings')
}
// Get all docs of a certain type
export const usePrisType = (type, options = {}) => {
const { key, execute } = cacheResult('getAllByType', type, options)
return useAsyncData(key, execute, { deep: false })
}
// Get doc by UID (and type)
export const usePrisUid = (uid, type = 'page', options = {}) => {
const { key, execute } = cacheResult('getByUID', type, uid, options)
return useAsyncData(key, execute, { deep: false })
}

View File

@@ -1,42 +0,0 @@
import { ref } from 'vue'
export default () => {
const os = ref('Unknown')
const isMobile = ref(false)
const isAndroid = ref(false)
const isIOS = ref(false)
const isMacOS = ref(false)
const isWindows = ref(false)
const isLinux = ref(false)
if (typeof navigator !== 'undefined') {
const userAgent = navigator.userAgent.toLowerCase()
const platform = navigator.platform.toLowerCase()
const detectedAndroid = /android/.test(userAgent)
const detectedIOS = /iphone|ipad|ipod/.test(userAgent)
const detectedMacOS = /mac/.test(platform) && !detectedIOS
const detectedWindows = /win/.test(platform)
const detectedLinux = /linux/.test(platform) && !detectedAndroid
os.value = detectedAndroid
? 'Android'
: detectedIOS
? 'iOS'
: detectedMacOS
? 'macOS'
: detectedWindows
? 'Windows'
: detectedLinux
? 'Linux'
: 'Unknown'
isMobile.value = detectedAndroid || detectedIOS
isAndroid.value = detectedAndroid
isIOS.value = detectedIOS
isMacOS.value = detectedMacOS
isWindows.value = detectedWindows
isLinux.value = detectedLinux
}
return { os, isMobile, isAndroid, isIOS, isMacOS, isWindows, isLinux }
}

107
composables/useKlaviyo.js Normal file
View File

@@ -0,0 +1,107 @@
import { ref } from 'vue'
// Wraps Klaviyo's legacy client-side list-subscribe endpoint.
// Klaviyo requires any non-standard property names to be declared in `$fields`
// for them to be persisted on the profile — this composable handles that for you.
const ENDPOINT = 'https://manage.kmail-lists.com/ajax/subscriptions/subscribe'
export default () => {
const loading = ref(false)
const success = ref(false)
const error = ref('')
const reset = () => {
loading.value = false
success.value = false
error.value = ''
}
const subscribe = async ({
listId,
email,
firstName,
lastName,
name,
source,
properties = {},
} = {}) => {
if (!listId) {
error.value = 'Missing listId'
return
}
if (!email) {
error.value = 'Missing email'
return
}
loading.value = true
error.value = ''
// `name` is a convenience: first token → first_name, rest → last_name
let fName = firstName
let lName = lastName
if (name && !fName && !lName) {
const parts = name.trim().split(/\s+/)
fName = parts.shift()
lName = parts.join(' ') || undefined
}
const body = { g: listId, email }
if (fName) body.first_name = fName
if (lName) body.last_name = lName
const extraFields = []
if (source) {
body.$source = source
extraFields.push('$source')
}
for (const [key, value] of Object.entries(properties)) {
if (value === undefined || value === null || value === '') continue
body[key] = value
extraFields.push(key)
}
if (extraFields.length) body.$fields = extraFields.join(',')
try {
await $fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
},
body: new URLSearchParams(body),
})
success.value = true
} catch (err) {
console.error('useKlaviyo subscribe error', err)
error.value =
err?.toString().split('Error: ')[1] || 'Subscription failed'
}
loading.value = false
}
// Fires a Klaviyo event (metric) — append-only history, good for things
// segments will slice on like "Submitted RSVP" with per-event properties.
// Throws on failure; doesn't touch the shared loading/success/error refs,
// so callers can pair it with subscribe() without state collisions.
const track = ({ email, metric, properties = {} }) => {
if (!email || !metric) {
throw new Error('useKlaviyo.track: email and metric are required')
}
return $fetch('/api/klaviyo-track', {
method: 'POST',
body: { email, metric, properties },
})
}
return {
loading,
success,
error,
subscribe,
track,
reset,
}
}

10
libs/resolveEventType.ts Normal file
View File

@@ -0,0 +1,10 @@
enum EVENT_TYPE {
'Ticket Purchase' = 'product',
'Free RSVP' = 'rsvp',
}
export const resolveEventType = (
label: 'Ticket Purchase' | 'Free RSVP',
): EVENT_TYPE => {
return EVENT_TYPE[label]
}

133
libs/shopify/cart.js Normal file
View File

@@ -0,0 +1,133 @@
import { gql } from 'graphql-request'
import { IMAGE_FRAGMENT, PRICE_FRAGMENT } from './fragments'
export const CART_FRAGMENT = gql`
fragment CartFields on Cart {
id
checkoutUrl
cost {
subtotalAmount {
...PriceFields
}
}
totalQuantity
discountCodes {
code
applicable
}
buyerIdentity {
email
customer {
id
}
}
lines(first: 100) {
edges {
node {
id
cost {
totalAmount {
...PriceFields
}
}
quantity
merchandise {
... on ProductVariant {
id
product {
handle
title
}
image {
...ImageFields
}
selectedOptions {
name
value
}
}
}
}
}
}
}
`
export const CREATE_CART = gql`
${PRICE_FRAGMENT}
${IMAGE_FRAGMENT}
${CART_FRAGMENT}
mutation createCart($input: CartInput) {
cartCreate(input: $input) {
cart {
...CartFields
}
}
}
`
export const ADD_TO_CART = gql`
${PRICE_FRAGMENT}
${IMAGE_FRAGMENT}
${CART_FRAGMENT}
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...CartFields
}
}
}
`
export const REMOVE_FROM_CART = gql`
${PRICE_FRAGMENT}
${IMAGE_FRAGMENT}
${CART_FRAGMENT}
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...CartFields
}
}
}
`
export const UPDATE_DISCOUNT_CODES = gql`
${PRICE_FRAGMENT}
${IMAGE_FRAGMENT}
${CART_FRAGMENT}
mutation updateDiscountCodes($cartId: ID!, $discountCodes: [String!]!) {
cartDiscountCodesUpdate(
cartId: $cartId
discountCodes: $discountCodes
) {
cart {
...CartFields
}
}
}
`
export const UPDATE_BUYER_ID = gql`
${PRICE_FRAGMENT}
${IMAGE_FRAGMENT}
${CART_FRAGMENT}
mutation updateBuyerId(
$cartId: ID!
$buyerIdentity: CartBuyerIdentityInput!
) {
cartBuyerIdentityUpdate(
cartId: $cartId
buyerIdentity: $buyerIdentity
) {
cart {
...CartFields
}
}
}
`

View File

@@ -0,0 +1,52 @@
import { gql } from 'graphql-request'
import { IMAGE_FRAGMENT, PRICE_FRAGMENT } from './fragments'
import { PRODUCT_FRAGMENT } from './product'
export const COLLECTION_FRAGMENT = gql`
fragment CollectionFields on Collection {
id
handle
title
description
image {
...ImageFields
}
products(first: 100) {
edges {
node {
...ProductFields
}
}
}
}
`
export const GET_COLLECTIONS = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
${PRODUCT_FRAGMENT}
${COLLECTION_FRAGMENT}
query getCollections($first: Int!) {
collections(first: $first) {
edges {
node {
...CollectionFields
}
}
}
}
`
export const GET_COLLECTION_BY_HANDLE = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
${PRODUCT_FRAGMENT}
${COLLECTION_FRAGMENT}
query getCollectionByHandle($handle: String) {
collection(handle: $handle) {
...CollectionFields
}
}
`

83
libs/shopify/customer.js Normal file
View File

@@ -0,0 +1,83 @@
import { gql } from 'graphql-request'
import { IMAGE_FRAGMENT, PRICE_FRAGMENT } from './fragments'
export const GET_CUSTOMER = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
query getCustomer {
customer {
id
defaultAddress {
address1
address2
city
zoneCode
zip
country
}
firstName
lastName
emailAddress {
emailAddress
}
orders(first: 100) {
edges {
node {
id
name
number
createdAt
statusPageUrl
totalPrice {
amount
currencyCode
}
lineItems(first: 100) {
edges {
node {
id
name
image {
...ImageFields
}
price {
...PriceFields
}
}
}
}
}
}
}
}
}
`
export const UPDATE_CUSTOMER = gql`
mutation updateCustomerAndAddress(
$address: CustomerAddressInput!
$input: CustomerUpdateInput!
) {
customerAddressCreate(address: $address, defaultAddress: true) {
customerAddress {
address1
address2
city
province
country
}
userErrors {
code
field
message
}
}
customerUpdate(input: $input) {
customer {
firstName
lastName
}
}
}
`

19
libs/shopify/fragments.js Normal file
View File

@@ -0,0 +1,19 @@
// Misc fragments used throughout
import { gql } from 'graphql-request'
export const IMAGE_FRAGMENT = gql`
fragment ImageFields on Image {
url
altText
width
height
}
`
export const PRICE_FRAGMENT = gql`
fragment PriceFields on MoneyV2 {
amount
currencyCode
}
`

20
libs/shopify/menu.js Normal file
View File

@@ -0,0 +1,20 @@
import { gql } from 'graphql-request'
export const GET_MENU = gql`
query getMenu($handle: String!) {
menu(handle: $handle) {
id
title
items {
id
title
url
items {
id
title
url
}
}
}
}
`

143
libs/shopify/product.js Normal file
View File

@@ -0,0 +1,143 @@
import { gql } from 'graphql-request'
import { IMAGE_FRAGMENT, PRICE_FRAGMENT } from './fragments'
// Base fragment for product
// with product metafield of a list of files (images)
// called 'community_images'
export const PRODUCT_FRAGMENT = gql`
fragment ProductFields on Product {
id
handle
title
descriptionHtml
metafields(
identifiers: [
{ namespace: "custom", key: "community_images" }
{ namespace: "custom", key: "size_guide" }
{ namespace: "custom", key: "member_gated" }
{ namespace: "custom", key: "member_discounted" }
]
) {
key
namespace
id
type
value
references(first: 100) {
edges {
node {
... on MediaImage {
image {
...ImageFields
}
}
}
}
}
}
featuredImage {
...ImageFields
}
priceRange {
minVariantPrice {
...PriceFields
}
}
options {
id
name
optionValues {
id
name
}
}
sellingPlanGroups(first: 1) {
edges {
node {
name
options {
name
values
}
sellingPlans(first: 3) {
edges {
node {
id
name
description
recurringDeliveries
options {
name
value
}
}
}
}
}
}
}
}
`
export const GET_PRODUCTS = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
${PRODUCT_FRAGMENT}
query getProducts($first: Int!) {
products(first: $first) {
edges {
node {
...ProductFields
}
}
}
}
`
export const GET_PRODUCT_BY_HANDLE = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
${PRODUCT_FRAGMENT}
query getProductByHandle($handle: String!) {
product(handle: $handle) {
...ProductFields
images(first: 100) {
edges {
node {
...ImageFields
}
}
}
variants(first: 100) {
edges {
node {
id
title
selectedOptions {
name
value
}
price {
...PriceFields
}
availableForSale
}
}
}
}
}
`
export const GET_PRODUCT_RECOMMENDATIONS = gql`
${IMAGE_FRAGMENT}
${PRICE_FRAGMENT}
${PRODUCT_FRAGMENT}
query getProductRecommendations($handle: String!) {
productRecommendations(productHandle: $handle) {
...ProductFields
}
}
`

View File

@@ -1,25 +1,39 @@
const colors = {
black: '#181818',
white: '#D5D5D5',
'grey-100': '#747474',
green: '#87FF5B',
blue: '#5B92FF',
purple: '#94079E',
red: '#D40202',
import gsap from 'gsap'
const generateShades = (colors) => {
const result = {}
for (const [key, value] of Object.entries(colors)) {
result[key] = value
for (let i = 0; i <= 19; i++) {
const split = gsap.utils.splitColor(value)
const rgb = split.toString().replaceAll(',', ' ')
const alpha = parseInt((((i / 20) * 0xff) / 255) * 100)
result[`${key}-${i * 5}`] = `rgb(${rgb} / ${alpha}%)`
}
}
return result
}
const colors = generateShades({
black: '#0B0D0B',
white: '#FCFCF6',
purple: '#541CDC',
})
const themes = {
dark: {
bg: colors.black,
fg: colors.white,
accent: colors.green,
link: colors.blue,
},
light: {
bg: colors.white,
fg: colors.black,
accent: colors.purple,
link: colors.blue,
contrast: colors.purple,
},
dark: {
bg: colors.black,
fg: colors.white,
contrast: colors.purple,
},
}
@@ -29,20 +43,23 @@ const breakpoints = {
const viewports = {
mobile: {
width: 440,
height: 956,
width: 480,
height: 872,
},
desktop: {
width: 1728,
height: 1117,
width: 1920,
height: 1080,
},
}
export { colors, themes, breakpoints, viewports }
const scrollSmoothing = 0.7
export { colors, themes, breakpoints, viewports, scrollSmoothing }
export default {
colors,
themes,
breakpoints,
viewports,
scrollSmoothing,
}

7766
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,34 @@
{
"scripts": {
"dev": "vike dev",
"build": "vike build",
"preview": "vike build && vike preview"
},
"dependencies": {
"@fuzzco/font-loader": "^1.0.2",
"@strapi/client": "^1.6.1",
"@vueuse/core": "^14.3.0",
"lenis": "^1.3.23",
"lodash": "^4.18.1",
"sass": "^1.99.0",
"sass-embedded": "^1.99.0",
"tempus": "^1.0.0-dev.17",
"vike": "^0.4.255",
"vike-vue": "^0.9.11",
"vue": "^3.5.30",
"vue-strapi-blocks-renderer": "^1.1.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"type": "module"
"type": "module",
"scripts": {
"dev": "vike dev",
"build": "vike build",
"preview": "vike build && vike preview"
},
"dependencies": {
"@fuzzco/font-loader": "^1.0.2",
"@prismicio/client": "^7.21.8",
"@prismicio/helpers": "^2.3.9",
"@shopify/storefront-api-client": "^1.0.10",
"@vueuse/core": "^14.3.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-class-names": "^8.6.0",
"embla-carousel-vue": "^8.6.0",
"fecha": "^4.2.3",
"graphql-request": "^7.4.0",
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"lodash": "^4.18.1",
"sass": "^1.99.0",
"sass-embedded": "^1.99.0",
"tempus": "^1.0.0-dev.17",
"vike": "^0.4.255",
"vike-vue": "^0.9.11",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"prettier": "^3.8.1",
"vite": "^7.3.1"
}
}

View File

@@ -7,7 +7,7 @@
</template>
<script setup>
import '@/styles/main.scss'
import '@/styles/global.scss'
import { ref, computed, onMounted } from 'vue'
import loadFonts from '@fuzzco/font-loader'
import { useWindowSize } from '@vueuse/core'
@@ -20,19 +20,26 @@ const fontsLoading = ref(true)
const classes = computed(() => [
'container',
{ 'fonts-ready': !fontsLoading.value },
'theme-dark',
'theme-light',
])
onMounted(async () => {
// Load fonts
loadFonts([
{
name: 'Leibniz Fraktur',
weights: [400],
name: 'Arial Narrow',
weights: [700],
styles: ['normal', 'italic'],
},
{
name: 'Geist Mono',
weights: [400, 700],
name: 'Druk Wide',
weights: [900],
styles: ['normal'],
},
{
name: 'GT Super',
weights: [400],
styles: ['italic'],
},
])
.then(() => {

37
pages/@id/+Page.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<column-layout class="event-detail">
<template v-slot:column-one>
<event-info :event="event" :product="product" />
</template>
<template v-slot:column-two>
<slices-map :slices="slices" />
</template>
</column-layout>
</template>
<script setup>
import { computed } from 'vue'
import { useData } from 'vike-vue/useData'
import ColumnLayout from '@/components/ColumnLayout.vue'
import EventInfo from '@/components/event/Info.vue'
import SlicesMap from '@/components/slices/Map.vue'
const { event, product } = useData()
const slices = computed(() => event?.slices || [])
</script>
<style lang="scss">
main.event-detail {
.column.two {
padding-bottom: desktop-vw(75px);
.slices-full-bleed-media.diptych {
.prismic-media {
border-width: 1px 0;
}
}
}
}
</style>

29
pages/@id/+data.js Normal file
View File

@@ -0,0 +1,29 @@
import * as prismic from '@prismicio/client'
import { createStorefrontApiClient } from '@shopify/storefront-api-client'
import { GET_PRODUCT_BY_HANDLE } from '@/libs/shopify/product'
export const data = async (pageContext) => {
const { id } = pageContext.routeParams
const prismicClient = prismic.createClient('swang')
const eventDoc = await prismicClient.getByUID('event', id)
const event = eventDoc?.data
const productHandle = event?.shopify_product_handle
if (!productHandle) return { event, product: null }
const shopifyClient = createStorefrontApiClient({
storeDomain: `https://swangent.myshopify.com`,
apiVersion: '2026-04',
publicAccessToken: 'd2848b9bcde999cea878c218493cfe84',
})
const query = GET_PRODUCT_BY_HANDLE
const variables = { handle: productHandle }
const productRes = await shopifyClient.request(query, { variables })
const product = productRes?.data
return { event, product }
}

View File

@@ -1,86 +1,19 @@
<template>
<main class="splash">
<svg-wordmark />
<div class="content">
<strapi-blocks v-if="content" :content="content" />
</div>
<a :href="downloadUrl" download>
<btn :disabled="loading">
{{ loading ? 'Checking for updates…' : `Download for ${os}` }}
</btn>
</a>
<pre>{{ events }}</pre>
</main>
</template>
<script setup>
import { StrapiBlocks } from 'vue-strapi-blocks-renderer'
import { ref, computed, onMounted } from 'vue'
import SvgWordmark from '@/components/svg/Wordmark.vue'
import useDetectOS from '@/composables/useDetectOS'
import Btn from '@/components/Btn.vue'
import { useData } from 'vike-vue/useData'
const BASE_URL = 'https://s3.takerofnotes.com'
const { os } = useDetectOS()
const { data } = useData()
const version = ref(null)
const downloadPath = ref(null)
const loading = ref(true)
const downloadUrl = computed(() => {
if (!downloadPath.value || os.value === 'Unknown') return null
return `${BASE_URL}/dist/${os.value.toLowerCase()}/${version.value}/${downloadPath.value}`
})
const content = computed(() => data?.content)
onMounted(async () => {
try {
const response = await fetch(
`${BASE_URL}/dist/latest-${os.value.toLowerCase()}.yml`,
)
if (!response.ok) throw new Error(response.statusText)
const yaml = await response.text()
const versionMatch = yaml.match(/^version:\s*(.+)/m)
const pathMatch = yaml.match(/^path:\s*(.+)/m)
if (versionMatch) version.value = versionMatch[1].trim()
if (pathMatch) downloadPath.value = pathMatch[1].trim()
} catch {
// fallback to placeholder
// downloadPath.value = `takerofnotes-app-0.2.0.${os.value === 'Windows' ? 'exe' : os.value === 'macOS' ? 'dmg' : 'AppImage'}`
console.error('Failed to fetch latest version info')
} finally {
loading.value = false
}
})
const { events } = useData()
</script>
<style lang="scss">
main.splash {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: desktop-vw(40px);
.svg-wordmark {
width: 70%;
height: auto;
display: block;
}
.content {
margin: var(--layout-margin);
p {
max-width: desktop-vw(500px);
}
pre {
margin: 0;
}
}
</style>

View File

@@ -1,11 +1,9 @@
import { strapi } from '@strapi/client'
import * as prismic from '@prismicio/client'
export const data = async () => {
const client = strapi({
baseURL: 'https://cms.takerofnotes.com/api',
auth: import.meta.env.STRAPI_API_TOKEN,
})
const client = prismic.createClient('swang')
const global = client.single('global')
return await global.find()
const events = await client.getAllByType('event')
return { events }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 870 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,22 +1,20 @@
:root {
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
--ease-custom: cubic-bezier(0.315, 0.365, 0.23, 0.985);
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
}

View File

@@ -1,6 +1,6 @@
@use 'functions' as *;
@mixin size-font($ds, $ms) {
@mixin size-font($ms, $ds) {
font-size: mobile-vw($ms);
&.vh {
@@ -16,24 +16,165 @@
}
}
// Super
@mixin s1 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(240px, 240px);
}
@mixin s2 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(50px, 160px);
}
@mixin s3 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(60px, 140px);
}
@mixin s4 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.046em;
@include size-font(115px, 115px);
}
// Header
@mixin h1 {
font-family: var(--font-display);
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(46px, 85px);
}
@mixin h2 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(32px, 60px);
}
@mixin h3 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(32px, 40px);
}
@mixin h4 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.9;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(18px, 30px);
}
@mixin h5 {
font-family: var(--font-druk);
font-weight: 900;
line-height: 0.8;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(18px, 20px);
}
@mixin h6 {
font-family: var(--font-druk);
font-weight: 700;
line-height: 1.1;
text-transform: none;
letter-spacing: -0.01em;
@include size-font(18px, 18px);
}
// Emphasis
@mixin em1 {
font-family: var(--font-gt);
font-weight: 400;
font-style: italic;
line-height: 0.8;
letter-spacing: -0.06em;
text-transform: none;
@include size-font(48px, 80px);
}
@mixin em2 {
font-family: var(--font-gt);
font-weight: 400;
font-style: italic;
line-height: 0.96;
letter-spacing: -0.01em;
text-transform: none;
@include size-font(36px, 61px);
}
@mixin em3 {
font-family: var(--font-gt);
font-weight: 400;
font-style: italic;
line-height: 0.8;
text-transform: none;
@include size-font(22px, 41px);
}
// Quote
@mixin q1 {
font-family: var(--font-gt);
font-weight: 400;
font-style: italic;
line-height: 1.1;
letter-spacing: -0.02em;
line-height: 1.3;
@include size-font(30px, 30px);
text-transform: none;
@include size-font(22px, 35px);
}
@mixin h1-mono {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
@include size-font(22px, 22px);
// Paragraph
@mixin p-l {
font-family: var(--font-arial);
font-weight: 700;
line-height: 0.9;
text-transform: uppercase;
letter-spacing: -0.04em;
@include size-font(32px, 45px);
}
@mixin p {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1.4;
@include size-font(12px, 12px);
font-family: var(--font-arial);
font-weight: 700;
line-height: 0.94;
letter-spacing: -0.03em;
text-transform: uppercase;
@include size-font(16px, 21px);
}
@mixin p-s {
font-family: var(--font-arial);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.02em;
text-transform: uppercase;
@include size-font(14px, 18px);
}
@mixin p-xs {
font-family: var(--font-arial);
font-weight: 700;
line-height: 0.94;
text-transform: uppercase;
@include size-font(14px, 16px);
}
@mixin p-xxs {
font-family: var(--font-arial);
font-weight: 700;
line-height: 0.94;
text-transform: uppercase;
@include size-font(12px, 14px);
}

View File

@@ -11,35 +11,42 @@
900 - Black (Heavy)
*/
/* Leibniz Fraktur */
/* ARIAL NARROW */
@font-face {
font-family: 'Leibniz Fraktur';
font-style: normal;
font-weight: 400;
src:
url('/fonts/neuefraktur.woff2') format('woff2'),
url('/fonts/neuefraktur.woff') format('woff');
}
/* Geist Mono */
@font-face {
font-family: 'Geist Mono';
font-family: 'Arial Narrow';
font-style: normal;
font-weight: 700;
src:
url('/fonts/geist-mono-bold.woff2') format('woff2'),
url('/fonts/geist-mono-bold.woff') format('woff');
src: url('/fonts/arial-narrow-bold.woff2') format('woff2'),
url('/fonts/arial-narrow-bold.woff') format('woff');
}
@font-face {
font-family: 'Geist Mono';
font-family: 'Arial Narrow';
font-style: italic;
font-weight: 700;
src: url('/fonts/arial-narrow-bold-italic.woff2') format('woff2'),
url('/fonts/arial-narrow-bold-italic.woff') format('woff');
}
/* DRUK WIDE */
@font-face {
font-family: 'Druk Wide';
font-style: normal;
font-weight: 900;
src: url('/fonts/druk-wide-cy-tt-super.woff2') format('woff2'),
url('/fonts/druk-wide-cy-tt-super.woff') format('woff');
}
/* GT SUPER */
@font-face {
font-family: 'GT Super';
font-style: italic;
font-weight: 400;
src:
url('/fonts/geist-mono.woff2') format('woff2'),
url('/fonts/geist-mono.woff') format('woff');
src: url('/fonts/gt-super-display-regular-italic.woff2') format('woff2'),
url('/fonts/gt-super-display-regular-italic.woff') format('woff');
}
:root {
--font-display: 'Leibniz Fraktur', serif;
--font-mono: 'Geist Mono', monospace;
--font-arial: 'Arial Narrow', sans-serif;
--font-druk: 'Druk Wide', sans-serif;
--font-gt: 'GT Super', serif;
}

View File

@@ -60,6 +60,14 @@ $mobile-height: get('viewports.mobile.height');
display: grid;
}
@mixin hover {
@include desktop {
&:hover {
@content;
}
}
}
@mixin reduced-motion {
@media (prefers-reduced-motion: reduce) {
@content;
@@ -170,18 +178,66 @@ $mobile-height: get('viewports.mobile.height');
}
}
@mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) {
@for $i from 0 through $num-children {
&:nth-child(#{$i + 1}) {
transition-delay: #{$stagger * $i + $base-delay}ms;
@mixin text-flip($duration: 400, $easing: linear) {
.text {
position: relative;
display: block;
overflow: hidden;
span {
display: inline-block;
}
span:last-child {
position: absolute;
inset: 0;
transform: translateY(105%);
}
}
@include desktop {
&:hover,
&.active {
.text {
span:first-child {
animation: button-flip-out $duration $easing forwards;
}
span:last-child {
animation: button-flip-in $duration $easing forwards;
}
}
}
}
@keyframes button-flip-in {
from {
transform: translateY(105%);
}
to {
transform: none;
}
}
@keyframes button-flip-out {
from {
transform: none;
}
to {
transform: translateY(-105%);
}
}
}
@mixin hover {
@include desktop {
&:hover {
@content;
}
@mixin scorecard-dot {
&::before {
content: '';
display: block;
width: 46%;
aspect-ratio: 1;
background: var(--theme-bg);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--td) var(--te);
}
}

View File

@@ -1,7 +1,36 @@
@use 'functions' as *;
// z-index
.lily-cursor {
z-index: 20;
.site-orchestra {
z-index: 100;
}
.stats,
.grid-debug {
z-index: 99 !important;
}
main.front-page,
.site-mobile-menu {
z-index: 80;
}
.webgl-canvas {
z-index: 70;
}
.cart {
z-index: 60;
}
.site-header {
z-index: 10;
z-index: 50;
}
.site-footer {
z-index: 30;
}
@include mobile {
.cart {
z-index: 80;
}
main.front-page {
z-index: 10;
}
}

View File

@@ -21,15 +21,15 @@
$layout: (
'columns-count': (
5,
18,
8,
),
'columns-gap': (
20px,
20px,
0px,
),
'margin': (
30px,
60px,
20px,
20px,
),
);
@@ -48,8 +48,7 @@ $layout: (
(var(--layout-column-count) - 1) *
var(--layout-column-gap)
)
) /
var(--layout-column-count)
) / var(--layout-column-count)
);
@include desktop {

View File

@@ -28,6 +28,13 @@ button {
cursor: revert;
}
/* Remove list styles (bullets/numbers) */
ol,
ul,
menu {
list-style: none;
}
/* For images to not be able to exceed their container */
img {
max-inline-size: 100%;

View File

@@ -1,34 +1,34 @@
html {
&:not(.dev) {
&,
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
&:not(.dev) {
&,
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
&::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
&::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
}
}
html.lenis {
height: auto;
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto;
scroll-behavior: auto;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
overflow: hidden;
}
.lenis.lenis-scrolling iframe {
pointer-events: none;
pointer-events: none;
}

View File

@@ -1,7 +1,7 @@
// Fades
.fade-enter-active,
.fade-leave-active {
transition: opacity 400ms;
transition: opacity 200ms;
}
.fade-enter-from,
.fade-leave-to {
@@ -41,3 +41,13 @@
.slide-left-leave-to {
transform: translateX(-100%);
}
// Page
.page-enter-active,
.page-leave-active {
transition: opacity 400ms;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}

187
styles/global.scss Normal file
View File

@@ -0,0 +1,187 @@
@use 'reset' as *;
@use 'fonts' as *;
@use 'colors' as *;
@use 'easings' as *;
@use 'functions' as *;
@use 'layout' as *;
@use 'utils' as *;
@use 'font-style' as *;
@use 'themes' as *;
@use 'scroll' as *;
@use 'layers' as *;
@use 'transitions' as *;
body {
margin: 0;
-webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased !important;
text-rendering: optimizeLegibility !important;
font-variant-numeric: lining-nums proportional-nums ordinal;
font-feature-settings: 'dlig' on;
// Standard transitions
--td: 500ms;
--te: var(--ease-out-expo);
}
a {
text-decoration: none;
color: inherit;
}
// Type
.s1 {
@include s1;
}
.s2 {
@include s2;
}
.s3 {
@include s3;
}
.s4 {
@include s4;
}
.h1,
h1 {
@include h1;
}
.h2,
h2 {
@include h2;
}
.h3,
h3 {
@include h3;
}
.h4,
h4 {
@include h4;
}
.h5,
h5 {
@include h5;
}
.h6,
h6 {
@include h6;
}
.em1 {
@include em1;
}
.em2 {
@include em2;
}
.em3 {
@include em3;
}
.q1 {
@include q1;
}
.p-l {
@include p-l;
}
.p,
body,
p,
a,
button,
input {
@include p;
}
.p-s {
@include p-s;
}
.p-xs {
@include p-xs;
}
.p-xxs {
@include p-xxs;
}
// Rich text styling
.entry {
img {
max-width: 100%;
height: auto;
}
p {
min-height: 1px;
}
a {
text-decoration: underline;
}
& > * {
margin-bottom: 1em;
margin-top: 1em;
}
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
ul,
ol {
li {
margin-bottom: 1em;
width: desktop-vw(577px);
@include mobile {
width: 100%;
}
}
}
ul {
list-style: disc;
padding-left: 1em;
}
ol {
counter-reset: list;
li::before {
counter-increment: list;
content: counter(list);
padding-right: 0.5em;
font-variant-numeric: lining-nums proportional-nums;
}
}
}
// Generic animations
@keyframes blink {
5% {
opacity: 0;
}
40% {
opacity: 0;
}
60% {
opacity: 1;
}
95% {
opacity: 1;
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeup {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}

View File

@@ -1,86 +0,0 @@
@use 'colors' as *;
@use 'themes' as *;
@use 'easings' as *;
@use 'reset' as *;
@use 'layers' as *;
@use 'functions' as *;
@use 'utils' as *;
@use 'fonts' as *;
@use 'font-style' as *;
@use 'layout' as *;
@use 'scroll' as *;
@use 'transitions' as *;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
background: var(--white);
color: var(--black);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
// Type
.h1,
h1,
h2,
h3,
h4,
h5,
h6 {
@include h1;
&.mono {
@include h1-mono;
}
}
.p,
p,
a,
button,
input,
pre,
span,
label,
li {
@include p;
}
.bold {
font-weight: 700;
}
#app {
min-height: 100vh;
}
@keyframes blink {
5% {
opacity: 0;
}
40% {
opacity: 0;
}
60% {
opacity: 1;
}
95% {
opacity: 1;
}
}
@keyframes bonk {
0% {
transform: rotate(calc(var(--bonk-angle) * -1));
}
50% {
transform: rotate(var(--bonk-angle));
}
}