detail page port
This commit is contained in:
@@ -1,32 +1,165 @@
|
||||
<template>
|
||||
<component :is="tag" class="btn">
|
||||
<svg-btn-outline />
|
||||
<component
|
||||
:class="[`btn p-xs size-${size}`, { secondary, number, hover }]"
|
||||
:is="component"
|
||||
:href="href"
|
||||
:field="linkField"
|
||||
>
|
||||
<span class="text" v-if="linkField">
|
||||
<span>{{ linkField.text }}</span>
|
||||
<span>{{ linkField.text }}</span>
|
||||
</span>
|
||||
<span class="text" v-else-if="slots.default">
|
||||
<span><slot /></span>
|
||||
<span><slot /></span>
|
||||
</span>
|
||||
|
||||
<slot />
|
||||
<span v-if="loading" class="loading">
|
||||
<svg-util-spinner />
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgBtnOutline from '@/components/svg/BtnOutline.vue'
|
||||
import { useSlots, computed, resolveComponent } from 'vue'
|
||||
import SvgUtilSpinner from '@/components/svg/util/Spinner.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
// Will render ADiv
|
||||
href: [Object, String],
|
||||
// Will render PrismicLink
|
||||
linkField: Object,
|
||||
tag: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
default: () => 'small',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
number: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
hover: {
|
||||
type: Boolean,
|
||||
default: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const component = computed(() => {
|
||||
if (props.tag) return props.tag
|
||||
if (props.href) return 'a'
|
||||
if (props.linkField) return resolveComponent('prismic-link')
|
||||
return 'button'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.btn {
|
||||
text-transform: uppercase;
|
||||
padding: desktop-vw(5px) desktop-vw(16px) desktop-vw(6px);
|
||||
color: var(--grey-100);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: desktop-vw(5px) desktop-vw(20px);
|
||||
border: 1px solid var(--theme-fg);
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--td) var(--te),
|
||||
background var(--td) var(--te);
|
||||
@include text-flip(var(--td), var(--te));
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-accent);
|
||||
@include mobile {
|
||||
padding: mobile-vw(5px) mobile-vw(20px);
|
||||
}
|
||||
|
||||
.text span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: desktop-vw(6px);
|
||||
width: desktop-vw(12px);
|
||||
height: auto;
|
||||
|
||||
@include mobile {
|
||||
margin-left: mobile-vw(6px);
|
||||
width: mobile-vw(12px);
|
||||
}
|
||||
}
|
||||
|
||||
&.size-large {
|
||||
font-size: desktop-vw(28px);
|
||||
padding: desktop-vw(8.7px) desktop-vw(34.7px);
|
||||
|
||||
@include mobile {
|
||||
font-size: mobile-vw(28px);
|
||||
padding: mobile-vw(8.7px) mobile-vw(34.7px);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--theme-bg);
|
||||
color: var(--theme-fg);
|
||||
}
|
||||
|
||||
&.number {
|
||||
padding: desktop-vw(5px) desktop-vw(10px);
|
||||
|
||||
@include mobile {
|
||||
padding: mobile-vw(5px) mobile-vw(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&.hover {
|
||||
&:hover,
|
||||
&.active,
|
||||
&.router-link-active,
|
||||
&.router-link-exact-active {
|
||||
color: var(--theme-fg);
|
||||
background: var(--theme-bg);
|
||||
|
||||
&.secondary {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.hover) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
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 = {
|
||||
black: '#181818',
|
||||
white: '#D5D5D5',
|
||||
'grey-100': '#747474',
|
||||
green: '#87FF5B',
|
||||
blue: '#5B92FF',
|
||||
purple: '#94079E',
|
||||
red: '#D40202',
|
||||
import gsap from 'gsap'
|
||||
|
||||
const generateShades = (colors) => {
|
||||
const result = {}
|
||||
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
result[key] = value
|
||||
|
||||
for (let i = 0; i <= 19; i++) {
|
||||
const split = gsap.utils.splitColor(value)
|
||||
const rgb = split.toString().replaceAll(',', ' ')
|
||||
const alpha = parseInt((((i / 20) * 0xff) / 255) * 100)
|
||||
|
||||
result[`${key}-${i * 5}`] = `rgb(${rgb} / ${alpha}%)`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const colors = generateShades({
|
||||
black: '#0B0D0B',
|
||||
white: '#FCFCF6',
|
||||
purple: '#541CDC',
|
||||
})
|
||||
|
||||
const themes = {
|
||||
dark: {
|
||||
bg: colors.black,
|
||||
fg: colors.white,
|
||||
accent: colors.green,
|
||||
link: colors.blue,
|
||||
},
|
||||
light: {
|
||||
bg: colors.white,
|
||||
fg: colors.black,
|
||||
accent: colors.purple,
|
||||
link: colors.blue,
|
||||
contrast: colors.purple,
|
||||
},
|
||||
dark: {
|
||||
bg: colors.black,
|
||||
fg: colors.white,
|
||||
contrast: colors.purple,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,20 +43,23 @@ const breakpoints = {
|
||||
|
||||
const viewports = {
|
||||
mobile: {
|
||||
width: 440,
|
||||
height: 956,
|
||||
width: 480,
|
||||
height: 872,
|
||||
},
|
||||
desktop: {
|
||||
width: 1728,
|
||||
height: 1117,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
}
|
||||
|
||||
export { colors, themes, breakpoints, viewports }
|
||||
const scrollSmoothing = 0.7
|
||||
|
||||
export { colors, themes, breakpoints, viewports, scrollSmoothing }
|
||||
|
||||
export default {
|
||||
colors,
|
||||
themes,
|
||||
breakpoints,
|
||||
viewports,
|
||||
scrollSmoothing,
|
||||
}
|
||||
|
||||
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": {
|
||||
"dev": "vike dev",
|
||||
"build": "vike build",
|
||||
@@ -6,8 +7,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-class-names": "^8.6.0",
|
||||
"embla-carousel-vue": "^8.6.0",
|
||||
"fecha": "^4.2.3",
|
||||
"graphql-request": "^7.4.0",
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.23",
|
||||
"lodash": "^4.18.1",
|
||||
"sass": "^1.99.0",
|
||||
@@ -15,14 +24,11 @@
|
||||
"tempus": "^1.0.0-dev.17",
|
||||
"vike": "^0.4.255",
|
||||
"vike-vue": "^0.9.11",
|
||||
"vue": "^3.5.30",
|
||||
"vue-strapi-blocks-renderer": "^1.1.1"
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import '@/styles/main.scss'
|
||||
import '@/styles/global.scss'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import loadFonts from '@fuzzco/font-loader'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
@@ -20,19 +20,26 @@ const fontsLoading = ref(true)
|
||||
const classes = computed(() => [
|
||||
'container',
|
||||
{ 'fonts-ready': !fontsLoading.value },
|
||||
'theme-dark',
|
||||
'theme-light',
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
// Load fonts
|
||||
loadFonts([
|
||||
{
|
||||
name: 'Leibniz Fraktur',
|
||||
weights: [400],
|
||||
name: 'Arial Narrow',
|
||||
weights: [700],
|
||||
styles: ['normal', 'italic'],
|
||||
},
|
||||
{
|
||||
name: 'Geist Mono',
|
||||
weights: [400, 700],
|
||||
name: 'Druk Wide',
|
||||
weights: [900],
|
||||
styles: ['normal'],
|
||||
},
|
||||
{
|
||||
name: 'GT Super',
|
||||
weights: [400],
|
||||
styles: ['italic'],
|
||||
},
|
||||
])
|
||||
.then(() => {
|
||||
|
||||
37
pages/@id/+Page.vue
Normal file
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>
|
||||
<main class="splash">
|
||||
<svg-wordmark />
|
||||
|
||||
<div class="content">
|
||||
<strapi-blocks v-if="content" :content="content" />
|
||||
</div>
|
||||
|
||||
<a :href="downloadUrl" download>
|
||||
<btn :disabled="loading">
|
||||
{{ loading ? 'Checking for updates…' : `Download for ${os}` }}
|
||||
</btn>
|
||||
</a>
|
||||
<pre>{{ events }}</pre>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { StrapiBlocks } from 'vue-strapi-blocks-renderer'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import SvgWordmark from '@/components/svg/Wordmark.vue'
|
||||
import useDetectOS from '@/composables/useDetectOS'
|
||||
import Btn from '@/components/Btn.vue'
|
||||
import { useData } from 'vike-vue/useData'
|
||||
|
||||
const BASE_URL = 'https://s3.takerofnotes.com'
|
||||
|
||||
const { os } = useDetectOS()
|
||||
const { data } = useData()
|
||||
|
||||
const version = ref(null)
|
||||
const downloadPath = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
if (!downloadPath.value || os.value === 'Unknown') return null
|
||||
return `${BASE_URL}/dist/${os.value.toLowerCase()}/${version.value}/${downloadPath.value}`
|
||||
})
|
||||
const content = computed(() => data?.content)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/dist/latest-${os.value.toLowerCase()}.yml`,
|
||||
)
|
||||
if (!response.ok) throw new Error(response.statusText)
|
||||
const yaml = await response.text()
|
||||
|
||||
const versionMatch = yaml.match(/^version:\s*(.+)/m)
|
||||
const pathMatch = yaml.match(/^path:\s*(.+)/m)
|
||||
|
||||
if (versionMatch) version.value = versionMatch[1].trim()
|
||||
if (pathMatch) downloadPath.value = pathMatch[1].trim()
|
||||
} catch {
|
||||
// fallback to placeholder
|
||||
// downloadPath.value = `takerofnotes-app-0.2.0.${os.value === 'Windows' ? 'exe' : os.value === 'macOS' ? 'dmg' : 'AppImage'}`
|
||||
console.error('Failed to fetch latest version info')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
const { events } = useData()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
main.splash {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: desktop-vw(40px);
|
||||
|
||||
.svg-wordmark {
|
||||
width: 70%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: var(--layout-margin);
|
||||
|
||||
p {
|
||||
max-width: desktop-vw(500px);
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { strapi } from '@strapi/client'
|
||||
import * as prismic from '@prismicio/client'
|
||||
|
||||
export const data = async () => {
|
||||
const client = strapi({
|
||||
baseURL: 'https://cms.takerofnotes.com/api',
|
||||
auth: import.meta.env.STRAPI_API_TOKEN,
|
||||
})
|
||||
const client = prismic.createClient('swang')
|
||||
|
||||
const global = client.single('global')
|
||||
return await global.find()
|
||||
const events = await client.getAllByType('event')
|
||||
|
||||
return { events }
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 870 B After Width: | Height: | Size: 4.8 KiB |
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-expo: cubic-bezier(1, 0, 0, 1);
|
||||
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||
|
||||
--ease-custom: cubic-bezier(0.315, 0.365, 0.23, 0.985);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use 'functions' as *;
|
||||
|
||||
@mixin size-font($ds, $ms) {
|
||||
@mixin size-font($ms, $ds) {
|
||||
font-size: mobile-vw($ms);
|
||||
|
||||
&.vh {
|
||||
@@ -16,24 +16,165 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Super
|
||||
@mixin s1 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(240px, 240px);
|
||||
}
|
||||
@mixin s2 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(50px, 160px);
|
||||
}
|
||||
@mixin s3 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(60px, 140px);
|
||||
}
|
||||
@mixin s4 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.046em;
|
||||
@include size-font(115px, 115px);
|
||||
}
|
||||
|
||||
// Header
|
||||
@mixin h1 {
|
||||
font-family: var(--font-display);
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(46px, 85px);
|
||||
}
|
||||
@mixin h2 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(32px, 60px);
|
||||
}
|
||||
@mixin h3 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(32px, 40px);
|
||||
}
|
||||
@mixin h4 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(18px, 30px);
|
||||
}
|
||||
@mixin h5 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 900;
|
||||
line-height: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(18px, 20px);
|
||||
}
|
||||
@mixin h6 {
|
||||
font-family: var(--font-druk);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-transform: none;
|
||||
letter-spacing: -0.01em;
|
||||
@include size-font(18px, 18px);
|
||||
}
|
||||
|
||||
// Emphasis
|
||||
@mixin em1 {
|
||||
font-family: var(--font-gt);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.06em;
|
||||
text-transform: none;
|
||||
@include size-font(48px, 80px);
|
||||
}
|
||||
@mixin em2 {
|
||||
font-family: var(--font-gt);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: none;
|
||||
@include size-font(36px, 61px);
|
||||
}
|
||||
@mixin em3 {
|
||||
font-family: var(--font-gt);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
line-height: 0.8;
|
||||
text-transform: none;
|
||||
@include size-font(22px, 41px);
|
||||
}
|
||||
|
||||
// Quote
|
||||
@mixin q1 {
|
||||
font-family: var(--font-gt);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
@include size-font(30px, 30px);
|
||||
text-transform: none;
|
||||
@include size-font(22px, 35px);
|
||||
}
|
||||
|
||||
@mixin h1-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
@include size-font(22px, 22px);
|
||||
// Paragraph
|
||||
@mixin p-l {
|
||||
font-family: var(--font-arial);
|
||||
font-weight: 700;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
@include size-font(32px, 45px);
|
||||
}
|
||||
|
||||
@mixin p {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
@include size-font(12px, 12px);
|
||||
font-family: var(--font-arial);
|
||||
font-weight: 700;
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.03em;
|
||||
text-transform: uppercase;
|
||||
@include size-font(16px, 21px);
|
||||
}
|
||||
@mixin p-s {
|
||||
font-family: var(--font-arial);
|
||||
font-weight: 700;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
@include size-font(14px, 18px);
|
||||
}
|
||||
@mixin p-xs {
|
||||
font-family: var(--font-arial);
|
||||
font-weight: 700;
|
||||
line-height: 0.94;
|
||||
text-transform: uppercase;
|
||||
@include size-font(14px, 16px);
|
||||
}
|
||||
@mixin p-xxs {
|
||||
font-family: var(--font-arial);
|
||||
font-weight: 700;
|
||||
line-height: 0.94;
|
||||
text-transform: uppercase;
|
||||
@include size-font(12px, 14px);
|
||||
}
|
||||
|
||||
@@ -11,35 +11,42 @@
|
||||
900 - Black (Heavy)
|
||||
*/
|
||||
|
||||
/* Leibniz Fraktur */
|
||||
/* ARIAL NARROW */
|
||||
@font-face {
|
||||
font-family: 'Leibniz Fraktur';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url('/fonts/neuefraktur.woff2') format('woff2'),
|
||||
url('/fonts/neuefraktur.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Geist Mono */
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-family: 'Arial Narrow';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url('/fonts/geist-mono-bold.woff2') format('woff2'),
|
||||
url('/fonts/geist-mono-bold.woff') format('woff');
|
||||
src: url('/fonts/arial-narrow-bold.woff2') format('woff2'),
|
||||
url('/fonts/arial-narrow-bold.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-family: 'Arial Narrow';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('/fonts/arial-narrow-bold-italic.woff2') format('woff2'),
|
||||
url('/fonts/arial-narrow-bold-italic.woff') format('woff');
|
||||
}
|
||||
|
||||
/* DRUK WIDE */
|
||||
@font-face {
|
||||
font-family: 'Druk Wide';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url('/fonts/druk-wide-cy-tt-super.woff2') format('woff2'),
|
||||
url('/fonts/druk-wide-cy-tt-super.woff') format('woff');
|
||||
}
|
||||
|
||||
/* GT SUPER */
|
||||
@font-face {
|
||||
font-family: 'GT Super';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url('/fonts/geist-mono.woff2') format('woff2'),
|
||||
url('/fonts/geist-mono.woff') format('woff');
|
||||
src: url('/fonts/gt-super-display-regular-italic.woff2') format('woff2'),
|
||||
url('/fonts/gt-super-display-regular-italic.woff') format('woff');
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-display: 'Leibniz Fraktur', serif;
|
||||
--font-mono: 'Geist Mono', monospace;
|
||||
--font-arial: 'Arial Narrow', sans-serif;
|
||||
--font-druk: 'Druk Wide', sans-serif;
|
||||
--font-gt: 'GT Super', serif;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,14 @@ $mobile-height: get('viewports.mobile.height');
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
@include desktop {
|
||||
&:hover {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin reduced-motion {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@content;
|
||||
@@ -170,18 +178,66 @@ $mobile-height: get('viewports.mobile.height');
|
||||
}
|
||||
}
|
||||
|
||||
@mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) {
|
||||
@for $i from 0 through $num-children {
|
||||
&:nth-child(#{$i + 1}) {
|
||||
transition-delay: #{$stagger * $i + $base-delay}ms;
|
||||
@mixin text-flip($duration: 400, $easing: linear) {
|
||||
.text {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
span:last-child {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateY(105%);
|
||||
}
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
&:hover,
|
||||
&.active {
|
||||
.text {
|
||||
span:first-child {
|
||||
animation: button-flip-out $duration $easing forwards;
|
||||
}
|
||||
span:last-child {
|
||||
animation: button-flip-in $duration $easing forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes button-flip-in {
|
||||
from {
|
||||
transform: translateY(105%);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes button-flip-out {
|
||||
from {
|
||||
transform: none;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-105%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
@include desktop {
|
||||
&:hover {
|
||||
@content;
|
||||
}
|
||||
@mixin scorecard-dot {
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 46%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--theme-bg);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform var(--td) var(--te);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
@use 'functions' as *;
|
||||
|
||||
// z-index
|
||||
.lily-cursor {
|
||||
z-index: 20;
|
||||
.site-orchestra {
|
||||
z-index: 100;
|
||||
}
|
||||
.stats,
|
||||
.grid-debug {
|
||||
z-index: 99 !important;
|
||||
}
|
||||
|
||||
main.front-page,
|
||||
.site-mobile-menu {
|
||||
z-index: 80;
|
||||
}
|
||||
.webgl-canvas {
|
||||
z-index: 70;
|
||||
}
|
||||
.cart {
|
||||
z-index: 60;
|
||||
}
|
||||
.site-header {
|
||||
z-index: 10;
|
||||
z-index: 50;
|
||||
}
|
||||
.site-footer {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.cart {
|
||||
z-index: 80;
|
||||
}
|
||||
main.front-page {
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
$layout: (
|
||||
'columns-count': (
|
||||
5,
|
||||
18,
|
||||
8,
|
||||
),
|
||||
'columns-gap': (
|
||||
20px,
|
||||
20px,
|
||||
0px,
|
||||
),
|
||||
'margin': (
|
||||
30px,
|
||||
60px,
|
||||
20px,
|
||||
20px,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -48,8 +48,7 @@ $layout: (
|
||||
(var(--layout-column-count) - 1) *
|
||||
var(--layout-column-gap)
|
||||
)
|
||||
) /
|
||||
var(--layout-column-count)
|
||||
) / var(--layout-column-count)
|
||||
);
|
||||
|
||||
@include desktop {
|
||||
|
||||
@@ -28,6 +28,13 @@ button {
|
||||
cursor: revert;
|
||||
}
|
||||
|
||||
/* Remove list styles (bullets/numbers) */
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* For images to not be able to exceed their container */
|
||||
img {
|
||||
max-inline-size: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Fades
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 400ms;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@@ -41,3 +41,13 @@
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
// Page
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 400ms;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
187
styles/global.scss
Normal file
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