🚧 Switch to monorepo with Turbo

This commit is contained in:
2023-01-10 12:53:42 +01:00
parent dd8715bb34
commit 25bb949a13
205 changed files with 14975 additions and 347 deletions

View File

@@ -0,0 +1,15 @@
<script lang="ts">
export let domain: string
export let enabled: boolean = !import.meta.env.DEV
</script>
<svelte:head>
{#if enabled}
<script defer data-domain={domain} src="https://analytics.flayks.com/js/{import.meta.env.DEV ? 'script.local.outbound-links' : 'script.outbound-links'}.js"></script>
<script>
window.plausible = window.plausible || function() {
(window.plausible.q = window.plausible.q || []).push(arguments)
}
</script>
{/if}
</svelte:head>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { getContext } from 'svelte'
import { getAssetUrlKey } from '$utils/api'
const { settings }: any = getContext('global')
export let title: string
export let description: string = undefined
export let image: string = getAssetUrlKey(settings.seo_image.id, 'share-image')
export let url: string = undefined
export let type: string = 'website'
export let card: string = 'summary_large_image'
export let creator: string = undefined
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description}>
<meta property="og:title" content={title} />
<meta name="twitter:title" content={title} />
{#if description}
<meta property="og:description" content={description} />
<meta name="twitter:description" content={description} />
{/if}
<meta property="og:type" content={type} />
{#if image}
<meta property="og:image" content={image} />
<meta name="twitter:image" content={image} />
{/if}
{#if url}
<meta property="og:url" content={url} />
{/if}
<meta property="twitter:card" content={card} />
<meta property="twitter:creator" content={creator} />
</svelte:head>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/stores'
import { afterUpdate } from 'svelte'
import { fade } from 'svelte/transition'
import { scrollToTop } from '$utils/functions'
import { pageLoading } from '$utils/stores'
import { DELAY, DURATION } from '$utils/constants'
let loadingTimeout: ReturnType<typeof setTimeout> | number = null
$: doNotScroll = !$page.url.searchParams.get('country') && !$page.url.pathname.includes('/shop/')
// Hide page loading indicator on page update
afterUpdate(() => {
clearTimeout(loadingTimeout)
loadingTimeout = setTimeout(() => $pageLoading = false, DURATION.PAGE_IN)
})
</script>
<div class="page"
in:fade={{ duration: DURATION.PAGE_IN, delay: DELAY.PAGE_LOADING }}
out:fade={{ duration: DURATION.PAGE_OUT }}
on:outrostart={() => {
// Show page loading indicator
$pageLoading = true
}}
on:outroend={() => {
// Scroll back to top
doNotScroll && requestAnimationFrame(() => scrollToTop())
}}
>
<slot />
</div>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { browser } from '$app/environment'
import { onMount } from 'svelte'
import Lenis from '@studio-freight/lenis'
import { smoothScroll } from '$utils/stores'
let smoothScrollRAF = 0
// Setup smooth scroll
if (browser) {
$smoothScroll = new Lenis({
duration: 1.2,
easing: (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)), // https://easings.net/
smooth: true,
direction: 'vertical',
})
}
// Lenis RAF
const update = (time: number) => {
$smoothScroll.raf(time)
smoothScrollRAF = requestAnimationFrame(update)
}
onMount(() => {
// Enable smooth scroll
requestAnimationFrame(update)
// Destroy
return () => {
cancelAnimationFrame(smoothScrollRAF)
$smoothScroll.destroy()
}
})
</script>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { splitText } from '$utils/functions'
export let text: string
export let mode: string = undefined
export let clone: boolean = false
$: split = splitText(text, mode)
const classes = ['text-split', $$props.class].join(' ').trim()
</script>
{#if clone}
{#if mode && mode === 'words'}
<span class={classes}>
{#each Array(2) as _, index}
<span class="text-split__line" aria-hidden={index === 1}>
{#each split as word, i}
<span class="word" style:--i-w={i}>{word}</span>{#if word.includes('\n')}<br>{/if}
<!-- svelte-ignore empty-block -->
{#if i < split.length - 1}{/if}
{/each}
</span>
{/each}
</span>
{:else}
<span class={classes}>
{#each Array(2) as _, index}
<span class="text-split__line" aria-hidden={index === 1}>
{text}
</span>
{/each}
</span>
{/if}
{:else}
<span class={classes}>
{#each split as char, i}
<span class="char" style:--i-c={i}>{char}</span>
{/each}
</span>
{/if}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import Image from './Image.svelte'
export let id: string
export let alt: string
export let disabled: boolean = false
let hovering: boolean = false
let timer: ReturnType<typeof setTimeout> | number = null
$: classes = [
hovering ? 'is-hovered' : undefined,
disabled ? 'is-disabled' : undefined,
$$props.class
].join(' ').trim()
// Hovering functions
const handleMouseEnter = () => {
clearTimeout(timer)
hovering = true
}
const handleMouseLeave = () => {
// Reset hovering to false after a delay
timer = setTimeout(() => hovering = false, 800)
}
</script>
<figure class={classes}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
>
<Image
{id}
sizeKey="photo-list"
sizes={{
small: { width: 250 },
}}
ratio={1.5}
{alt}
/>
</figure>

View File

@@ -0,0 +1,12 @@
<style lang="scss">
@import "../../style/atoms/badge";
</style>
<script lang="ts">
export let text: string
export let size: string = 'small'
</script>
<div class="badge badge--{size}">
<span>{text}</span>
</div>

View File

@@ -0,0 +1,21 @@
<style lang="scss">
@import "../../style/atoms/box-cta";
</style>
<script lang="ts">
import Icon from '$components/atoms/Icon.svelte'
export let icon: string
export let alt: string
export let label: string
export let url: string
</script>
<a href={url} class="box-cta">
<div class="icon">
<Icon icon={icon} label={alt} />
</div>
<span class="text-label">
{label}
</span>
</a>

View File

@@ -0,0 +1,60 @@
<style lang="scss">
@import "../../style/atoms/button";
</style>
<script lang="ts">
import SplitText from '$components/SplitText.svelte'
export let tag: string = 'a'
export let text: string
export let url: string = undefined
export let color: string = undefined
export let size: string = undefined
export let effect: string = 'link-3d'
export let disabled: boolean = undefined
export let slotPosition: string = 'before'
const className = 'button'
const classes = [
className,
effect ? effect : undefined,
...[color, size].map(variant => variant && `${className}--${variant}`),
Object.keys($$slots).length !== 0 ? `has-icon-${slotPosition}` : undefined,
$$props.class
].join(' ').trim()
// Define external links
$: isExternal = /^(http|https):\/\//i.test(url)
$: isProtocol = /^(mailto|tel):/i.test(url)
$: rel = isExternal ? 'external noopener' : null
$: target = isExternal ? '_blank' : null
</script>
{#if tag === 'button'}
<button class={classes} tabindex="0" {disabled} on:click>
{#if slotPosition === 'before'}
<slot />
{/if}
<SplitText {text} clone={true} />
{#if slotPosition === 'after'}
<slot />
{/if}
</button>
{:else if tag === 'a'}
<a
href={url} class={classes}
{target} {rel}
data-sveltekit-noscroll={isExternal || isProtocol ? 'off' : ''}
{disabled}
tabindex="0"
on:click
>
{#if slotPosition === 'before'}
<slot />
{/if}
<SplitText {text} clone={true} />
{#if slotPosition === 'after'}
<slot />
{/if}
</a>
{/if}

View File

@@ -0,0 +1,27 @@
<style lang="scss">
@import "../../style/atoms/button-cart";
</style>
<script lang="ts">
import { scale } from 'svelte/transition'
import { quartOut } from 'svelte/easing'
import { cartOpen, cartAmount } from '$utils/stores/shop'
import { sendEvent } from '$utils/analytics'
// Components
import Icon from '$components/atoms/Icon.svelte'
import ButtonCircle from '$components/atoms/ButtonCircle.svelte'
const openCart = () => {
$cartOpen = true
sendEvent('cartOpen')
}
</script>
<div class="button-cart">
<ButtonCircle color="purple" on:click={openCart}>
<Icon icon="bag" label="Cart icon" />
{#if $cartAmount > 0}
<span class="quantity" transition:scale={{ start: 0.6, duration: 400, easing: quartOut }}>{$cartAmount}</span>
{/if}
</ButtonCircle>
</div>

View File

@@ -0,0 +1,44 @@
<style lang="scss">
@import "../../style/atoms/button-circle";
</style>
<script lang="ts">
export let tag: string = 'button'
export let url: string = undefined
export let color: string = undefined
export let size: string = undefined
export let type: string = undefined
export let clone: boolean = false
export let disabled: boolean = undefined
export let label: string = undefined
const className = 'button-circle'
const classes = [
className,
...[color, size].map(variant => variant && `${className}--${variant}`),
clone ? 'has-clone' : null,
$$props.class
].join(' ').trim()
</script>
{#if tag === 'a'}
<a href={url} class={classes} tabindex="0" aria-label={label} on:click>
{#if clone}
{#each Array(2) as _}
<slot />
{/each}
{:else}
<slot />
{/if}
</a>
{:else}
<button {type} class={classes} disabled={disabled} tabindex="0" aria-label={label} on:click>
{#if clone}
{#each Array(2) as _}
<slot />
{/each}
{:else}
<slot />
{/if}
</button>
{/if}

View File

@@ -0,0 +1,15 @@
<style lang="scss">
@import "../../style/atoms/discover";
</style>
<script lang="ts">
import { getContext } from 'svelte'
const { count }: any = getContext('global')
</script>
<p class="discover">
Discover <strong>{count.photos} homes</strong><br>
from <strong>{count.locations} places</strong>
in <strong>{count.countries} countries</strong>
</p>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
export let icon: string
export let label: string = undefined
const classes = [$$props.class].join(' ').trim()
</script>
<svg class={classes} aria-label={label} width="32" height="32">
<use xlink:href="#icon-{icon}" />
</svg>

View File

@@ -0,0 +1,15 @@
<style lang="scss">
@import "../../style/atoms/arrow";
</style>
<script lang="ts">
export let color: string = undefined
export let flip: boolean = false
</script>
<svg width="12" height="14"
class="arrow arrow--{color}"
class:arrow--flip={flip}
>
<use xlink:href="#arrow" />
</svg>

View File

@@ -0,0 +1,44 @@
<style lang="scss">
svg {
width: 24px;
height: 24px;
color: $color-gray;
}
</style>
<script lang="ts">
export let animate: boolean = false
const classes = ['icon-earth', $$props.class].join(' ').trim()
</script>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"
class={classes}
>
{#if animate}
<defs>
<mask id="circle" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<circle cx="24" cy="24" r="23" fill="currentColor" stroke="currentColor" stroke-width="2"/>
</mask>
</defs>
<g mask="url(#circle)" fill="currentColor">
<g class:anim-earth={animate}>
<path d="M23.56 26.87a.63.63 0 0 1-.61-.64c-.06-1.26-1.06.38-2.42-1.48a.65.65 0 0 0-.56-.28c-7.11.3-2.58-.64-5.48-1.45a.65.65 0 0 1-.46-.6c0-1.73-2.2-.06-.78-2.55a.6.6 0 0 1 .33-.28c1.91-.7 2.12-.44 2.3-.07a.63.63 0 0 0 1.2-.11c.37-1.16 2.28-1.98 3.3-2.6a.64.64 0 0 0 .3-.68l-.1-.4a.62.62 0 0 1 0-.21.63.63 0 0 1 .71-.54l2.9.3h.02a.63.63 0 0 0 .69-.59c.05-.93.28-2.27-.56-2.23a.67.67 0 0 1-.6-.37l-.39-.78a.63.63 0 0 0-.67-.36c-.76.14-2.04.3-2.56.75a.65.65 0 0 1-.69.11c-2.41-1.04.7-.32 1.49-3.05l.02-.14a.63.63 0 0 0-.58-.68c-1.7-.15-4.22-.95-5.15.6a.65.65 0 0 1-.63.3L5.21 7.67a.62.62 0 0 0-.47.12.63.63 0 0 0-.23.67l.18.78a.66.66 0 0 1-.03.39.63.63 0 0 1-.58.39c-1.15-.04-1.11.2-1.06 1.64a.63.63 0 0 0 .32.52c.52.28 1.1.42 1.69.4v.04a.63.63 0 0 0 .63-.55c.2-1.27 1.77-.53 2.85-.53a.63.63 0 0 1 .5.25 3.28 3.28 0 0 1 .6 2.47.65.65 0 0 1-.57.56h-.16a.63.63 0 0 0-.56.58l-.33 3.66a.63.63 0 0 0 .18.52c.82.8 1.9 1.27 1.49 2.37a.61.61 0 0 0 .44.85c1.06.2 2.76.48 2.98.95a.63.63 0 0 0 .44.35c2.05.45.32 2.35 1.8 1.45a.64.64 0 0 1 .96.73c-.13.45-.32.88-.75 1.04a.61.61 0 0 0-.4.58v.93a.62.62 0 0 0 .23.48c4.02 3.05-1.3 2.33 2.98 4.47a.64.64 0 0 1 .37.56c.04 1.49-.48 5.8.6 6.64a.65.65 0 0 1 .24.67c-.37 1.53.55 1.67 1.73 2.27l.1.04a.63.63 0 0 0 .79-.41c.96-2.72-1.45.76-.82-4.97a.62.62 0 0 1 .24-.43c7.92-6.03 1.38-3.9 5.24-7.52a.65.65 0 0 0 .12-.8c-.95-1.61-1.34-2.86-3.39-2.97ZM84.07 9.8c-.76-1.37-3.07-.3-4.46-.16a.46.46 0 0 1-.47-.24c-.85-1.6-4.89-1.83-6.36-2.13a.52.52 0 0 0-.18 0c-3.1.5-4.22 1.8-6.53-.98a.47.47 0 0 0-.37-.19h-7.25a.48.48 0 0 0-.24.06c-3.01 1.7-.04 1.78-3.3 2.55a.48.48 0 0 1-.5-.19c-.55-.7-3.78-.18-4.21-.33l-3.48-1.2a.48.48 0 0 0-.31 0c-2.05.82-2.75 1.35-3.35 3.52a.5.5 0 0 0 .06.34l.5.96a.46.46 0 0 0 .4.25c.84.03 1.4.13 1.57-.84a.49.49 0 0 1 .24-.34c1.49-.76.97.9 1.08 1.96a.47.47 0 0 1-.3.5l-2 .7a.46.46 0 0 1-.62-.35c-.13-.68-.31-.96-1.02-1.08a.46.46 0 0 0-.52.5c.06 1.25-.35 1.3-1.65 1.6-.11.04-.2.1-.26.2-.4.51-.62.9-.47 1.4a.47.47 0 0 1-.45.58c-1.22.02-.74.65-.5 1.86a.46.46 0 0 0 .45.39c.8.04 1.34.1 1.52-.74a.46.46 0 0 1 .5-.36c.64.07 1.29-.04 1.86-.33a.46.46 0 0 1 .58.07c.37.43.65.75.65 1.21a.47.47 0 0 0 .65.43h.02a.46.46 0 0 0 .24-.61c-.1-.22-.22-.48-.3-.73a.47.47 0 0 1 .82-.42c.7.98.95 1.63 2.34.93a.47.47 0 0 1 .54.07c.73.7 1.29 1.28.9 2.34a.47.47 0 0 1-.32.28c-1.54.45-3.55 1-5.02.06a.46.46 0 0 1-.2-.28c-.38-1.64-2.03-1.2-3.33-1.28a.47.47 0 0 0-.32.1l-3.87 3.1a.46.46 0 0 0-.19.33c-.03.67-.14 2.18-.7 2.53a.48.48 0 0 0-.2.41v1.77c0 .12.04.23.12.32 3.22 3.5 4.4.82 7.25 2.64a.48.48 0 0 1 .23.39c0 1.34-.08 1.94 1.28 2.44a.47.47 0 0 1 .3.52c-1.12 6.14.72 3.44.89 8.13a.47.47 0 0 0 .33.45c2.88.83 3.2-.28 5.15-2.16a.47.47 0 0 0 .13-.37c-.1-2.31-.44-3.1 1.68-4.43a.47.47 0 0 0 .22-.41c0-2.44-.15-3.85 1.9-5.53a.49.49 0 0 0 .18-.39c-.1-1.64-1.28-1.6-2.55-1.93a.46.46 0 0 1-.35-.47c.04-2.16-1.37-1.62-1.11-3.7a.46.46 0 0 1 .72-.34c.63.45 1.32 1.68 1.7 2.16a.47.47 0 0 1 .09.37c-.5 2.66 2.06.75 3.38-.09a.46.46 0 0 0 .15-.64v-.01l-1.48-2.23a.46.46 0 0 1 .09-.65c.08-.06.18-.1.28-.1l.67-.02a.47.47 0 0 1 .48.32c.41 1.41 2.34 2.4 3.7 2.94a.46.46 0 0 1 .28.54c-.47 2.05 1.08 2.94 2.42 4.13a.47.47 0 0 0 .75-.18l1.48-4.06a.47.47 0 0 1 .52-.3l1.03.19a.47.47 0 0 1 .37.3l1 2.79.11.15.19.2a.46.46 0 0 0 .82-.33c-.04-.79.09-1.38.9-.41a.46.46 0 0 0 .55.14c2.04-.89 1.33-2.53 1.3-4.37a.46.46 0 0 1 .39-.46c1.3-.23 1.4-.86 1.67-2.1a.47.47 0 0 0-.11-.41l-1.2-1.36a.46.46 0 0 1-.08-.53c.28-.55.73-1.45 1.34-1.45a.44.44 0 0 0 .43-.24c.44-.97 1.86-3.96 2.36-5.1a.46.46 0 0 1 .69-.18c.6.39.61.98.57 1.76a.47.47 0 0 0 .24.45c1.92 1.04-.01-1.79 4.65-3.94a.46.46 0 0 0 .24-.61l-.01-.04Z"/>
<path d="m30.64 5.77-2.4-1.5a1.77 1.77 0 0 0-1.39-.21c-1.74.46-5.93 1.1-5.05 2.58 2.1 2.98-.99 3.03 1.99 3.54.38.07.73.28.98.6h.02c1.93 2.41 2.4 2.13 3.72-.47.18-.37.49-.65.87-.8 1.92-.73 2.57-.49 1.97-2.77-.1-.4-.36-.75-.7-.97Zm8.63 8.02.54.1c.06.02.12.02.18 0h.02c1.12-.18 1.1-.81 1.04-1.61a.56.56 0 0 0-1.04-.13l-.02.04a.52.52 0 0 1-.48.39c-.52.03-.69.18-.69.69a.56.56 0 0 0 .45.52ZM77.7 37a.26.26 0 0 0-.05-.1c-.3-.5-.92-2.46-1.63-3.04a.26.26 0 0 0-.41.23c.02.65 0 .89-.82.61a.25.25 0 0 1-.15-.15v.02c-.31-.74-1.48-.37-2.15-.24a.26.26 0 0 0-.17.13c-.37.74-1.41 1.45-2.23 1.82a.61.61 0 0 0-.37.6l.13 1.99a.61.61 0 0 0 .61.58h2.42c.07 0 .14.03.18.09.56.65 2.29.98 3.15 1.13a.26.26 0 0 0 .22-.07 3.9 3.9 0 0 0 1.26-3.6Zm1.54 2.15a10.45 10.45 0 0 1-2.75 2.95.44.44 0 0 0-.2.49c.29.8.9.74 1.68.73a.44.44 0 0 0 .41-.38c.69-3.22 2.66-2.36 1.49-3.76a.45.45 0 0 0-.63-.03ZM55.63 35.6c-.32-.17-.62.12-.86.48a3.5 3.5 0 0 0-.6 2.42c.14.93.5 1.73 1.46.95-.08-.62 1.43-3.87 0-3.86Zm20.72-14.18c.95 2.14 1.92-.15 2.57-1.34a.9.9 0 0 0 .1-.45c-.14-1.06-.25-3.16-1.23-3.15a.69.69 0 0 0-.66.94v.01c.34 1-.37 2.46-.78 3.39a.72.72 0 0 0 0 .6Zm34.09 5.45a.63.63 0 0 1-.61-.64c-.06-1.26-1.06.38-2.42-1.48a.65.65 0 0 0-.56-.28c-7.12.3-2.58-.64-5.48-1.45a.65.65 0 0 1-.46-.6c0-1.73-2.2-.06-.78-2.55a.6.6 0 0 1 .33-.28c1.91-.7 2.12-.44 2.3-.07a.63.63 0 0 0 1 .22c.1-.1.16-.2.2-.33.37-1.16 2.28-1.98 3.3-2.6.11-.08.2-.18.26-.3a.63.63 0 0 0 .04-.38l-.1-.4a.63.63 0 0 1 0-.21.63.63 0 0 1 .7-.54l2.9.3h.03a.63.63 0 0 0 .68-.59c.06-.93.28-2.27-.55-2.23a.67.67 0 0 1-.6-.37l-.39-.78a.64.64 0 0 0-.67-.36c-.76.14-2.04.3-2.56.75a.65.65 0 0 1-.7.11c-2.4-1.04.72-.32 1.5-3.05l.02-.14a.63.63 0 0 0-.58-.68c-1.71-.15-4.22-.95-5.15.6a.65.65 0 0 1-.63.3l-9.37-1.16a.63.63 0 0 0-.7.78l.18.78a.66.66 0 0 1-.03.4.63.63 0 0 1-.58.38c-1.16-.03-1.12.2-1.06 1.64a.63.63 0 0 0 .31.52c.52.29 1.1.43 1.7.41v.04a.63.63 0 0 0 .63-.56c.2-1.26 1.76-.52 2.84-.52a.63.63 0 0 1 .5.24 3.28 3.28 0 0 1 .6 2.48.65.65 0 0 1-.56.56h-.16a.63.63 0 0 0-.56.57l-.34 3.67a.63.63 0 0 0 .18.52c.82.8 1.9 1.27 1.5 2.37a.61.61 0 0 0 .44.85c1.06.2 2.75.48 2.97.95a.63.63 0 0 0 .45.35c2.04.45.31 2.35 1.8 1.45a.64.64 0 0 1 .95.73c-.13.45-.32.88-.74 1.04a.61.61 0 0 0-.41.58v.93a.62.62 0 0 0 .24.48c4.01 3.05-1.3 2.33 2.97 4.47a.64.64 0 0 1 .37.56c.04 1.49-.48 5.8.6 6.64a.65.65 0 0 1 .24.67c-.37 1.53.56 1.67 1.73 2.27a.62.62 0 0 0 .77-.16c.06-.06.1-.13.12-.21.97-2.72-1.45.76-.82-4.97a.61.61 0 0 1 .25-.43c7.91-6.03 1.37-3.9 5.24-7.52a.65.65 0 0 0 .1-.8c-.94-1.61-1.32-2.86-3.37-2.97Zm60.52-17.02a.3.3 0 0 0-.02-.04c-.76-1.38-3.06-.3-4.46-.17a.46.46 0 0 1-.46-.24c-.85-1.6-4.89-1.83-6.36-2.13a.52.52 0 0 0-.18 0c-3.1.5-4.22 1.8-6.53-.98a.48.48 0 0 0-.37-.19h-7.25a.47.47 0 0 0-.24.06c-3.01 1.7-.04 1.78-3.3 2.55a.48.48 0 0 1-.51-.19c-.54-.7-3.77-.18-4.2-.33l-3.48-1.2a.48.48 0 0 0-.31 0c-2.05.82-2.75 1.35-3.35 3.52a.5.5 0 0 0 .06.34l.5.96a.46.46 0 0 0 .4.25c.84.03 1.4.13 1.57-.84a.48.48 0 0 1 .24-.34c1.49-.76.97.9 1.08 1.96a.47.47 0 0 1-.3.5l-2.01.7a.46.46 0 0 1-.5-.12.47.47 0 0 1-.11-.23c-.13-.68-.32-.96-1.03-1.08a.46.46 0 0 0-.52.5c.06 1.25-.35 1.3-1.65 1.6-.1.04-.2.1-.26.2-.4.51-.61.9-.47 1.4a.47.47 0 0 1-.44.58c-1.23.02-.75.65-.5 1.86.01.1.07.2.15.28.08.07.18.1.3.11.8.04 1.33.1 1.52-.74a.46.46 0 0 1 .5-.36c.64.07 1.28-.04 1.86-.33a.46.46 0 0 1 .57.07c.38.43.66.75.66 1.21 0 .08.01.15.05.22a.46.46 0 0 0 .37.25c.08 0 .15-.01.23-.04h.01a.47.47 0 0 0 .28-.43.45.45 0 0 0-.03-.18c-.1-.22-.23-.48-.3-.73a.47.47 0 0 1 .22-.57.47.47 0 0 1 .6.15c.7.98.94 1.63 2.34.93a.47.47 0 0 1 .54.07c.72.7 1.28 1.28.89 2.34a.46.46 0 0 1-.32.28c-1.54.45-3.55 1-5.02.06a.46.46 0 0 1-.2-.28c-.37-1.64-2.03-1.2-3.33-1.28a.46.46 0 0 0-.31.1l-3.87 3.1a.46.46 0 0 0-.19.33c-.03.67-.15 2.18-.7 2.53a.49.49 0 0 0-.2.41v1.77c0 .12.04.23.12.32 3.22 3.5 4.39.82 7.25 2.64a.49.49 0 0 1 .22.39c0 1.34-.07 1.94 1.29 2.44a.46.46 0 0 1 .3.52c-1.12 6.14.72 3.44.89 8.13a.47.47 0 0 0 .33.45c2.88.83 3.2-.28 5.15-2.16a.42.42 0 0 0 .1-.17.46.46 0 0 0 .03-.2c-.11-2.31-.45-3.1 1.67-4.43a.46.46 0 0 0 .23-.41c0-2.44-.15-3.85 1.9-5.53a.49.49 0 0 0 .18-.39c-.1-1.64-1.28-1.6-2.55-1.93a.46.46 0 0 1-.35-.47c.04-2.16-1.38-1.62-1.12-3.7a.46.46 0 0 1 .51-.41c.08 0 .15.03.22.07.63.45 1.32 1.68 1.69 2.16a.48.48 0 0 1 .1.37c-.5 2.66 2.06.75 3.38-.09a.47.47 0 0 0 .15-.64v-.01l-1.49-2.23a.46.46 0 0 1-.08-.35.46.46 0 0 1 .18-.3c.08-.06.18-.1.28-.1l.67-.02a.47.47 0 0 1 .48.32c.4 1.41 2.34 2.4 3.7 2.94a.47.47 0 0 1 .28.54c-.47 2.05 1.07 2.94 2.41 4.13a.47.47 0 0 0 .66-.04.5.5 0 0 0 .09-.15l1.48-4.05a.47.47 0 0 1 .53-.3l1.02.19a.47.47 0 0 1 .37.3l1 2.78c.03.06.07.11.11.15l.19.2a.46.46 0 0 0 .82-.33c-.04-.78.1-1.37.9-.4a.46.46 0 0 0 .55.14c2.04-.89 1.34-2.53 1.3-4.37a.47.47 0 0 1 .39-.46c1.3-.23 1.4-.86 1.67-2.1a.47.47 0 0 0-.11-.4l-1.19-1.37a.46.46 0 0 1-.07-.53c.28-.55.72-1.45 1.34-1.45a.44.44 0 0 0 .42-.24c.45-.97 1.86-3.96 2.36-5.1a.47.47 0 0 1 .44-.27.47.47 0 0 1 .25.09c.6.39.62.98.58 1.76a.47.47 0 0 0 .24.45c1.91 1.04-.02-1.79 4.65-3.94a.46.46 0 0 0 .23-.61Z"/>
<path d="m117.52 5.77-2.4-1.5a1.77 1.77 0 0 0-1.4-.21c-1.74.46-5.92 1.1-5.05 2.58 2.1 2.98-.98 3.03 1.99 3.54.39.07.74.28.98.6h.02c1.94 2.41 2.4 2.13 3.72-.47.18-.37.5-.65.87-.8 1.92-.73 2.57-.49 1.97-2.77a1.6 1.6 0 0 0-.7-.97Zm8.62 8.02.54.1a.3.3 0 0 0 .19 0h.02c1.11-.18 1.1-.81 1.04-1.61a.56.56 0 0 0-.48-.43.56.56 0 0 0-.56.3l-.02.04a.52.52 0 0 1-.48.39c-.53.03-.7.18-.7.69a.56.56 0 0 0 .45.52ZM164.57 37a.28.28 0 0 0-.04-.1c-.3-.5-.93-2.46-1.64-3.04a.26.26 0 0 0-.2-.04.26.26 0 0 0-.16.1.26.26 0 0 0-.05.17c.02.65 0 .89-.81.61a.25.25 0 0 1-.15-.15v.02c-.32-.74-1.49-.37-2.16-.24a.26.26 0 0 0-.17.13c-.37.74-1.4 1.45-2.23 1.82a.61.61 0 0 0-.37.6l.13 1.99a.61.61 0 0 0 .62.58h2.41c.07 0 .14.03.19.09.56.65 2.28.98 3.14 1.13a.25.25 0 0 0 .22-.07 3.9 3.9 0 0 0 1.27-3.6Zm1.54 2.15a10.44 10.44 0 0 1-2.75 2.95.44.44 0 0 0-.19.49c.28.8.9.74 1.68.73a.45.45 0 0 0 .4-.38c.7-3.22 2.66-2.36 1.5-3.76a.44.44 0 0 0-.49-.12.44.44 0 0 0-.15.1ZM142.5 35.6c-.31-.17-.61.12-.85.48a3.5 3.5 0 0 0-.6 2.42c.13.93.48 1.73 1.45.95-.07-.62 1.43-3.87 0-3.86Zm20.73-14.18c.95 2.14 1.92-.15 2.57-1.34.07-.14.1-.3.1-.45-.14-1.06-.25-3.16-1.24-3.15a.69.69 0 0 0-.65.94v.01c.34 1-.37 2.46-.78 3.39a.73.73 0 0 0 0 .6Z"/>
</g>
</g>
<rect x="1.25" y="1.25" width="45.5" height="45.5" rx="22.75" stroke="currentColor" stroke-width="2.5"/>
{:else}
<g clip-path="url(#a)" fill="currentColor">
<path d="M58.07 8.8c-.76-1.37-3.07-.3-4.46-.16a.46.46 0 0 1-.47-.24c-.85-1.6-4.89-1.83-6.36-2.13a.52.52 0 0 0-.18 0c-3.1.5-4.22 1.8-6.53-.98a.47.47 0 0 0-.37-.19h-7.25a.48.48 0 0 0-.24.06c-3.01 1.7-.04 1.78-3.3 2.55a.48.48 0 0 1-.5-.19c-.55-.7-3.78-.18-4.21-.33l-3.48-1.2a.48.48 0 0 0-.31 0c-2.05.82-2.75 1.35-3.35 3.52a.5.5 0 0 0 .06.34l.5.96a.46.46 0 0 0 .4.25c.84.03 1.4.13 1.57-.84a.49.49 0 0 1 .24-.34c1.49-.76.97.9 1.08 1.96a.47.47 0 0 1-.3.5l-2 .7a.46.46 0 0 1-.62-.35c-.13-.68-.31-.96-1.02-1.08a.46.46 0 0 0-.52.5c.06 1.25-.35 1.3-1.65 1.6-.11.04-.2.1-.26.2-.4.51-.62.9-.47 1.4a.47.47 0 0 1-.45.58c-1.22.02-.74.65-.5 1.86a.46.46 0 0 0 .45.39c.8.04 1.34.1 1.52-.74a.46.46 0 0 1 .5-.36c.64.07 1.29-.04 1.86-.33a.46.46 0 0 1 .58.07c.37.43.65.75.65 1.21a.47.47 0 0 0 .65.43h.02a.46.46 0 0 0 .24-.61c-.1-.22-.22-.48-.3-.73a.47.47 0 0 1 .82-.42c.7.98.95 1.63 2.34.93a.47.47 0 0 1 .54.07c.73.7 1.29 1.28.9 2.34a.47.47 0 0 1-.32.28c-1.54.45-3.55 1-5.02.06a.46.46 0 0 1-.2-.28c-.38-1.64-2.03-1.2-3.33-1.28a.47.47 0 0 0-.32.1l-3.87 3.1a.47.47 0 0 0-.19.33c-.03.67-.14 2.18-.7 2.53a.48.48 0 0 0-.2.41v1.77c0 .12.04.23.12.32 3.22 3.5 4.4.82 7.25 2.64a.49.49 0 0 1 .23.39c0 1.34-.08 1.94 1.28 2.44a.47.47 0 0 1 .3.52c-1.12 6.14.72 3.44.89 8.13a.47.47 0 0 0 .33.45c2.88.83 3.2-.28 5.15-2.16a.47.47 0 0 0 .13-.37c-.1-2.31-.44-3.1 1.68-4.43a.47.47 0 0 0 .22-.41c0-2.44-.15-3.85 1.9-5.53a.49.49 0 0 0 .18-.39c-.1-1.64-1.28-1.6-2.55-1.93a.46.46 0 0 1-.35-.47c.04-2.16-1.37-1.62-1.11-3.7a.47.47 0 0 1 .72-.34c.63.45 1.32 1.68 1.7 2.16a.47.47 0 0 1 .09.37c-.5 2.66 2.06.75 3.38-.09a.47.47 0 0 0 .15-.64v-.01l-1.48-2.23a.46.46 0 0 1 .37-.75l.67-.02a.47.47 0 0 1 .48.32c.41 1.41 2.34 2.4 3.7 2.94a.46.46 0 0 1 .28.54c-.47 2.05 1.08 2.94 2.42 4.13a.47.47 0 0 0 .75-.18l1.48-4.06a.47.47 0 0 1 .52-.3l1.03.19a.47.47 0 0 1 .37.3l1 2.79.11.15.19.2a.46.46 0 0 0 .82-.33c-.04-.79.09-1.38.9-.41a.46.46 0 0 0 .55.14c2.04-.89 1.33-2.53 1.3-4.37a.46.46 0 0 1 .39-.46c1.3-.23 1.4-.86 1.67-2.1a.47.47 0 0 0-.11-.41l-1.2-1.36a.46.46 0 0 1-.08-.53c.28-.55.73-1.45 1.34-1.45a.44.44 0 0 0 .43-.24c.44-.97 1.86-3.96 2.36-5.1a.47.47 0 0 1 .69-.18c.6.39.61.98.57 1.76a.47.47 0 0 0 .24.45c1.92 1.04-.01-1.79 4.65-3.94a.46.46 0 0 0 .24-.61l-.01-.04Z"/>
<path d="m13.27 12.79.54.1c.06.02.12.02.18 0h.02c1.12-.18 1.1-.81 1.04-1.61a.56.56 0 0 0-1.04-.13l-.02.04a.52.52 0 0 1-.48.39c-.52.03-.69.18-.69.69a.56.56 0 0 0 .45.52ZM29.63 34.6c-.32-.17-.62.12-.86.48a3.5 3.5 0 0 0-.6 2.42c.14.93.5 1.73 1.46.95-.08-.62 1.43-3.87 0-3.86Zm20.72-14.18c.95 2.14 1.92-.15 2.57-1.34a.9.9 0 0 0 .1-.45c-.14-1.06-.25-3.16-1.23-3.15a.69.69 0 0 0-.66.94v.01c.34 1-.37 2.46-.78 3.39a.72.72 0 0 0 0 .6Z"/>
</g>
<rect x="1.25" y="1.25" width="45.5" height="45.5" rx="22.75" stroke="currentColor" stroke-width="2.5"/>
<defs>
<clipPath id="a">
<rect width="48" height="48" rx="24" fill="#fff"/>
</clipPath>
</defs>
{/if}
</svg>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { getAssetUrlKey } from '$utils/api'
export let src: string = undefined
export let id: string = undefined
export let sizeKey: string = undefined
export let sizes: Sizes = undefined
export let width: number = sizes && sizes.medium && sizes.medium.width
export let height: number = sizes && sizes.medium && sizes.medium.height
export let ratio: number = undefined
export let alt: string
export let lazy: boolean = true
export let decoding: "auto" | "sync" | "async" = "auto"
interface Sizes {
small?: { width?: number, height?: number }
medium?: { width?: number, height?: number }
large?: { width?: number, height?: number }
}
let srcSet = { webp: [], jpg: [] }
/**
* Define height from origin ratio if not defined
*/
const setHeightFromRatio = (w: number, r: number = ratio) => {
return Math.round(w / r)
}
if (ratio && !height) {
// Set height from width using ratio
height = setHeightFromRatio(width)
// Add height to all sizes
if (sizes) {
Object.entries(sizes).forEach(size => {
const [key, value]: [string, { width?: number, height?: number }] = size
sizes[key].height = setHeightFromRatio(value.width)
})
}
}
/**
* Image attributes
*/
$: imgWidth = sizes && sizes.small ? sizes.small.width : width
$: imgHeight = sizes && sizes.small ? sizes.small.height : height
$: imgSrc = id ? getAssetUrlKey(id, `${sizeKey}-small-jpg`) : src ? src : undefined
$: srcSet = {
// WebP
webp:
sizes ? [
`${getAssetUrlKey(id, `${sizeKey}-small-webp`)} 345w`,
sizes.medium ? `${getAssetUrlKey(id, `${sizeKey}-medium-webp`)} 768w` : null,
sizes.large ? `${getAssetUrlKey(id, `${sizeKey}-large-webp`)} 1280w` : null,
]
: [getAssetUrlKey(id, `${sizeKey}-webp`)],
// JPG
jpg:
sizes ? [
`${getAssetUrlKey(id, `${sizeKey}-small-jpg`)} 345w`,
sizes.medium ? `${getAssetUrlKey(id, `${sizeKey}-medium-jpg`)} 768w` : null,
sizes.large ? `${getAssetUrlKey(id, `${sizeKey}-large-jpg`)} 1280w` : null,
]
: [getAssetUrlKey(id, `${sizeKey}-jpg`)]
}
</script>
<picture class={$$props.class}>
<source
type="image/webp"
srcset={srcSet.webp.join(', ').trim()}
>
<img
src={imgSrc}
sizes={sizes ? '(min-width: 1200px) 864px, (min-width: 992px) 708px, (min-width: 768px) 540px, 100%' : null}
srcset={srcSet.jpg.join(', ').trim()}
width={imgWidth}
height={imgHeight}
{alt}
loading={lazy ? 'lazy' : undefined}
{decoding}
/>
</picture>

View File

@@ -0,0 +1,72 @@
<style lang="scss">
.scrolling-title {
display: inline-block;
transform: translate3d(var(--parallax-x), 0, 0);
transition: transform 1.2s var(--ease-quart);
will-change: transform;
}
</style>
<script lang="ts">
import { map } from '$utils/functions'
import reveal from '$animations/reveal'
export let tag: string
export let label: string = undefined
export let parallax: number = undefined
export let offsetStart: number = undefined
export let offsetEnd: number = undefined
export let animate: boolean = true
let scrollY: number
let innerWidth: number
let innerHeight: number
let titleEl: HTMLElement
let isLarger: boolean
// Define default values
$: if (titleEl && !offsetStart && !offsetEnd) {
offsetStart = titleEl.offsetTop - innerHeight * (innerWidth < 768 ? 0.2 : 0.75)
offsetEnd = titleEl.offsetTop + innerHeight * (innerWidth < 768 ? 0.5 : 0.5)
}
// Check if title is larger than viewport to translate it
$: isLarger = titleEl && titleEl.offsetWidth >= innerWidth
// Calculate the parallax value
$: if (titleEl) {
const toTranslate = 100 - (innerWidth / titleEl.offsetWidth * 100)
parallax = isLarger ? map(scrollY, offsetStart, offsetEnd, 0, -toTranslate, true) : 0
}
const classes = [
'scrolling-title',
'title-huge',
$$props.class
].join(' ').trim()
const revealOptions = animate ? {
children: '.char',
animation: { y: ['-105%', 0] },
options: {
stagger: 0.06,
duration: 1.6,
delay: 0.2,
threshold: 0.2,
},
} : null
</script>
<svelte:window
bind:scrollY
bind:innerWidth bind:innerHeight
/>
<svelte:element this={tag}
bind:this={titleEl}
class={classes} aria-label={label}
style:--parallax-x="{parallax}%"
use:reveal={revealOptions}
>
<slot />
</svelte:element>

View File

@@ -0,0 +1,37 @@
<style lang="scss">
@import "../../style/atoms/site-title";
</style>
<script lang="ts">
import SplitText from '$components/SplitText.svelte'
import reveal from '$animations/reveal'
import { DURATION } from '$utils/constants'
export let variant: string = 'lines'
export let tag: string = 'h1'
</script>
{#if tag === 'h1'}
<h1 class="site-title site-title--{variant}"
use:reveal={{
children: '.char',
animation: { y: ['105%', 0] },
options: {
stagger: 0.04,
duration: 1,
delay: DURATION.PAGE_IN / 1000,
threshold: 0,
},
}}
>
<SplitText text="Houses" mode="chars" class="pink mask" />
<SplitText text="Of The" mode="chars" class="middle mask" />
<SplitText text="World" mode="chars" class="pink mask" />
</h1>
{:else}
<div class="site-title site-title--{variant}">
<span class="word-1">Houses</span>
<span class="middle word-2">Of The</span>
<span class="word-3">World</span>
</div>
{/if}

View File

@@ -0,0 +1,134 @@
<style lang="scss">
@import "../../style/layouts/poster";
</style>
<script lang="ts">
import { addToCart } from '$utils/functions/shop'
import { capitalizeFirstLetter } from '$utils/functions'
// Components
import SplitText from '$components/SplitText.svelte'
import Button from '$components/atoms/Button.svelte'
import Image from '$components/atoms/Image.svelte'
import ScrollingTitle from '$components/atoms/ScrollingTitle.svelte'
import Carousel from '$components/organisms/Carousel.svelte'
export let product: any
export let shopProduct: any
$: hasStock = shopProduct.stock_level > 0
/**
* Preview photos specs
*/
let lastPreviewPhoto: any = undefined
$: if (product && product.photos_preview.length) {
lastPreviewPhoto = product.photos_preview[product.photos_preview.length - 1].directus_files_id
}
// Images sizes
const photosPreview = [
{
sizes: {
small: { width: 275 },
medium: { width: 500 },
large: { width: 800 },
},
ratio: 0.75,
},
{
sizes: {
small: { width: 200 },
medium: { width: 300 },
large: { width: 400 },
},
ratio: 0.8,
},
{
sizes: {
small: { width: 200 },
medium: { width: 300 },
large: { width: 400 },
},
ratio: 1.28,
},
{
sizes: {
small: { width: 450 },
medium: { width: 700 },
large: { width: 1000 },
},
ratio: 0.68,
},
]
</script>
<section class="poster-layout grid" id="poster">
<div class="poster-layout__title">
<ScrollingTitle tag="h2" label={product.location.name}>
<SplitText mode="chars" text={product.location.name} />
</ScrollingTitle>
</div>
<aside class="poster-layout__buy">
<div class="poster-layout__info">
<dl>
<dt class="title-small">{capitalizeFirstLetter(product.type)}</dt>
<dd class="text-info">{shopProduct.name} {shopProduct.price}</dd>
</dl>
<Button
tag="button"
text={hasStock ? 'Add to cart' : 'Sold out'}
color="pinklight"
disabled={!hasStock}
on:click={() => addToCart(shopProduct)}
/>
</div>
<Carousel
class="shadow-box-dark"
slides={product.photos_product.map(({ directus_files_id }) => ({
id: directus_files_id.id,
alt: directus_files_id.title,
}))}
/>
</aside>
{#if product.photos_preview.length}
<div class="poster-layout__images grid container">
{#each product.photos_preview.slice(0, 3) as { directus_files_id}, index}
<Image
class="image image--{index + 1} photo shadow-box-light"
id={directus_files_id.id}
sizeKey="photo-list"
sizes={photosPreview[index].sizes}
ratio={photosPreview[index].ratio}
alt={directus_files_id.title}
/>
{/each}
</div>
{/if}
<div class="poster-layout__about grid">
<div class="text container">
{#if product.description}
<p class="headline text-large">{product.description}</p>
{/if}
{#if product.details}
<p class="details text-xsmall">{product.details}</p>
{/if}
</div>
</div>
{#if lastPreviewPhoto}
<Image
class="image image--4 photo shadow-box-light"
id={lastPreviewPhoto.id}
sizeKey="photo-grid"
sizes={photosPreview[photosPreview.length - 1].sizes}
ratio={photosPreview[photosPreview.length - 1].ratio}
alt={lastPreviewPhoto.title}
/>
{/if}
</section>

View File

@@ -0,0 +1,69 @@
<style lang="scss">
@import "../../style/molecules/cart-item";
</style>
<script lang="ts">
import { createEventDispatcher } from 'svelte'
// Components
import ButtonCircle from '$components/atoms/ButtonCircle.svelte'
import Select from '$components/molecules/Select.svelte'
export let item: any
const dispatch = createEventDispatcher()
const quantityLimit = 5
// When changing item quantity
const updateQuantity = ({ detail }: any) => {
dispatch('updatedQuantity', {
id: item.id,
quantity: Number(detail)
})
}
// When removing item
const removeItem = () => {
dispatch('removed', item.id)
}
</script>
<div class="cart-item shadow-small">
<div class="cart-item__left">
<img src={item.product.images[0].file.url} width={200} height={300} alt={item.product.name}>
</div>
<div class="cart-item__right">
<h3>Poster</h3>
<p>
{item.product.name}
<br> {item.price}
</p>
{#if item && item.quantity}
<Select
name="sort" id="filter_sort"
options={[...Array(item.quantity <= quantityLimit ? quantityLimit : item.quantity)].map((_, index) => {
return {
value: `${index + 1}`,
name: `${index + 1}`,
default: index === 0,
selected: index + 1 === item.quantity,
}
})}
on:change={updateQuantity}
value={String(item.quantity)}
>
<span>Quantity:</span>
</Select>
{/if}
<ButtonCircle class="remove"
size="tiny" color="gray"
on:click={removeItem}
>
<svg width="8" height="8">
<use xlink:href="#cross" />
</svg>
</ButtonCircle>
</div>
</div>

View File

@@ -0,0 +1,106 @@
<style lang="scss">
@import "../../style/molecules/newsletter-form";
</style>
<script lang="ts">
import { fly } from 'svelte/transition'
import { quartOut } from 'svelte/easing'
import { sendEvent } from '$utils/analytics'
// Components
import IconArrow from '$components/atoms/IconArrow.svelte'
import ButtonCircle from '$components/atoms/ButtonCircle.svelte'
export let past: boolean = false
let inputInFocus = false
let formStatus: FormStatus = null
let formMessageTimeout: ReturnType<typeof setTimeout> | number
interface FormStatus {
error?: string
success?: boolean
message: string
}
const formMessages = {
PENDING: `Almost there! Please confirm your email address through the email you'll receive soon.`,
MEMBER_EXISTS_WITH_EMAIL_ADDRESS: `This email address is already subscribed to the newsletter.`,
INVALID_EMAIL: `Woops. This email doesn't seem to be valid.`,
}
$: isSuccess = formStatus && formStatus.success
// Toggle input focus
const toggleFocus = () => inputInFocus = !inputInFocus
// Handle form submission
async function handleForm (event: Event | HTMLFormElement) {
const data = new FormData(this)
const email = data.get('email')
if (email) {
const req = await fetch(this.action, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
})
const result: FormStatus = await req.json()
formStatus = result
console.log('SK api response:', result)
// If successful
if (formStatus.success) {
sendEvent('newsletterSubscribe')
} else {
// Hide message for errors
clearTimeout(formMessageTimeout)
formMessageTimeout = requestAnimationFrame(() => setTimeout(() => formStatus = null, 4000))
}
}
}
</script>
<div class="newsletter-form">
{#if !isSuccess}
<form method="POST" action="/api/newsletter" on:submit|preventDefault={handleForm}
out:fly|local={{ y: -8, easing: quartOut, duration: 600 }}
>
<div class="newsletter-form__email" class:is-focused={inputInFocus}>
<input type="email" placeholder="Your email address" name="email" id="newsletter_email" required
on:focus={toggleFocus}
on:blur={toggleFocus}
>
<ButtonCircle
type="submit"
color="pink" size="small"
clone={true}
label="Subscribe"
>
<IconArrow color="white" />
</ButtonCircle>
</div>
<div class="newsletter-form__bottom">
{#if past}
<a href="/subscribe" class="past-issues" data-sveltekit-noscroll>
<svg width="20" height="16" viewBox="0 0 20 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-label="Newsletter icon">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 2.346H2a.5.5 0 0 0-.5.5v11.102a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5V2.846a.5.5 0 0 0-.5-.5ZM2 .846a2 2 0 0 0-2 2v11.102a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2.846a2 2 0 0 0-2-2H2Zm13.75 4.25h-2v3h2v-3Zm-2-1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2ZM3.5 6.5a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Zm.25 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5Zm1.25 2a.5.5 0 0 0 0 1h6a.5.5 0 1 0 0-1H5Z" />
</svg>
<span>See past issues</span>
</a>
{/if}
<p>No spam, we promise!</p>
</div>
</form>
{/if}
{#if formStatus && formStatus.message}
<div class="newsletter-form__message shadow-small"
class:is-error={!isSuccess}
class:is-success={isSuccess}
in:fly|local={{ y: 8, easing: quartOut, duration: 600, delay: isSuccess ? 600 : 0 }}
out:fly|local={{ y: 8, easing: quartOut, duration: 600 }}
>
<p class="text-xsmall">{formMessages[formStatus.message]}</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,17 @@
<style lang="scss">
@import "../../style/molecules/heading";
</style>
<script lang="ts">
import SiteTitle from '$components/atoms/SiteTitle.svelte'
export let text: string
</script>
<section class="heading">
<SiteTitle variant="inline" />
<div class="text text-medium">
{@html text}
</div>
</section>

View File

@@ -0,0 +1,66 @@
<style lang="scss">
@import "../../style/molecules/house";
</style>
<script lang="ts">
import dayjs from 'dayjs'
// Components
import Image from '$components/atoms/Image.svelte'
import Icon from '$components/atoms/Icon.svelte'
export let url: string
export let photoId: string
export let photoAlt: string
export let title: string
export let index: string
export let ratio: number
export let date: string = undefined
export let city: string = undefined
export let location: string
</script>
<div class="house grid">
<div class="house__info">
<h2 class="title-image">
{title}
</h2>
<p class="info text-info">
{#if city}
<a href="https://www.openstreetmap.org/search?query={title}, {city} {location}" target="_blank" rel="noopener noreferrer">
<Icon class="icon" icon="map-pin" label="Map pin" /> {city}
</a>
<span class="sep">·</span>
{/if}
{#if date}
<time datetime={dayjs(date).format('YYYY-MM-DD')}>
{dayjs(date).format('MMMM YYYY')}
</time>
{/if}
</p>
</div>
<div class="house__photo grid" class:not-landscape={ratio < 1.475}>
<a href={url} tabindex="0">
<figure class="house__image shadow-photo">
<Image
class="photo"
id={photoId}
sizeKey="photo-list"
sizes={{
small: { width: 500 },
medium: { width: 850 },
large: { width: 1280 },
}}
ratio={1.5}
alt={photoAlt}
/>
</figure>
</a>
<span class="house__index title-index"
class:has-one-start={index.startsWith('1')}
class:has-one-end={index.endsWith('1')}
>
{index}
</span>
</div>
</div>

View File

@@ -0,0 +1,125 @@
<style lang="scss">
@import "../../style/molecules/location";
</style>
<script lang="ts">
import { getContext } from 'svelte'
import { spring } from 'svelte/motion'
import dayjs from 'dayjs'
import { lerp } from '$utils/functions'
import { PUBLIC_PREVIEW_COUNT } from '$env/static/public'
import { seenLocations } from '$utils/stores'
// Components
import Image from '$components/atoms/Image.svelte'
import Badge from '$components/atoms/Badge.svelte'
export let location: any
export let latestPhoto: any
const { settings }: any = getContext('global')
let locationEl: HTMLElement
let photoIndex = 0
// Location date limit
let isNew = false
const dateNowOffset = dayjs().subtract(settings.limit_new, 'day')
const parsedSeenLocations = JSON.parse($seenLocations)
$: if (latestPhoto) {
const dateUpdated = dayjs(latestPhoto.date_created)
// Detect if location has new content
const seenLocationDate = dayjs(parsedSeenLocations[location.id])
const isLocationSeen = parsedSeenLocations?.hasOwnProperty(location.id)
// Define if location is has new photos
if (seenLocationDate && isLocationSeen) {
// A more recent photo has been added (if has been seen and has a seen date)
isNew = dateUpdated.isAfter(dateNowOffset) && dateUpdated.isAfter(seenLocationDate)
} else {
// The photo is after the offset
isNew = dateUpdated.isAfter(dateNowOffset)
}
}
/**
* Moving cursor over
*/
const offset = spring({ x: 0, y: 0 }, {
stiffness: 0.075,
damping: 0.9
})
const handleMouseMove = ({ clientX }: MouseEvent) => {
const { width, left } = locationEl.getBoundingClientRect()
const moveProgress = (clientX - left) / width // 0 to 1
// Move horizontally
offset.update(_ => ({
x: lerp(-56, 56, moveProgress),
y: 0
}))
// Change photo index from mouse position percentage
photoIndex = Math.round(lerp(0, Number(PUBLIC_PREVIEW_COUNT) - 1, moveProgress))
}
// Leaving mouseover
const handleMouseLeave = () => {
offset.update($c => ({
x: $c.x,
y: 40
}))
}
</script>
<div class="location" bind:this={locationEl}
style:--offset-x="{$offset.x}px"
style:--offset-y="{$offset.y}px"
style:--rotate="{$offset.x * 0.125}deg"
>
<a href="/{location.country.slug}/{location.slug}"
on:mousemove={handleMouseMove}
on:mouseleave={handleMouseLeave}
tabindex="0"
>
<Image
class="flag"
id={location.country.flag.id}
sizeKey="square-small"
width={32} height={32}
alt="Flag of {location.country.name}"
/>
<div class="text">
<dl>
<dt class="location__name">
{location.name}
</dt>
<dd class="location__country text-label">
{location.country.name}
</dd>
</dl>
{#if isNew}
<Badge text="New" />
{/if}
</div>
</a>
{#if location.photos.length}
<div class="location__photos">
{#each location.photos as { image }, index}
{#if image}
{@const classes = ['location__photo', index === photoIndex ? 'is-visible' : null].join(' ').trim()}
<Image
class={classes}
id={image.id}
sizeKey="photo-thumbnail"
width={340} height={226}
alt={image.title}
/>
{/if}
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,33 @@
<style lang="scss">
@import "../../style/molecules/issue";
</style>
<script lang="ts">
import dayjs from 'dayjs'
import Image from '$components/atoms/Image.svelte'
export let title: string
export let issue: number
export let date: string
export let link: string
export let thumbnail: { id: string }
export let size: string = undefined
</script>
<div class="issue" class:is-large={size === 'large'}>
<a href={link} target="_blank" rel="external noopener" tabindex="0">
<Image
id={thumbnail.id}
sizeKey="issue-thumbnail-small"
width={160} height={112}
alt="Issue {issue} thumbnail"
/>
<dl>
<dt>Issue #{issue}</dt>
<dd>
<p>{title}</p>
<time>{dayjs(date).format('DD/MM/YYYY')}</time>
</dd>
</dl>
</a>
</div>

View File

@@ -0,0 +1,32 @@
<style lang="scss">
@import "../../style/molecules/notification-cart";
</style>
<script lang="ts">
import { fly } from 'svelte/transition'
import { quartOut } from 'svelte/easing'
import { cartOpen } from '$utils/stores/shop'
export let title: string
export let name: string
export let image: string
const closeNotification = () => {
// Open cart
$cartOpen = true
}
</script>
<div class="notification-cart shadow-small"
on:click={closeNotification}
transition:fly={{ y: 20, duration: 700, easing: quartOut }}
>
<div class="notification-cart__left">
<img src={image} width={58} height={88} alt={title}>
</div>
<div class="notification-cart__right">
<h3>{title}</h3>
<p>{name}</p>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<style lang="scss">
@import "../../style/molecules/pagination";
</style>
<script lang="ts">
export let ended: boolean = false
export let current: number
export let total: number
</script>
<div class="pagination" role="button" tabindex="0"
disabled={ended ? ended : undefined}
on:click
on:keydown
>
<div class="pagination__progress">
<span class="current">{current}</span>
<span>/</span>
<span class="total">{total}</span>
<slot />
</div>
</div>

View File

@@ -0,0 +1,70 @@
<style lang="scss">
@import "../../style/molecules/photo-card";
</style>
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Image from '$components/atoms/Image.svelte'
export let id: string
export let alt: string
export let url: string = undefined
export let title: string = undefined
export let location: any = undefined
export let city: string = undefined
export let hovered: boolean = false
export let lazy: boolean = true
const dispatch = createEventDispatcher()
const sizes = {
small: { width: 224 },
medium: { width: 464 },
large: { width: 864 },
}
const sendHover = (hover: boolean) => dispatch('hover', hover)
</script>
<div class="photo-card"
class:is-hovered={hovered}
on:mouseenter={() => sendHover(true)}
on:focus={() => sendHover(true)}
on:mouseout={() => sendHover(false)}
on:blur={() => sendHover(false)}
>
{#if url}
<div class="photo-card__content">
<a href={url} data-sveltekit-noscroll>
<Image
{id}
sizeKey="postcard"
{sizes}
ratio={1.5}
{alt}
{lazy}
/>
{#if title && location}
<div class="photo-card__info">
<Image
id={location.country.flag.id}
sizeKey="square-small"
width={24}
height={24}
alt="Flag of {location.country.name}"
/>
<p>{title} - {city ? `${city}, ` : ''}{location.name}, {location.country.name}</p>
</div>
{/if}
</a>
</div>
{:else}
<Image
{id}
sizeKey="postcard"
{sizes}
ratio={1.5}
{alt}
{lazy}
/>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
<style lang="scss">
@import "../../style/organisms/postcard";
</style>
<script lang="ts">
import Image from '$components/atoms/Image.svelte'
export let street: string
export let location: string
export let region: string = undefined
export let country: string
export let flagId: string
export let size: string = undefined
const className = 'postcard'
$: classes = [
className,
...[size].map(variant => variant && `${className}--${variant}`),
$$props.class
].join(' ').trim()
</script>
<div class={classes}>
<div class="postcard__left">
<p class="postcard__country">
<span>Houses of</span><br>
<strong class="title-country__purple">{country}</strong>
</p>
</div>
<div class="postcard__right">
<div class="postcard__stamp">
<div class="frame">
<img src="/images/icons/stamp.svg" width="32" height="42" alt="Stamp">
</div>
<Image
class="flag"
id={flagId}
sizeKey="square-small"
width={32} height={32}
alt="Flag of {country}"
/>
</div>
<ul class="postcard__address">
<li>{street}</li>
<li>{location}{region ? `, ${region}` : ''}</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<style lang="scss">
@import "../../style/molecules/poster";
</style>
<script lang="ts">
import { addToCart } from '$utils/functions/shop'
import { smoothScroll } from '$utils/stores'
// Components
import Button from '$components/atoms/Button.svelte'
import Image from '$components/atoms/Image.svelte'
export let product: any
export let location: { name: string, slug: string }
export let image: any
</script>
<div class="poster">
{#if image}
<a href="/shop/poster-{location.slug}" data-sveltekit-noscroll
on:click={() => $smoothScroll.scrollTo('#poster', { duration: 2 })}
>
<Image
id={image.id}
sizeKey="product"
sizes={{
small: { width: 326 },
medium: { width: 326 },
large: { width: 326 },
}}
ratio={1.5}
alt="Poster of {location.name}"
/>
</a>
{/if}
<div class="buttons">
<Button
size="xsmall"
url="/shop/poster-{location.slug}"
text="View"
on:click={() => $smoothScroll.scrollTo('#poster', { duration: 2 })}
/>
<Button
tag="button"
size="xsmall"
text="Add to cart"
color="pink"
on:click={() => addToCart(product)}
/>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<style lang="scss">
@import "../../style/molecules/process-step";
</style>
<script lang="ts">
import { scaleFade } from '$animations/transitions'
// Components
import Image from '$components/atoms/Image.svelte'
import { getAssetUrlKey } from '$utils/api'
export let index: number
export let text: string
export let image: any = undefined
export let video: any = undefined
const imageRatio = image ? image.width / image.height : undefined
</script>
<div class="step grid" style:--index={index}
in:scaleFade|local={{ scale: [1.1, 1], opacity: [0, 1], x: [20, 0], delay: 0.2 }}
out:scaleFade|local={{ scale: [1, 0.9], opacity: [1, 0], x: [0, -20] }}
>
{#if image || video}
<div class="media">
{#if image}
<Image
class="image shadow-box-dark"
id={image.id}
sizeKey="product"
sizes={{
small: { width: 400 },
medium: { width: 600 },
}}
ratio={imageRatio}
alt={image.title}
/>
{:else if video && video.mp4 && video.webm}
<video muted loop playsinline autoplay allow="autoplay">
<source type="video/mp4" src={getAssetUrlKey(video.mp4, 'step')} />
<source type="video/webm" src={getAssetUrlKey(video.webm, 'step')} />
<track kind="captions" />
</video>
{/if}
</div>
{/if}
<div class="text text-xsmall">
{@html text}
</div>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
interface Option {
value: string
name: string
default?: boolean
selected?: boolean
}
export let id: string
export let name: string
export let options: Option[]
export let value: string = undefined
const dispatch = createEventDispatcher()
const defaultOption = options.find(option => option.default)
let selected = value || options[0].value
$: currentOption = options.find(option => option.value === selected)
// Redefine value from parent (when reset)
$: if (value === defaultOption.value) {
selected = defaultOption.value
}
/**
* When changing select value
*/
const handleChange = ({ target: { value }}: any) => {
const option = options.find(option => option.value === value)
// Dispatch event to parent
dispatch('change', option.value)
}
</script>
<div class="select">
<slot />
<span>{currentOption.name}</span>
<select {name} {id} bind:value={selected} on:change={handleChange}>
{#each options as { value, name }}
<option {value} selected={value === selected}>
{name}
</option>
{/each}
</select>
</div>

View File

@@ -0,0 +1,45 @@
<style lang="scss">
@import "../../style/molecules/shop-locationswitcher";
</style>
<script lang="ts">
import { goto } from '$app/navigation'
import { getContext, tick } from 'svelte'
import { shopCurrentProductSlug } from '$utils/stores/shop'
import { smoothScroll } from '$utils/stores'
export let isOver: boolean = false
const { shopLocations }: any = getContext('shop')
const classes = [
'shop-locationswitcher',
isOver && 'is-over',
$$props.class
].join(' ').trim()
// Quick location change
const quickLocationChange = async ({ target: { value }}: any) => {
const pathTo = `/shop/poster-${value}`
goto(pathTo, { replaceState: true, noscroll: true, keepfocus: true })
// Scroll to anchor
await tick()
$smoothScroll.scrollTo('#poster', { duration: 2 })
}
</script>
<dl class={classes}>
<dt class="text-label">Choose a city</dt>
<dd>
<svg width="18" height="18">
<use xlink:href="#icon-map-pin" />
</svg>
<select on:change={quickLocationChange}>
{#each shopLocations as { name, slug }}
<option value={slug} selected={slug === $shopCurrentProductSlug}>{name}</option>
{/each}
</select>
</dd>
</dl>

View File

@@ -0,0 +1,73 @@
<style lang="scss">
@import "../../style/molecules/switcher";
</style>
<script lang="ts">
import { page } from '$app/stores'
import { getContext } from 'svelte'
import reveal from '$animations/reveal'
import { sendEvent } from '$utils/analytics'
// Components
import Icon from '$components/atoms/Icon.svelte'
const { settings: { switcher_links }}: any = getContext('global')
let switcherEl: HTMLElement
let isOpen = false
/**
* Toggle switcher open state
*/
const toggleSwitcher = () => {
isOpen = !isOpen
// Record opening event
!isOpen && sendEvent('switcherOpen')
}
/**
* Detect outside click
*/
const windowClick = ({ target }) => {
if (!switcherEl.contains(target) && isOpen) {
// Close switcher
toggleSwitcher()
}
}
</script>
<svelte:window on:click={windowClick} />
<aside class="switcher" bind:this={switcherEl}
class:is-open={isOpen}
use:reveal={{
animation: { y: [24, 0], opacity: [0, 1] },
options: {
duration: 1,
delay: 0.6,
threshold: 0,
},
}}
>
<button class="switcher__button" title="{!isOpen ? 'Open' : 'Close'} menu" tabindex="0"
on:click={toggleSwitcher}
>
<span>
{#each Array(3) as _}
<i />
{/each}
</span>
</button>
<ul class="switcher__links" data-sveltekit-noscroll>
{#each switcher_links as { text, url, icon, icon_label }}
<li class:is-active={$page.url.pathname === url}>
<a href={url} on:click={toggleSwitcher} tabindex="0">
<Icon class="icon" icon={icon} label={icon_label} />
<span>{text}</span>
</a>
</li>
{/each}
</ul>
</aside>

View File

@@ -0,0 +1,42 @@
<style lang="scss">
@import "../../style/organisms/banner";
</style>
<script lang="ts">
// Components
import Image from '$components/atoms/Image.svelte'
export let title: string
export let image: any
export let back: boolean = false
</script>
<section class="banner">
<div class="banner__top container">
{#if back}
<a href="/" class="back" data-sveltekit-noscroll>
<svg width="5" height="8" viewBox="0 0 5 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 1 1 4l3 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Back to Houses Of</span>
</a>
{/if}
</div>
<div class="banner__title">
<h1 class="title-big-sans">{title}</h1>
</div>
<Image
class="banner__background"
id={image.id}
alt={image.alt}
sizeKey="hero"
sizes={{
large: { width: 1800, height: 1200 },
medium: { width: 1200, height: 800 },
small: { width: 700, height: 700 },
}}
lazy={false}
/>
</section>

View File

@@ -0,0 +1,129 @@
<style lang="scss">
@import "../../style/organisms/carousel";
</style>
<script lang="ts">
import { onMount } from 'svelte'
import { writable } from 'svelte/store'
import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'
// Components
import Image from '$components/atoms/Image.svelte'
export let slides: any
let carouselEl: HTMLElement
let carousel: EmblaCarouselType
let currentSlide = 0
let arrowDirection: string = null
$: isFirstSlide = currentSlide === 0
$: isLastSlide = currentSlide === slides.length - 1
/** Navigate to specific slide */
const goToSlide = (index: number = 0) => carousel.scrollTo(index)
/** Move and change arrow direction when moving */
const arrowPosition = writable({ x: 0, y: 0 })
/** Move arrow and define direction on mousemove */
const handleArrowMove = (event: MouseEvent) => {
const { left, top, width } = carouselEl.getBoundingClientRect()
const offsetX = event.clientX - left
const offsetY = event.clientY - top
// Define direction
if (isFirstSlide) {
arrowDirection = 'next'
} else if (isLastSlide) {
arrowDirection = 'prev'
} else {
arrowDirection = offsetX < Math.round(width / 2) ? 'prev' : 'next'
}
// Move arrow
arrowPosition.set({
x: offsetX - 12,
y: offsetY - 56,
})
}
/** Go to prev or next slide depending on direction */
const handleArrowClick = () => {
if (!carousel.clickAllowed()) return
// Define direction
if (isFirstSlide) {
arrowDirection = 'next'
} else if (isLastSlide) {
arrowDirection = 'prev'
}
// Click only if carousel if being dragged
if (arrowDirection === 'prev') {
carousel.scrollPrev()
} else {
carousel.scrollNext()
}
}
onMount(() => {
// Init carousel
carousel = EmblaCarousel(carouselEl, {
loop: false
})
carousel.on('select', () => {
currentSlide = carousel.selectedScrollSnap()
})
// Destroy
return () => {
carousel.destroy()
}
})
</script>
<div class="carousel {$$props.class ?? ''}">
{#if slides.length}
<div class="carousel__viewport" bind:this={carouselEl}
on:mousemove={handleArrowMove}
on:click={handleArrowClick}
>
<div class="carousel__slides">
{#each slides as { id, alt }}
<Image
class="carousel__slide"
id={id}
sizeKey="product"
sizes={{
small: { width: 300 },
medium: { width: 550 },
large: { width: 800 },
}}
ratio={1.5}
alt={alt}
/>
{/each}
</div>
</div>
<ul class="carousel__dots">
{#each slides as _, index}
<li class:is-active={index === currentSlide}>
<button on:click={() => goToSlide(index)} aria-label="Go to slide #{index + 1}" />
</li>
{/each}
</ul>
<span class="carousel__arrow"
style:--x="{$arrowPosition.x}px"
style:--y="{$arrowPosition.y}px"
class:is-flipped={arrowDirection === 'prev' && !isFirstSlide || isLastSlide}
>
<svg width="29" height="32">
<use xlink:href="#arrow" />
</svg>
</span>
{/if}
</div>

View File

@@ -0,0 +1,155 @@
<style lang="scss">
@import "../../style/organisms/cart";
</style>
<script lang="ts">
import { browser } from '$app/environment'
import { onMount } from 'svelte'
import { fade, fly } from 'svelte/transition'
import { quartOut } from 'svelte/easing'
import { smoothScroll } from '$utils/stores'
import { cartOpen, cartData, cartAmount, cartIsUpdating } from '$utils/stores/shop'
import { initSwell, getCart, updateCartItem, removeCartItem } from '$utils/functions/shop'
// Components
import Button from '$components/atoms/Button.svelte'
import Icon from '$components/atoms/Icon.svelte'
import CartItem from '$components/molecules/CartItem.svelte'
import ShopLocationSwitcher from '$components/molecules/ShopLocationSwitcher.svelte'
import { sendEvent } from '$utils/analytics';
// Block scroll if cart is open
$: if (browser && $smoothScroll) {
if ($cartOpen) {
$smoothScroll.stop()
} else {
$smoothScroll.start()
}
document.documentElement.classList.toggle('block-scroll', $cartOpen)
}
// Closing the cart
const handleCloseCart = () => {
$cartOpen = false
}
// Item quantity changed
const changedQuantity = async ({ detail: { id, quantity } }) => {
// Cart is now updating
$cartIsUpdating = true
// Update cart item
const updatedCart = await updateCartItem(id, quantity)
if (updatedCart) {
// Store new cart data
$cartData = updatedCart
// Cart is updated
$cartIsUpdating = false
}
}
// Item removed
const removedItem = async ({ detail: id }) => {
// Cart is now updating
$cartIsUpdating = true
// Remove item from cart
const updatedCart = await removeCartItem(id)
if (updatedCart) {
// Store new cart data
$cartData = updatedCart
// Cart is updated
$cartIsUpdating = false
}
}
onMount(async () => {
// Init Swell
initSwell()
// Fetch cart
const cart = await getCart()
if (cart) {
// Store cart data
$cartData = cart
}
})
</script>
{#if $cartOpen}
<div class="cart-switcher" transition:fly={{ y: -24, duration: 1000, easing: quartOut }}>
<ShopLocationSwitcher isOver={true} />
</div>
<aside class="cart shadow-box-dark"
class:is-updating={$cartIsUpdating}
transition:fly={{ x: 48, duration: 600, easing: quartOut }}
>
<header class="cart__heading">
<h2>Cart</h2>
<button class="text-label" on:click={handleCloseCart}>Close</button>
</header>
<div class="cart__content">
{#if $cartAmount > 0}
{#each $cartData.items as item}
<CartItem {item}
on:updatedQuantity={changedQuantity}
on:removed={removedItem}
/>
{/each}
{:else}
<div class="cart__empty shadow-small" transition:fade={{ duration: 600 }}>
<div class="icon">
<Icon icon="bag" label="Shopping bag icon" />
</div>
<p>Your cart is empty</p>
</div>
{/if}
{#if $cartIsUpdating}
<div class="cart__update"
in:fly={{ y: 8, duration: 600, easing: quartOut }}
out:fly={{ y: -8, duration: 600, easing: quartOut }}
>
<p>Updating…</p>
</div>
{/if}
</div>
<footer class="cart__total">
<div class="cart__total--sum">
<h3>Total</h3>
{#if $cartData}
<span>{$cartAmount} item{$cartAmount > 1 ? 's' : ''}</span>
<p>{$cartData.sub_total ? $cartData.sub_total : 0}</p>
{:else}
<span>0 item</span>
<p>0€</p>
{/if}
</div>
<div class="cart__total--checkout">
<p>Free shipping worldwide!</p>
{#if $cartData && $cartAmount > 0 && $cartData.checkout_url}
<div transition:fly={{ y: 8, duration: 600, easing: quartOut }}>
<Button
url={$cartData && $cartData.checkout_url}
text="Checkout"
color="pink"
size="small"
on:click={() => sendEvent('cartCheckout', { props: { amount: $cartAmount }})}
/>
</div>
{/if}
</div>
</footer>
</aside>
<div class="cart-overlay"
transition:fade={{ duration: 600, easing: quartOut }}
on:click={handleCloseCart}
/>
{/if}

View File

@@ -0,0 +1,29 @@
<style lang="scss">
@import "../../style/organisms/collage";
</style>
<script lang="ts">
import PhotoCard from '$components/molecules/PhotoCard.svelte'
export let photos: any[] = []
let hovered: number = null
</script>
{#if photos}
<div class="collage" class:is-hovering={hovered !== null}>
{#each photos as { slug, title, image, location, city }, index}
<PhotoCard
id={image.id}
alt={title}
url="/{location.country.slug}/{location.slug}/{slug}"
title={title}
location={location}
city={city}
hovered={hovered === index}
lazy={false}
on:hover={({ detail }) => hovered = detail ? index : null}
/>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,45 @@
<style lang="scss">
@import "../../style/organisms/footer";
</style>
<script lang="ts">
import { getContext } from 'svelte'
// Components
import SplitText from '$components/SplitText.svelte'
import SiteTitle from '$components/atoms/SiteTitle.svelte'
const { settings: { instagram, footer_links }}: any = getContext('global')
</script>
<footer class="footer">
<div class="container grid">
<a href="/" class="footer__title" tabindex="0" data-sveltekit-noscroll>
<SiteTitle tag="div" />
</a>
<nav class="footer__links">
<ul data-sveltekit-noscroll>
{#each footer_links as { title, slug }}
<li>
<a href="/{slug}" class="link-3d" tabindex="0">
<SplitText text={title} clone={true} />
</a>
</li>
{/each}
<li class="instagram">
<a href="https://www.instagram.com/{instagram}" target="_blank" rel="noopener noreferrer external" class="link-3d">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.8c2.67 0 2.99.01 4.04.06.98.04 1.5.2 1.86.34a3.27 3.27 0 0 1 1.9 1.9c.13.35.3.88.34 1.86.05 1.05.06 1.37.06 4.04s-.01 2.99-.06 4.04c-.04.98-.2 1.5-.34 1.86-.19.46-.4.8-.75 1.15a3.1 3.1 0 0 1-1.15.75c-.35.13-.88.3-1.86.34-1.05.05-1.37.06-4.04.06s-2.99-.01-4.04-.06c-.98-.04-1.5-.2-1.86-.34a3.1 3.1 0 0 1-1.15-.75 3.1 3.1 0 0 1-.75-1.15 5.6 5.6 0 0 1-.34-1.86A69.42 69.42 0 0 1 1.8 10c0-2.67.01-2.99.06-4.04.04-.98.2-1.5.34-1.86.19-.46.4-.8.75-1.15A3.1 3.1 0 0 1 4.1 2.2c.35-.13.88-.3 1.86-.34C7 1.81 7.33 1.8 10 1.8ZM10 0C7.28 0 6.94.01 5.88.06 4.8.11 4.08.28 3.45.52a4.9 4.9 0 0 0-1.77 1.16A4.9 4.9 0 0 0 .52 3.45a7.34 7.34 0 0 0-.46 2.43C.01 6.94 0 7.28 0 10s.01 3.06.06 4.12c.05 1.07.22 1.8.46 2.43.26.66.6 1.22 1.16 1.77.55.56 1.11.9 1.77 1.16a7.6 7.6 0 0 0 2.43.46c1.06.05 1.4.06 4.12.06s3.06-.01 4.12-.06a7.34 7.34 0 0 0 2.43-.46 4.9 4.9 0 0 0 1.77-1.16 4.9 4.9 0 0 0 1.16-1.77 7.6 7.6 0 0 0 .46-2.43c.05-1.06.06-1.4.06-4.12s-.01-3.06-.06-4.12a7.34 7.34 0 0 0-.46-2.43 4.9 4.9 0 0 0-1.16-1.77A4.9 4.9 0 0 0 16.55.52a7.34 7.34 0 0 0-2.43-.46C13.06.01 12.72 0 10 0Zm0 4.86a5.14 5.14 0 1 0 0 10.28 5.14 5.14 0 0 0 0-10.28Zm0 8.47a3.33 3.33 0 1 1 0-6.66 3.33 3.33 0 0 1 0 6.66Zm5.34-7.47a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4Z" />
</svg>
<SplitText
class="instagram__text"
text="Instagram"
clone={true}
/>
</a>
</li>
</ul>
</nav>
</div>
</footer>

View File

@@ -0,0 +1,157 @@
<style lang="scss">
@import "../../style/modules/globe";
</style>
<script lang="ts">
import { getContext, onMount } from 'svelte'
import { fade, fly as flySvelte } from 'svelte/transition'
import { quartOut } from 'svelte/easing'
import { Globe, type Marker } from '$modules/globe'
import { getRandomItem, debounce } from '$utils/functions'
import { revealSplit } from '$animations/transitions'
// Components
import SplitText from '$components/SplitText.svelte'
const isDev = import.meta.env.DEV
export let type: string = undefined
export let autoRotate: boolean = true
export let enableMarkers: boolean = true
export let enableMarkersLinks: boolean = true
export let speed: number = 0.1
export let pane: boolean = isDev
export let width: number = undefined
let innerWidth: number
let globeParentEl: HTMLElement, globeEl: HTMLElement
let globe: any
let observer: IntersectionObserver
let animation: number
let hoveredMarker: { name: string, country: string } = null
const { continents, locations }: any = getContext('global')
const randomContinent: any = getRandomItem(continents)
const markers = locations.map(({ name, slug, country, coordinates: { coordinates }}): Marker => ({
name,
slug,
country: { ...country },
lat: coordinates[1],
lng: coordinates[0],
}))
onMount(() => {
const globeResolution = innerWidth > 1440 && window.devicePixelRatio > 1 ? 4 : 2
globe = new Globe({
el: globeEl,
parent: globeParentEl,
mapFile: `/images/globe-map-${globeResolution}k.png`,
mapFileDark: `/images/globe-map-dark-${globeResolution}k.png`,
dpr: Math.min(Math.round(window.devicePixelRatio), 2),
autoRotate,
speed,
sunAngle: 2,
rotationStart: {
x: randomContinent.rotation_x,
y: randomContinent.rotation_y,
},
enableMarkers,
enableMarkersLinks: enableMarkersLinks && type !== 'cropped',
markers,
pane,
})
resize()
// Render only if in viewport
observer = new IntersectionObserver(([{ isIntersecting }]) => {
if (isIntersecting) {
update()
if (isDev) {
console.log('globe: render/start')
}
} else {
stop()
if (isDev) {
console.log('globe: render/stop')
}
}
}, { threshold: 0 })
observer.observe(globeEl)
// Destroy
return () => {
destroy()
observer && observer.disconnect()
}
})
/**
* Methods
*/
// Update
const update = () => {
animation = requestAnimationFrame(update)
globe.render()
}
// Stop
const stop = () => {
cancelAnimationFrame(animation)
}
// Resize
const resize = debounce(() => {
globe.resize()
}, 100)
// Destroy
const destroy = () => {
stop()
globe.destroy()
}
</script>
<svelte:window bind:innerWidth
on:resize={resize}
/>
<div class="globe" bind:this={globeParentEl}
class:is-cropped={type === 'cropped'}
style:--width={width ? `${width}px` : null}
>
<div class="globe__canvas" bind:this={globeEl}
class:is-faded={hoveredMarker}
>
<ul class="globe__markers">
{#each markers as { name, slug, country, lat, lng }}
<li class="globe__marker" data-location={slug} data-lat={lat} data-lng={lng}>
<a href="/{country.slug}/{slug}" aria-label={name} data-sveltekit-noscroll
on:mouseenter={() => hoveredMarker = { name, country: country.name }}
on:mouseleave={() => hoveredMarker = null}
>
<i />
<span>{name}</span>
</a>
</li>
{/each}
</ul>
</div>
{#if hoveredMarker}
<div class="globe__location"
in:revealSplit={{ duration: 1 }}
out:fade={{ duration: 300, easing: quartOut }}
>
<SplitText text={hoveredMarker.name} mode="chars" class="name" />
<p class="country" in:flySvelte={{ y: 16, duration: 800, easing: quartOut, delay: 700 }}>
{hoveredMarker.country}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,24 @@
<style lang="scss">
.list-cta {
@include bp (sm) {
display: flex;
justify-content: center;
}
:global(li) {
display: block;
}
& > :global(*) {
margin: 20px auto 0;
@include bp (sm) {
margin: 0 16px;
}
}
}
</style>
<ul class="list-cta" data-sveltekit-noscroll>
<slot />
</ul>

View File

@@ -0,0 +1,92 @@
<style lang="scss">
@import "../../style/organisms/locations";
</style>
<script lang="ts">
import { getContext } from 'svelte'
import { flip } from 'svelte/animate'
import { quartOut } from 'svelte/easing'
import reveal from '$animations/reveal'
import { send, receive } from '$animations/crossfade'
import { throttle } from '$utils/functions'
import { sendEvent } from '$utils/analytics'
// Components
import Button from '$components/atoms/Button.svelte'
import Location from '$components/molecules/Location.svelte'
export let locations: any[]
const { continents, settings: { explore_list }}: any = getContext('global')
// Continents filtering logic
let currentContinent: string = undefined
$: filteredLocations = locations.filter(({ country: { continent }}: any) => {
if (!currentContinent) {
// Show all locations by default
return true
} else {
// Location's continent matches the clicked continent
return continent.slug === currentContinent
}
})
/**
* Filter locations from continent
*/
const filterLocation = throttle((continent: string) => {
currentContinent = continent !== currentContinent ? continent : null
}, 600)
</script>
<div class="browse">
<div class="browse__description">
<p>{explore_list}</p>
</div>
<ul class="browse__continents">
{#each continents as { name, slug }}
<li class:is-active={currentContinent === slug}>
<Button
tag="button" text={name} size="small"
slotPosition="after"
class={'is-disabled'}
on:click={() => {
filterLocation(slug)
sendEvent('filterContinent')
}}
>
<svg width="12" height="12">
<use xlink:href="#cross" />
</svg>
</Button>
</li>
{/each}
</ul>
<ul class="browse__locations" data-sveltekit-noscroll
use:reveal={{
children: '.location',
animation: { y: ['20%', 0], opacity: [0, 1] },
options: {
stagger: 0.105,
duration: 1,
threshold: 0.3,
},
}}
>
{#each filteredLocations as location (location)}
<li
animate:flip={{ duration: 1000, easing: quartOut }}
in:receive={{ key: location.slug }}
out:send={{ key: location.slug }}
>
<Location
location={location}
latestPhoto={location.photos[0]}
/>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,24 @@
<style lang="scss">
@import "../../style/organisms/newsletter";
</style>
<script lang="ts">
import { getContext } from 'svelte'
// Components
import EmailForm from '$components/molecules/EmailForm.svelte'
export let theme: string = 'default'
const { settings: { newsletter_text, newsletter_subtitle }}: any = getContext('global')
</script>
<div class="newsletter newsletter--{theme} shadow-box-dark">
<div class="newsletter__wrapper">
<h2 class="title-medium">
<label for="newsletter_email">{newsletter_subtitle}</label>
</h2>
<p class="text-small">{newsletter_text}</p>
<EmailForm past={true} />
</div>
</div>

View File

@@ -0,0 +1,120 @@
<style lang="scss">
@import "../../style/pages/shop/posters";
</style>
<script lang="ts">
import { getContext, onMount } from 'svelte'
import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'
// Components
import Poster from '$components/molecules/Poster.svelte'
import { debounce } from '$utils/functions'
export let posters: any = []
let innerWidth: number
let carouselEl: HTMLElement
let carousel: EmblaCarouselType
let currentSlide = 0
let carouselDots = []
const { shopProducts }: any = getContext('shop')
/** Navigate to specific slide */
const goToSlide = (index: number = 0) => {
carousel.scrollTo(index)
}
/** Init Carousel */
const initCarousel = () => {
if (innerWidth < 1200) {
if (!carousel) {
carousel = EmblaCarousel(carouselEl, {
slidesToScroll: innerWidth < 550 ? 1 : 2,
})
// On init
carousel.on('init', () => {
// Define amounts of dots
carouselDots = carousel.scrollSnapList()
})
// On slide change
carousel.on('select', () => {
// Define current slide
currentSlide = carousel.selectedScrollSnap()
})
// On resize
carousel.on('resize', () => {
// Redefine options
carousel.reInit({
slidesToScroll: innerWidth < 550 ? 1 : 2
})
// Define amounts of dots
carouselDots = carousel.scrollSnapList()
// Define current slide
currentSlide = carousel.selectedScrollSnap()
})
}
} else {
if (carousel) {
destroyCarousel()
}
}
}
/** Destroy carousel */
const destroyCarousel = () => {
carousel.destroy()
carousel = undefined
}
/** Destroy carousel for larger screens */
const handleResize = debounce(initCarousel, 200)
onMount(() => {
if (innerWidth < 1200) {
initCarousel()
}
// Destroy
return () => {
if (carousel) {
carousel.destroy()
}
}
})
</script>
<svelte:window
bind:innerWidth
on:resize={handleResize}
/>
<section class="shop-page__posters grid">
<h3>View all of our available posters</h3>
{#if posters}
<div class="set" bind:this={carouselEl}>
<div class="set__content">
{#each posters as { location, photos_product }}
<Poster
location={location}
image={photos_product.length && photos_product[1].directus_files_id}
product={shopProducts.find(item => item.slug.includes(location.slug))}
/>
{/each}
</div>
{#if carousel}
<ul class="set__dots">
{#each carouselDots as _, index}
<li class:is-active={index === currentSlide}>
<button on:click={() => goToSlide(index)} aria-label="Go to slide #{index + 1}" />
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</section>

View File

@@ -0,0 +1,166 @@
<style lang="scss">
@import "../../style/pages/shop/banner";
</style>
<script lang="ts">
import { navigating } from '$app/stores'
import { getContext, onMount } from 'svelte'
import { stagger, timeline } from 'motion'
import { smoothScroll } from '$utils/stores'
import { cartOpen } from '$utils/stores/shop'
import { DELAY } from '$utils/constants'
import { quartOut } from '$animations/easings'
// Components
import Image from '$components/atoms/Image.svelte'
import ButtonCart from '$components/atoms/ButtonCart.svelte'
import ShopLocationSwitcher from '$components/molecules/ShopLocationSwitcher.svelte'
export let product: any = undefined
const { shop, shopLocations }: any = getContext('shop')
let innerWidth: number
let navObserver: IntersectionObserver
let introEl: HTMLElement, navChooseEl: HTMLElement
let scrolledPastIntro = false
onMount(() => {
// Reveal the nav past the Intro
navObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
scrolledPastIntro = !entry.isIntersecting
})
}, {
threshold: 0,
rootMargin: '-3% 0px 0px'
})
navObserver.observe(introEl)
// Set navigation horizontal scroll depending on current link position
const navChooseActive: HTMLElement = navChooseEl.querySelector('.is-active')
const offsetLeft = navChooseActive.offsetLeft
if (offsetLeft > window.innerWidth / 2) {
navChooseEl.scrollLeft = offsetLeft
}
/**
* Animations
*/
const animation = timeline([
// Hero image
['.background', {
scale: [1.06, 1],
opacity: [0, 1],
z: 0,
}, {
at: 0.4,
duration: 2.4,
}],
// Intro top elements
['.shop-banner .top > *', {
y: [-100, 0],
opacity: [0, 1],
}, {
at: 0.4,
delay: stagger(0.25),
}],
// Hero title
['.shop-banner .title h1', {
y: [32, 0],
opacity: [0, 1],
}, {
at: 0.5,
}],
// Intro navbar
['.shop-banner .nav .container > *, .shop-banner .button-cart', {
y: [100, 0],
opacity: [0, 1],
}, {
at: 0.7,
delay: stagger(0.25),
}]
], {
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
defaultOptions: {
duration: 1.6,
easing: quartOut,
},
})
animation.stop()
// Run animation
requestAnimationFrame(animation.play)
// Destroy
return () => {
navObserver && navObserver.disconnect()
}
})
</script>
<svelte:window bind:innerWidth />
<section class="shop-banner" bind:this={introEl}>
<div class="top container">
<a href="/" class="back" data-sveltekit-noscroll>
<svg width="5" height="8" viewBox="0 0 5 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 1 1 4l3 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Back to Houses Of</span>
</a>
</div>
<div class="title">
<h1 class="title-big-sans">Shop</h1>
</div>
<div class="nav">
<div class="container">
<p class="text-label">Choose a city</p>
<nav>
<ul bind:this={navChooseEl} data-sveltekit-noscroll>
{#each shopLocations as { name, slug }}
<li class:is-active={product && slug === product.location.slug}>
<a href="/shop/poster-{slug}" on:click={() => $smoothScroll.scrollTo('#poster', { duration: 2 })}>
{name}
</a>
</li>
{/each}
</ul>
</nav>
</div>
</div>
<ButtonCart />
<Image
class="background"
id={shop.page_heroimage.id}
alt={shop.page_heroimage.alt}
sizeKey="hero"
sizes={{
large: { width: 1800, height: 1200 },
medium: { width: 1200, height: 800 },
small: { width: 700, height: 700 },
}}
lazy={false}
/>
</section>
<nav class="shop-quicknav"
class:is-visible={scrolledPastIntro}
class:is-overlaid={$cartOpen}
>
{#if innerWidth > 768}
<ShopLocationSwitcher />
{/if}
<ButtonCart />
</nav>

View File

@@ -0,0 +1,93 @@
<style lang="scss">
@import "../../style/organisms/shop";
</style>
<script lang="ts">
import { getContext, onMount } from 'svelte'
// Components
import Button from '$components/atoms/Button.svelte'
import Image from '$components/atoms/Image.svelte'
const { locations, shop }: any = getContext('global')
const locationsWithPoster = locations
// Filter locations with posters only
.filter((loc: Location) => loc.has_poster)
// Sort locations alphabetically from slug (a>z)
.sort((a: Location, b: Location) => a.slug.localeCompare(b.slug))
// Return name only
.map((loc: Location) => loc.name)
export let images: any[] = shop.module_images
export let title: string = shop.module_title
export let text: string = shop.module_text
export let textBottom: string = undefined
export let buttonText: string = 'Shop'
export let url: string = '/shop'
export let enabled: boolean = true
if (textBottom !== null) {
textBottom = `Posters available for ${locationsWithPoster.join(', ').replace(/,(?!.*,)/gmi, ' and')}.`
}
interface Location {
slug: string
name: string
has_poster: boolean
}
// Image rotation
let imagesLoop: ReturnType<typeof setTimeout>
let currentImageIndex = 0
const incrementCurrentImageIndex = () => {
currentImageIndex = currentImageIndex === images.length - 1 ? 0 : currentImageIndex + 1
imagesLoop = setTimeout(() => requestAnimationFrame(incrementCurrentImageIndex), 3000)
}
onMount(() => {
if (images.length > 1) {
incrementCurrentImageIndex()
}
return () => {
// Clear rotating words timeout
if (imagesLoop) {
clearTimeout(imagesLoop)
}
}
})
</script>
<div class="shop shadow-box-dark" class:has-no-bottom={!textBottom}>
<div class="content">
<div class="shop__images">
{#if images}
<a href={enabled ? url : undefined} title="Visit our shop" data-sveltekit-noscroll>
{#each images as { directus_files_id: { id, title }}, index}
<Image
class={index === currentImageIndex ? 'is-visible' : null}
{id}
sizeKey="square"
sizes={{
small: { width: 400, height: 400 },
large: { width: 800, height: 800 },
}}
alt={title}
/>
{/each}
</a>
{/if}
</div>
<div class="shop__content">
<h2 class="title-medium">{title}</h2>
<p class="text-small">{text}</p>
{#if enabled}
<Button {url} text={buttonText} color="pinklight" />
{/if}
{#if textBottom}
<p class="detail">{textBottom}</p>
{/if}
</div>
</div>
</div>