detail page port
This commit is contained in:
@@ -1,32 +1,165 @@
|
|||||||
<template>
|
<template>
|
||||||
<component :is="tag" class="btn">
|
<component
|
||||||
<svg-btn-outline />
|
: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>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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({
|
const props = defineProps({
|
||||||
tag: {
|
// Will render ADiv
|
||||||
|
href: [Object, String],
|
||||||
|
// Will render PrismicLink
|
||||||
|
linkField: Object,
|
||||||
|
tag: String,
|
||||||
|
size: {
|
||||||
type: String,
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.btn {
|
.btn {
|
||||||
text-transform: uppercase;
|
display: inline-flex;
|
||||||
padding: desktop-vw(5px) desktop-vw(16px) desktop-vw(6px);
|
align-items: center;
|
||||||
color: var(--grey-100);
|
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;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--td) var(--te),
|
||||||
|
background var(--td) var(--te);
|
||||||
|
@include text-flip(var(--td), var(--te));
|
||||||
|
|
||||||
&:hover {
|
@include mobile {
|
||||||
color: var(--theme-accent);
|
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>
|
</style>
|
||||||
|
|||||||
76
components/ColumnLayout.vue
Normal file
76
components/ColumnLayout.vue
Normal 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>
|
||||||
76
components/NumberInput.vue
Normal file
76
components/NumberInput.vue
Normal 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
173
components/Slider.vue
Normal 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
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>
|
||||||
27
components/prismic/Image.vue
Normal file
27
components/prismic/Image.vue
Normal 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>
|
||||||
167
components/prismic/Media.vue
Normal file
167
components/prismic/Media.vue
Normal 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>
|
||||||
17
components/prismic/RichText.vue
Normal file
17
components/prismic/RichText.vue
Normal 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>
|
||||||
50
components/slices/Carousel.vue
Normal file
50
components/slices/Carousel.vue
Normal 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>
|
||||||
57
components/slices/Content.vue
Normal file
57
components/slices/Content.vue
Normal 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>
|
||||||
61
components/slices/FullBleedMedia.vue
Normal file
61
components/slices/FullBleedMedia.vue
Normal 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
32
components/slices/Map.vue
Normal 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>
|
||||||
51
components/slices/Quote.vue
Normal file
51
components/slices/Quote.vue
Normal 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">
|
||||||
|
— {{ 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>
|
||||||
@@ -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
21
components/svg/util/Arrow.vue
Normal file
21
components/svg/util/Arrow.vue
Normal 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>
|
||||||
32
components/svg/util/Spinner.vue
Normal file
32
components/svg/util/Spinner.vue
Normal 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
44
composables/prismic.js
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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
107
composables/useKlaviyo.js
Normal 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
10
libs/resolveEventType.ts
Normal 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
133
libs/shopify/cart.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
52
libs/shopify/collection.js
Normal file
52
libs/shopify/collection.js
Normal 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
83
libs/shopify/customer.js
Normal 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
19
libs/shopify/fragments.js
Normal 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
20
libs/shopify/menu.js
Normal 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
143
libs/shopify/product.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -1,25 +1,39 @@
|
|||||||
const colors = {
|
import gsap from 'gsap'
|
||||||
black: '#181818',
|
|
||||||
white: '#D5D5D5',
|
const generateShades = (colors) => {
|
||||||
'grey-100': '#747474',
|
const result = {}
|
||||||
green: '#87FF5B',
|
|
||||||
blue: '#5B92FF',
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
purple: '#94079E',
|
result[key] = value
|
||||||
red: '#D40202',
|
|
||||||
|
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 = {
|
const themes = {
|
||||||
dark: {
|
|
||||||
bg: colors.black,
|
|
||||||
fg: colors.white,
|
|
||||||
accent: colors.green,
|
|
||||||
link: colors.blue,
|
|
||||||
},
|
|
||||||
light: {
|
light: {
|
||||||
bg: colors.white,
|
bg: colors.white,
|
||||||
fg: colors.black,
|
fg: colors.black,
|
||||||
accent: colors.purple,
|
contrast: colors.purple,
|
||||||
link: colors.blue,
|
},
|
||||||
|
dark: {
|
||||||
|
bg: colors.black,
|
||||||
|
fg: colors.white,
|
||||||
|
contrast: colors.purple,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,20 +43,23 @@ const breakpoints = {
|
|||||||
|
|
||||||
const viewports = {
|
const viewports = {
|
||||||
mobile: {
|
mobile: {
|
||||||
width: 440,
|
width: 480,
|
||||||
height: 956,
|
height: 872,
|
||||||
},
|
},
|
||||||
desktop: {
|
desktop: {
|
||||||
width: 1728,
|
width: 1920,
|
||||||
height: 1117,
|
height: 1080,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { colors, themes, breakpoints, viewports }
|
const scrollSmoothing = 0.7
|
||||||
|
|
||||||
|
export { colors, themes, breakpoints, viewports, scrollSmoothing }
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
colors,
|
colors,
|
||||||
themes,
|
themes,
|
||||||
breakpoints,
|
breakpoints,
|
||||||
viewports,
|
viewports,
|
||||||
|
scrollSmoothing,
|
||||||
}
|
}
|
||||||
|
|||||||
2228
package-lock.json
generated
2228
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vike dev",
|
"dev": "vike dev",
|
||||||
"build": "vike build",
|
"build": "vike build",
|
||||||
@@ -6,8 +7,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fuzzco/font-loader": "^1.0.2",
|
"@fuzzco/font-loader": "^1.0.2",
|
||||||
"@strapi/client": "^1.6.1",
|
"@prismicio/client": "^7.21.8",
|
||||||
|
"@prismicio/helpers": "^2.3.9",
|
||||||
|
"@shopify/storefront-api-client": "^1.0.10",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@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",
|
"lenis": "^1.3.23",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
@@ -15,14 +24,11 @@
|
|||||||
"tempus": "^1.0.0-dev.17",
|
"tempus": "^1.0.0-dev.17",
|
||||||
"vike": "^0.4.255",
|
"vike": "^0.4.255",
|
||||||
"vike-vue": "^0.9.11",
|
"vike-vue": "^0.9.11",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30"
|
||||||
"vue-strapi-blocks-renderer": "^1.1.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
}
|
||||||
"type": "module"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import '@/styles/main.scss'
|
import '@/styles/global.scss'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import loadFonts from '@fuzzco/font-loader'
|
import loadFonts from '@fuzzco/font-loader'
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
@@ -20,19 +20,26 @@ const fontsLoading = ref(true)
|
|||||||
const classes = computed(() => [
|
const classes = computed(() => [
|
||||||
'container',
|
'container',
|
||||||
{ 'fonts-ready': !fontsLoading.value },
|
{ 'fonts-ready': !fontsLoading.value },
|
||||||
'theme-dark',
|
'theme-light',
|
||||||
])
|
])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load fonts
|
// Load fonts
|
||||||
loadFonts([
|
loadFonts([
|
||||||
{
|
{
|
||||||
name: 'Leibniz Fraktur',
|
name: 'Arial Narrow',
|
||||||
weights: [400],
|
weights: [700],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Geist Mono',
|
name: 'Druk Wide',
|
||||||
weights: [400, 700],
|
weights: [900],
|
||||||
|
styles: ['normal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GT Super',
|
||||||
|
weights: [400],
|
||||||
|
styles: ['italic'],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
37
pages/@id/+Page.vue
Normal file
37
pages/@id/+Page.vue
Normal 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
29
pages/@id/+data.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,86 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="splash">
|
<main class="splash">
|
||||||
<svg-wordmark />
|
<pre>{{ events }}</pre>
|
||||||
|
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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'
|
import { useData } from 'vike-vue/useData'
|
||||||
|
|
||||||
const BASE_URL = 'https://s3.takerofnotes.com'
|
const { events } = useData()
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
main.splash {
|
main.splash {
|
||||||
height: 100vh;
|
pre {
|
||||||
display: flex;
|
margin: 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { strapi } from '@strapi/client'
|
import * as prismic from '@prismicio/client'
|
||||||
|
|
||||||
export const data = async () => {
|
export const data = async () => {
|
||||||
const client = strapi({
|
const client = prismic.createClient('swang')
|
||||||
baseURL: 'https://cms.takerofnotes.com/api',
|
|
||||||
auth: import.meta.env.STRAPI_API_TOKEN,
|
|
||||||
})
|
|
||||||
|
|
||||||
const global = client.single('global')
|
const events = await client.getAllByType('event')
|
||||||
return await global.find()
|
|
||||||
|
return { events }
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 870 B After Width: | Height: | Size: 4.8 KiB |
BIN
public/fonts/arial-narrow-bold-italic.woff
Normal file
BIN
public/fonts/arial-narrow-bold-italic.woff
Normal file
Binary file not shown.
BIN
public/fonts/arial-narrow-bold-italic.woff2
Normal file
BIN
public/fonts/arial-narrow-bold-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/arial-narrow-bold.woff
Normal file
BIN
public/fonts/arial-narrow-bold.woff
Normal file
Binary file not shown.
BIN
public/fonts/arial-narrow-bold.woff2
Normal file
BIN
public/fonts/arial-narrow-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/druk-wide-cy-tt-super.woff
Normal file
BIN
public/fonts/druk-wide-cy-tt-super.woff
Normal file
Binary file not shown.
BIN
public/fonts/druk-wide-cy-tt-super.woff2
Normal file
BIN
public/fonts/druk-wide-cy-tt-super.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/fonts/gt-super-display-regular-italic.woff
Normal file
BIN
public/fonts/gt-super-display-regular-italic.woff
Normal file
Binary file not shown.
BIN
public/fonts/gt-super-display-regular-italic.woff2
Normal file
BIN
public/fonts/gt-super-display-regular-italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,6 +17,4 @@
|
|||||||
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 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-expo: cubic-bezier(1, 0, 0, 1);
|
||||||
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
--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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@use 'functions' as *;
|
@use 'functions' as *;
|
||||||
|
|
||||||
@mixin size-font($ds, $ms) {
|
@mixin size-font($ms, $ds) {
|
||||||
font-size: mobile-vw($ms);
|
font-size: mobile-vw($ms);
|
||||||
|
|
||||||
&.vh {
|
&.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 {
|
@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-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;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.3;
|
text-transform: none;
|
||||||
@include size-font(30px, 30px);
|
@include size-font(22px, 35px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin h1-mono {
|
// Paragraph
|
||||||
font-family: var(--font-mono);
|
@mixin p-l {
|
||||||
font-weight: 400;
|
font-family: var(--font-arial);
|
||||||
line-height: 1;
|
font-weight: 700;
|
||||||
@include size-font(22px, 22px);
|
line-height: 0.9;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
@include size-font(32px, 45px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin p {
|
@mixin p {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-arial);
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 0.94;
|
||||||
@include size-font(12px, 12px);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,35 +11,42 @@
|
|||||||
900 - Black (Heavy)
|
900 - Black (Heavy)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Leibniz Fraktur */
|
/* ARIAL NARROW */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Leibniz Fraktur';
|
font-family: 'Arial Narrow';
|
||||||
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-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src:
|
src: url('/fonts/arial-narrow-bold.woff2') format('woff2'),
|
||||||
url('/fonts/geist-mono-bold.woff2') format('woff2'),
|
url('/fonts/arial-narrow-bold.woff') format('woff');
|
||||||
url('/fonts/geist-mono-bold.woff') format('woff');
|
|
||||||
}
|
}
|
||||||
@font-face {
|
@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-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;
|
font-weight: 400;
|
||||||
src:
|
src: url('/fonts/gt-super-display-regular-italic.woff2') format('woff2'),
|
||||||
url('/fonts/geist-mono.woff2') format('woff2'),
|
url('/fonts/gt-super-display-regular-italic.woff') format('woff');
|
||||||
url('/fonts/geist-mono.woff') format('woff');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-display: 'Leibniz Fraktur', serif;
|
--font-arial: 'Arial Narrow', sans-serif;
|
||||||
--font-mono: 'Geist Mono', monospace;
|
--font-druk: 'Druk Wide', sans-serif;
|
||||||
|
--font-gt: 'GT Super', serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ $mobile-height: get('viewports.mobile.height');
|
|||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
@include desktop {
|
||||||
|
&:hover {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@mixin reduced-motion {
|
@mixin reduced-motion {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@content;
|
@content;
|
||||||
@@ -170,18 +178,66 @@ $mobile-height: get('viewports.mobile.height');
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) {
|
@mixin text-flip($duration: 400, $easing: linear) {
|
||||||
@for $i from 0 through $num-children {
|
.text {
|
||||||
&:nth-child(#{$i + 1}) {
|
position: relative;
|
||||||
transition-delay: #{$stagger * $i + $base-delay}ms;
|
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 {
|
@mixin scorecard-dot {
|
||||||
@include desktop {
|
&::before {
|
||||||
&:hover {
|
content: '';
|
||||||
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
|
@use 'functions' as *;
|
||||||
|
|
||||||
// z-index
|
// z-index
|
||||||
.lily-cursor {
|
.site-orchestra {
|
||||||
z-index: 20;
|
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 {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,15 @@
|
|||||||
$layout: (
|
$layout: (
|
||||||
'columns-count': (
|
'columns-count': (
|
||||||
5,
|
5,
|
||||||
18,
|
8,
|
||||||
),
|
),
|
||||||
'columns-gap': (
|
'columns-gap': (
|
||||||
20px,
|
20px,
|
||||||
20px,
|
0px,
|
||||||
),
|
),
|
||||||
'margin': (
|
'margin': (
|
||||||
30px,
|
20px,
|
||||||
60px,
|
20px,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,8 +48,7 @@ $layout: (
|
|||||||
(var(--layout-column-count) - 1) *
|
(var(--layout-column-count) - 1) *
|
||||||
var(--layout-column-gap)
|
var(--layout-column-gap)
|
||||||
)
|
)
|
||||||
) /
|
) / var(--layout-column-count)
|
||||||
var(--layout-column-count)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@include desktop {
|
@include desktop {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ button {
|
|||||||
cursor: revert;
|
cursor: revert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remove list styles (bullets/numbers) */
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
menu {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* For images to not be able to exceed their container */
|
/* For images to not be able to exceed their container */
|
||||||
img {
|
img {
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Fades
|
// Fades
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 400ms;
|
transition: opacity 200ms;
|
||||||
}
|
}
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
@@ -41,3 +41,13 @@
|
|||||||
.slide-left-leave-to {
|
.slide-left-leave-to {
|
||||||
transform: translateX(-100%);
|
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
187
styles/global.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user