🚧 Switch to monorepo with Turbo
This commit is contained in:
27
apps/website/src/animations/crossfade.ts
Normal file
27
apps/website/src/animations/crossfade.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { crossfade } from 'svelte/transition'
|
||||
import { quartOut } from 'svelte/easing'
|
||||
|
||||
// Crossfade transition
|
||||
export const [send, receive] = crossfade({
|
||||
// duration: 1200,
|
||||
duration: d => Math.sqrt(d * 200),
|
||||
fallback (node, params) {
|
||||
const {
|
||||
duration = 600,
|
||||
easing = quartOut,
|
||||
start = 0.85
|
||||
} = params
|
||||
const style = getComputedStyle(node)
|
||||
const transform = style.transform === 'none' ? '' : style.transform
|
||||
const sd = 1 - start
|
||||
|
||||
return {
|
||||
duration,
|
||||
easing,
|
||||
css: (t, u) => `
|
||||
transform: ${transform} scale(${1 - (sd * u)});
|
||||
opacity: ${t}
|
||||
`
|
||||
}
|
||||
}
|
||||
})
|
||||
7
apps/website/src/animations/easings.ts
Normal file
7
apps/website/src/animations/easings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Easing } from 'motion'
|
||||
|
||||
|
||||
/**
|
||||
* Ease: Quart Out Array
|
||||
*/
|
||||
export const quartOut: Easing = [.165, .84, .44, 1]
|
||||
45
apps/website/src/animations/reveal.ts
Normal file
45
apps/website/src/animations/reveal.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { animate, inView, stagger } from 'motion'
|
||||
import { quartOut } from '$animations/easings'
|
||||
|
||||
const defaultOptions = {
|
||||
stagger: null,
|
||||
delay: 0,
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
}
|
||||
|
||||
export default (node: Element | any, {
|
||||
enable = true,
|
||||
children = undefined,
|
||||
animation = [],
|
||||
options = defaultOptions,
|
||||
}: RevealOptions) => {
|
||||
if (!enable) return
|
||||
|
||||
// Define targets from children, if empty get node
|
||||
const targets = children ? node.querySelectorAll(children) : [node]
|
||||
|
||||
// If animation has opacity starting with 0, hide it first
|
||||
if (animation.opacity && animation.opacity[0] === 0) {
|
||||
targets.forEach((el: HTMLElement) => el.style.opacity = '0')
|
||||
}
|
||||
|
||||
// Create inView instance
|
||||
inView(node, ({ isIntersecting }) => {
|
||||
const anim = animate(
|
||||
targets,
|
||||
animation,
|
||||
{
|
||||
delay: options.stagger ? stagger(options.stagger, { start: options.delay }) : options.delay,
|
||||
duration: options.duration,
|
||||
easing: options.easing,
|
||||
}
|
||||
)
|
||||
anim.stop()
|
||||
|
||||
// Run animation if in view and tab is active
|
||||
isIntersecting && requestAnimationFrame(anim.play)
|
||||
}, {
|
||||
amount: options.threshold,
|
||||
})
|
||||
}
|
||||
60
apps/website/src/animations/transitions.ts
Normal file
60
apps/website/src/animations/transitions.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { animate, stagger } from 'motion'
|
||||
import type { TransitionConfig } from 'svelte/transition'
|
||||
import { quartOut } from './easings'
|
||||
|
||||
|
||||
/**
|
||||
* Scale and fade
|
||||
*/
|
||||
export const scaleFade = (node: HTMLElement, {
|
||||
scale = [0.7, 1],
|
||||
opacity = [1, 0],
|
||||
x = null,
|
||||
delay = 0,
|
||||
duration = 1,
|
||||
}): TransitionConfig => {
|
||||
return {
|
||||
css: () => {
|
||||
animate(node, {
|
||||
scale,
|
||||
opacity,
|
||||
x,
|
||||
z: 0,
|
||||
}, {
|
||||
easing: quartOut,
|
||||
duration,
|
||||
delay,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scale and fade split text
|
||||
*/
|
||||
export const revealSplit = (node: HTMLElement, {
|
||||
opacity = [0, 1],
|
||||
y = ['110%', '0%'],
|
||||
children = '.char',
|
||||
duration = 1,
|
||||
delay = 0,
|
||||
}): TransitionConfig => {
|
||||
return {
|
||||
css: () => {
|
||||
animate(node.querySelectorAll(children), {
|
||||
opacity,
|
||||
y,
|
||||
z: 0,
|
||||
}, {
|
||||
easing: quartOut,
|
||||
duration,
|
||||
delay: stagger(0.04, { start: delay }),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/website/src/app.d.ts
vendored
Normal file
88
apps/website/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Custom Events
|
||||
*/
|
||||
// Swipe
|
||||
declare namespace svelte.JSX {
|
||||
interface HTMLAttributes<T> {
|
||||
onswipe?: (event: CustomEvent<string> & { target: EventTarget & T }) => any,
|
||||
ontap?: (event: CustomEvent<boolean> & { target: EventTarget & T }) => any,
|
||||
oncopied?: (event: CustomEvent<any> & { target: EventTarget & T }) => any,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Custom Types
|
||||
*/
|
||||
declare interface PhotoGridAbout {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
image: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifcation
|
||||
*/
|
||||
declare interface ShopNotification {
|
||||
title: string
|
||||
name: string
|
||||
image: string
|
||||
timeout?: number
|
||||
id?: number
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Smooth Scroll Options
|
||||
*/
|
||||
declare interface smoothScrollOptions {
|
||||
hash: string
|
||||
changeHash?: boolean
|
||||
event?: MouseEvent
|
||||
callback?: Function
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Swipe options
|
||||
*/
|
||||
declare interface SwipeOptions {
|
||||
travelX?: number
|
||||
travelY?: number
|
||||
timeframe?: number
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reveal Animation
|
||||
*/
|
||||
declare type RevealOptions = {
|
||||
enable?: boolean
|
||||
options?: TransitionOptions
|
||||
children?: string | HTMLElement
|
||||
animation: any
|
||||
}
|
||||
// Options interface
|
||||
declare type TransitionOptions = {
|
||||
threshold?: number
|
||||
duration?: number
|
||||
stagger?: number
|
||||
delay?: number
|
||||
easing?: string | Easing
|
||||
}
|
||||
23
apps/website/src/app.html
Normal file
23
apps/website/src/app.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#3C0576">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json">
|
||||
<link type="text/plain" rel="author" href="%sveltekit.assets%/humans.txt">
|
||||
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/images/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/siteicon.png">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
<script>
|
||||
document.body.style.opacity = '0'
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
apps/website/src/components/Analytics.svelte
Normal file
15
apps/website/src/components/Analytics.svelte
Normal 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>
|
||||
37
apps/website/src/components/Metas.svelte
Normal file
37
apps/website/src/components/Metas.svelte
Normal 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>
|
||||
33
apps/website/src/components/PageTransition.svelte
Normal file
33
apps/website/src/components/PageTransition.svelte
Normal 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>
|
||||
38
apps/website/src/components/SVGSprite.svelte
Normal file
38
apps/website/src/components/SVGSprite.svelte
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
37
apps/website/src/components/SmoothScroll.svelte
Normal file
37
apps/website/src/components/SmoothScroll.svelte
Normal 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>
|
||||
42
apps/website/src/components/SplitText.svelte
Normal file
42
apps/website/src/components/SplitText.svelte
Normal 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}
|
||||
41
apps/website/src/components/atoms/AboutGridPhoto.svelte
Normal file
41
apps/website/src/components/atoms/AboutGridPhoto.svelte
Normal 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>
|
||||
12
apps/website/src/components/atoms/Badge.svelte
Normal file
12
apps/website/src/components/atoms/Badge.svelte
Normal 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>
|
||||
21
apps/website/src/components/atoms/BoxCTA.svelte
Normal file
21
apps/website/src/components/atoms/BoxCTA.svelte
Normal 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>
|
||||
60
apps/website/src/components/atoms/Button.svelte
Normal file
60
apps/website/src/components/atoms/Button.svelte
Normal 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}
|
||||
27
apps/website/src/components/atoms/ButtonCart.svelte
Normal file
27
apps/website/src/components/atoms/ButtonCart.svelte
Normal 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>
|
||||
44
apps/website/src/components/atoms/ButtonCircle.svelte
Normal file
44
apps/website/src/components/atoms/ButtonCircle.svelte
Normal 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}
|
||||
15
apps/website/src/components/atoms/DiscoverText.svelte
Normal file
15
apps/website/src/components/atoms/DiscoverText.svelte
Normal 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>
|
||||
10
apps/website/src/components/atoms/Icon.svelte
Normal file
10
apps/website/src/components/atoms/Icon.svelte
Normal 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>
|
||||
15
apps/website/src/components/atoms/IconArrow.svelte
Normal file
15
apps/website/src/components/atoms/IconArrow.svelte
Normal 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>
|
||||
44
apps/website/src/components/atoms/IconEarth.svelte
Normal file
44
apps/website/src/components/atoms/IconEarth.svelte
Normal 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>
|
||||
86
apps/website/src/components/atoms/Image.svelte
Normal file
86
apps/website/src/components/atoms/Image.svelte
Normal 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>
|
||||
72
apps/website/src/components/atoms/ScrollingTitle.svelte
Normal file
72
apps/website/src/components/atoms/ScrollingTitle.svelte
Normal 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>
|
||||
37
apps/website/src/components/atoms/SiteTitle.svelte
Normal file
37
apps/website/src/components/atoms/SiteTitle.svelte
Normal 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}
|
||||
134
apps/website/src/components/layouts/PosterLayout.svelte
Normal file
134
apps/website/src/components/layouts/PosterLayout.svelte
Normal 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>
|
||||
69
apps/website/src/components/molecules/CartItem.svelte
Normal file
69
apps/website/src/components/molecules/CartItem.svelte
Normal 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>
|
||||
106
apps/website/src/components/molecules/EmailForm.svelte
Normal file
106
apps/website/src/components/molecules/EmailForm.svelte
Normal 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>
|
||||
17
apps/website/src/components/molecules/Heading.svelte
Normal file
17
apps/website/src/components/molecules/Heading.svelte
Normal 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>
|
||||
66
apps/website/src/components/molecules/House.svelte
Normal file
66
apps/website/src/components/molecules/House.svelte
Normal 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>
|
||||
125
apps/website/src/components/molecules/Location.svelte
Normal file
125
apps/website/src/components/molecules/Location.svelte
Normal 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>
|
||||
33
apps/website/src/components/molecules/NewsletterIssue.svelte
Normal file
33
apps/website/src/components/molecules/NewsletterIssue.svelte
Normal 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>
|
||||
@@ -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>
|
||||
23
apps/website/src/components/molecules/Pagination.svelte
Normal file
23
apps/website/src/components/molecules/Pagination.svelte
Normal 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>
|
||||
70
apps/website/src/components/molecules/PhotoCard.svelte
Normal file
70
apps/website/src/components/molecules/PhotoCard.svelte
Normal 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>
|
||||
48
apps/website/src/components/molecules/PostCard.svelte
Normal file
48
apps/website/src/components/molecules/PostCard.svelte
Normal 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>
|
||||
50
apps/website/src/components/molecules/Poster.svelte
Normal file
50
apps/website/src/components/molecules/Poster.svelte
Normal 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>
|
||||
51
apps/website/src/components/molecules/ProcessStep.svelte
Normal file
51
apps/website/src/components/molecules/ProcessStep.svelte
Normal 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>
|
||||
51
apps/website/src/components/molecules/Select.svelte
Normal file
51
apps/website/src/components/molecules/Select.svelte
Normal 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>
|
||||
@@ -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>
|
||||
73
apps/website/src/components/molecules/Switcher.svelte
Normal file
73
apps/website/src/components/molecules/Switcher.svelte
Normal 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>
|
||||
42
apps/website/src/components/organisms/Banner.svelte
Normal file
42
apps/website/src/components/organisms/Banner.svelte
Normal 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>
|
||||
129
apps/website/src/components/organisms/Carousel.svelte
Normal file
129
apps/website/src/components/organisms/Carousel.svelte
Normal 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>
|
||||
155
apps/website/src/components/organisms/Cart.svelte
Normal file
155
apps/website/src/components/organisms/Cart.svelte
Normal 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}
|
||||
29
apps/website/src/components/organisms/Collage.svelte
Normal file
29
apps/website/src/components/organisms/Collage.svelte
Normal 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}
|
||||
45
apps/website/src/components/organisms/Footer.svelte
Normal file
45
apps/website/src/components/organisms/Footer.svelte
Normal 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>
|
||||
157
apps/website/src/components/organisms/InteractiveGlobe.svelte
Normal file
157
apps/website/src/components/organisms/InteractiveGlobe.svelte
Normal 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>
|
||||
24
apps/website/src/components/organisms/ListCTAs.svelte
Normal file
24
apps/website/src/components/organisms/ListCTAs.svelte
Normal 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>
|
||||
92
apps/website/src/components/organisms/Locations.svelte
Normal file
92
apps/website/src/components/organisms/Locations.svelte
Normal 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>
|
||||
@@ -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>
|
||||
120
apps/website/src/components/organisms/PostersGrid.svelte
Normal file
120
apps/website/src/components/organisms/PostersGrid.svelte
Normal 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>
|
||||
166
apps/website/src/components/organisms/ShopBanner.svelte
Normal file
166
apps/website/src/components/organisms/ShopBanner.svelte
Normal 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>
|
||||
93
apps/website/src/components/organisms/ShopModule.svelte
Normal file
93
apps/website/src/components/organisms/ShopModule.svelte
Normal 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>
|
||||
20
apps/website/src/modules/globe/frag.glsl
Normal file
20
apps/website/src/modules/globe/frag.glsl
Normal file
@@ -0,0 +1,20 @@
|
||||
precision highp float;
|
||||
|
||||
varying vec3 vNormal;
|
||||
uniform sampler2D map;
|
||||
uniform sampler2D mapDark;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vSunDir;
|
||||
|
||||
|
||||
void main() {
|
||||
float cosineAngleSunToNormal = dot(normalize(vNormal), normalize(vSunDir));
|
||||
cosineAngleSunToNormal = clamp(cosineAngleSunToNormal * 1.0, -1.0, 1.0);
|
||||
|
||||
float mixAmount = cosineAngleSunToNormal * 0.666 + 0.333;
|
||||
vec3 dayColor = texture2D(map, vUv).rgb;
|
||||
vec3 nightColor = texture2D(mapDark, vUv).rgb;
|
||||
vec3 color = mix(nightColor, dayColor, mixAmount);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
410
apps/website/src/modules/globe/index.ts
Normal file
410
apps/website/src/modules/globe/index.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
// @ts-nocheck
|
||||
import { Renderer, Camera, Vec3, Orbit, Sphere, Transform, Program, Mesh, Texture } from 'ogl'
|
||||
// Shaders
|
||||
import VERTEX_SHADER from '$modules/globe/vertex.glsl?raw'
|
||||
import FRAGMENT_SHADER from '$modules/globe/frag.glsl?raw'
|
||||
|
||||
|
||||
export class Globe {
|
||||
constructor (options: Options) {
|
||||
// Options
|
||||
this.options = options
|
||||
this.el = options.el
|
||||
this.parent = options.parent
|
||||
this.width = this.el.offsetWidth
|
||||
this.height = this.el.offsetHeight
|
||||
this.markers = options.markers || []
|
||||
this.zoom = 1.3075
|
||||
|
||||
// Calculate the current sun position from a given location
|
||||
// const locations = [
|
||||
// {
|
||||
// lat: -37.840935,
|
||||
// lng: 144.946457,
|
||||
// tz: 'Australia/Melbourne',
|
||||
// },
|
||||
// {
|
||||
// lat: 48.856614,
|
||||
// lng: 2.3522219,
|
||||
// tz: 'Europe/Paris',
|
||||
// }
|
||||
// ]
|
||||
// const location = locations[1]
|
||||
// const localDate = new Date(new Date().toLocaleString('en-US', { timeZone: location.tz }))
|
||||
|
||||
// Parameters
|
||||
this.params = {
|
||||
autoRotate: options.autoRotate,
|
||||
speed: options.speed,
|
||||
enableMarkers: options.enableMarkers,
|
||||
enableMarkersLinks: options.enableMarkersLinks,
|
||||
sunAngle: options.sunAngle || 0,
|
||||
sunAngleDelta: 1.8,
|
||||
}
|
||||
|
||||
// Misc
|
||||
this.isDev = import.meta.env.DEV
|
||||
this.hoveringMarker = false
|
||||
this.hoveringMarkerTimeout = 0
|
||||
this.lastFrame = now()
|
||||
this.dragging = false
|
||||
this.webgl = WebGLSupport() !== null
|
||||
this.pane = undefined
|
||||
|
||||
// Run globe after check for WebGL support
|
||||
if (this.webgl) {
|
||||
this.build()
|
||||
this.resize()
|
||||
}
|
||||
|
||||
// Add GUI panel if activated
|
||||
if (this.options.pane) {
|
||||
import('./pane').then(({ createPane }) => {
|
||||
createPane(this)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build scene
|
||||
*/
|
||||
build () {
|
||||
// Create renderer
|
||||
this.renderer = new Renderer({
|
||||
dpr: this.options.dpr || 1,
|
||||
alpha: true,
|
||||
premultiplyAlpha: false,
|
||||
antialias: this.options.antialias || true,
|
||||
})
|
||||
this.gl = this.renderer.gl
|
||||
|
||||
// Create camera
|
||||
this.camera = new Camera(this.gl)
|
||||
this.camera.position.set(setFromSphericalCoords(
|
||||
this.zoom,
|
||||
degToRad(this.options.rotationStart.y || 40), // phi: y
|
||||
degToRad(this.options.rotationStart.x || 0), // theta: x
|
||||
))
|
||||
this.camera.lookAt(0,0,0)
|
||||
|
||||
// Create controls
|
||||
this.controls = new Orbit(this.camera, {
|
||||
element: this.el,
|
||||
enableZoom: false,
|
||||
enablePan: false,
|
||||
ease: 0.2,
|
||||
minPolarAngle: Math.PI / 4,
|
||||
maxPolarAngle: Math.PI / 1.85,
|
||||
})
|
||||
|
||||
// Append canvas to scene
|
||||
this.el.appendChild(this.gl.canvas)
|
||||
|
||||
// Create scene and geometry
|
||||
this.scene = new Transform()
|
||||
this.geometry = new Sphere(this.gl, {
|
||||
widthSegments: 75,
|
||||
heightSegments: 75,
|
||||
})
|
||||
|
||||
// Add map texture
|
||||
const mapWorld = new Texture(this.gl)
|
||||
const img = new Image()
|
||||
img.onload = () => (mapWorld.image = img)
|
||||
img.src = this.options.mapFile
|
||||
|
||||
// Dark map texture
|
||||
const mapDark = new Texture(this.gl)
|
||||
const imgDark = new Image()
|
||||
imgDark.onload = () => (mapDark.image = imgDark)
|
||||
imgDark.src = this.options.mapFileDark
|
||||
|
||||
// Create light
|
||||
const lightD = degToRad(7 * 360 / 24)
|
||||
const sunPosition = new Vec3(
|
||||
Math.cos(lightD),
|
||||
Math.sin(lightD) * Math.sin(0),
|
||||
Math.sin(lightD) * Math.cos(0)
|
||||
)
|
||||
|
||||
// Create program
|
||||
const program = new Program(this.gl, {
|
||||
vertex: VERTEX_SHADER,
|
||||
fragment: FRAGMENT_SHADER,
|
||||
uniforms: {
|
||||
u_dt: { value: 0 },
|
||||
map: { value: mapWorld }, // Map Texture
|
||||
mapDark: { value: mapDark }, // Map Dark Texture
|
||||
sunPosition: { value: sunPosition },
|
||||
},
|
||||
cullFace: null,
|
||||
})
|
||||
|
||||
// Create globe mesh
|
||||
this.globe = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
program,
|
||||
})
|
||||
this.globe.setParent(this.scene)
|
||||
|
||||
// Add events
|
||||
this.addEvents()
|
||||
|
||||
// Setup markers
|
||||
if (this.markers) {
|
||||
this.setupMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add events
|
||||
*/
|
||||
addEvents () {
|
||||
// When clicking on globe
|
||||
this.gl.canvas.addEventListener('mousedown', () => {
|
||||
this.dragging = true
|
||||
this.gl.canvas.classList.add('is-grabbing')
|
||||
}, false)
|
||||
|
||||
// When releasing globe click
|
||||
this.gl.canvas.addEventListener('mouseup', () => {
|
||||
this.dragging = false
|
||||
this.gl.canvas.classList.remove('is-grabbing')
|
||||
}, false)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Markers
|
||||
*/
|
||||
// Get marker from DOM element
|
||||
getMarker (id: string) {
|
||||
const marker = this.parent.querySelector(`[data-location="${id}"]`)
|
||||
if (marker) {
|
||||
return marker
|
||||
}
|
||||
}
|
||||
|
||||
// Setup markers
|
||||
setupMarkers () {
|
||||
this.markers.forEach((marker: Marker) => {
|
||||
const markerEl = this.getMarker(marker.slug)
|
||||
|
||||
// Update marker position
|
||||
this.updateMarkerPosition(marker, markerEl)
|
||||
|
||||
// Entering marker
|
||||
markerEl.addEventListener('mouseenter', () => {
|
||||
this.hoveringMarker = true
|
||||
clearTimeout(this.hoveringMarkerTimeout)
|
||||
}, false)
|
||||
|
||||
// Leaving marker
|
||||
markerEl.addEventListener('mouseleave', () => {
|
||||
this.hoveringMarkerTimeout = setTimeout(() => {
|
||||
this.hoveringMarker = false
|
||||
}, 300)
|
||||
}, false)
|
||||
|
||||
return marker
|
||||
})
|
||||
}
|
||||
|
||||
// Update marker position
|
||||
updateMarkerPosition (marker: Marker, markerEl: HTMLElement) {
|
||||
// Get vec3 position from lat/long
|
||||
const position = latLonToVec3(marker.lat, marker.lng)
|
||||
const screenVector = new Vec3(position.x, position.y, position.z)
|
||||
// Apply transformation to marker from globe world matrix
|
||||
screenVector.applyMatrix4(this.globe.worldMatrix)
|
||||
// Then project marker on camera
|
||||
this.camera.project(screenVector)
|
||||
|
||||
// Position marker
|
||||
const posX = ((screenVector[0] + 1) / 2) * this.width
|
||||
const posY = (1. - (screenVector[1] + 1) / 2) * this.height
|
||||
markerEl.style.transform = `translate3d(${posX}px, ${posY}px, 0)`
|
||||
|
||||
// Hide marker if behind globe
|
||||
markerEl.classList.toggle('is-hidden', screenVector[2] > 0.82)
|
||||
}
|
||||
|
||||
// Update markers
|
||||
updateMarkers () {
|
||||
this.markers.forEach((marker: Marker) => {
|
||||
const markerEl = this.getMarker(marker.slug)
|
||||
// Update marker position
|
||||
this.updateMarkerPosition(marker, markerEl)
|
||||
})
|
||||
}
|
||||
|
||||
// Enable or disable markers
|
||||
enableMarkers (state: boolean) {
|
||||
this.markers.forEach((marker: Marker) => {
|
||||
const markerEl = this.getMarker(marker.slug)
|
||||
markerEl.classList.toggle('is-disabled', !state)
|
||||
})
|
||||
}
|
||||
|
||||
// Hide markers
|
||||
hideMarkers () {
|
||||
this.markers.forEach((marker: Marker) => {
|
||||
const markerEl = this.getMarker(marker.slug)
|
||||
markerEl.classList.add('is-hidden')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resize method
|
||||
*/
|
||||
resize () {
|
||||
if (this.renderer) {
|
||||
this.width = this.el.offsetWidth
|
||||
this.height = this.el.offsetHeight
|
||||
this.renderer.setSize(this.width, this.height)
|
||||
this.camera.perspective({
|
||||
aspect: this.gl.canvas.width / this.gl.canvas.height
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update method
|
||||
*/
|
||||
render () {
|
||||
const delta = (now() - this.lastFrame) / 1000
|
||||
this.lastFrame = now()
|
||||
|
||||
// Rotate globe if not dragging neither hovering marker
|
||||
if (this.params.autoRotate && !this.hoveringMarker) {
|
||||
this.globe.rotation.y += this.params.speed * delta
|
||||
}
|
||||
|
||||
// Update controls and renderer
|
||||
this.controls.update()
|
||||
this.renderer.render({
|
||||
scene: this.scene,
|
||||
camera: this.camera,
|
||||
})
|
||||
|
||||
// Update markers
|
||||
if (this.params.enableMarkers) {
|
||||
this.updateMarkers()
|
||||
// Enable or disable interactivity
|
||||
this.enableMarkers(this.params.enableMarkersLinks)
|
||||
} else {
|
||||
this.hideMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Destroy
|
||||
*/
|
||||
destroy () {
|
||||
this.gl = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
this.globe = null
|
||||
this.renderer = null
|
||||
this.controls.remove()
|
||||
|
||||
if (this.pane) {
|
||||
this.pane.dispose()
|
||||
}
|
||||
if (this.isDev) {
|
||||
console.log('globe: destroy')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
type Options = {
|
||||
el: HTMLElement
|
||||
parent: HTMLElement
|
||||
mapFile: string
|
||||
mapFileDark: string
|
||||
dpr: number
|
||||
autoRotate: boolean
|
||||
speed: number
|
||||
sunAngle: number
|
||||
rotationStart?: { x: number, y: number }
|
||||
enableMarkers?: boolean
|
||||
enableMarkersLinks?: boolean
|
||||
markers?: any[]
|
||||
pane?: boolean
|
||||
}
|
||||
export type Marker = {
|
||||
name: string
|
||||
slug: string
|
||||
country: {
|
||||
name: string
|
||||
slug: string
|
||||
flag: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
HELPERS
|
||||
========================================================================== */
|
||||
/**
|
||||
* Detect WebGL support
|
||||
*/
|
||||
function WebGLSupport () {
|
||||
try {
|
||||
var canvas = document.createElement('canvas')
|
||||
return !!window.WebGLRenderingContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lng to Vec3
|
||||
*/
|
||||
const latLonToVec3 = (lat: number, lng: number) => {
|
||||
const phi = (90 - lat) * (Math.PI / 180)
|
||||
const theta = (lng + 180) * (Math.PI / 180)
|
||||
|
||||
const x = -((0.5) * Math.sin(phi) * Math.cos(theta))
|
||||
const z = ((0.5) * Math.sin(phi) * Math.sin(theta))
|
||||
const y = ((0.5) * Math.cos(phi))
|
||||
|
||||
return new Vec3(x,y,z)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get position from spherical coordinates
|
||||
*/
|
||||
const setFromSphericalCoords = (radius: number, phi: number, theta: number) => {
|
||||
const sinPhiRadius = Math.sin(phi) * radius
|
||||
const x = sinPhiRadius * Math.sin(theta)
|
||||
const y = Math.cos(phi) * radius
|
||||
const z = sinPhiRadius * Math.cos(theta)
|
||||
|
||||
return new Vec3(x,y,z)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Degrees to Radians
|
||||
*/
|
||||
const degToRad = (deg: number) => deg * Math.PI / 180
|
||||
|
||||
|
||||
/**
|
||||
* Get current timestamp (performance or Date)
|
||||
*/
|
||||
const now = () => (typeof performance === 'undefined' ? Date : performance).now()
|
||||
55
apps/website/src/modules/globe/pane.ts
Normal file
55
apps/website/src/modules/globe/pane.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Pane } from 'tweakpane'
|
||||
|
||||
export const createPane = (ctx: any) => {
|
||||
ctx.pane = new Pane({
|
||||
container: ctx.parent,
|
||||
title: 'Globe Settings',
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Rotation
|
||||
*/
|
||||
const rotation = ctx.pane.addFolder({
|
||||
title: 'Rotation',
|
||||
})
|
||||
rotation.addInput(ctx.params, 'autoRotate', {
|
||||
label: 'Auto-rotate',
|
||||
})
|
||||
rotation.addInput(ctx.params, 'speed', {
|
||||
label: 'Rotation speed',
|
||||
min: 0.01,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Markers
|
||||
*/
|
||||
if (ctx.markers && ctx.markers.length > 0) {
|
||||
const markers = ctx.pane.addFolder({
|
||||
title: 'Markers',
|
||||
})
|
||||
markers.addInput(ctx.params, 'enableMarkers', {
|
||||
label: 'Enable markers',
|
||||
})
|
||||
markers.addInput(ctx.params, 'enableMarkersLinks', {
|
||||
label: 'Interactive',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Others
|
||||
*/
|
||||
const misc = ctx.pane.addFolder({
|
||||
title: 'Misc',
|
||||
})
|
||||
// Sun position
|
||||
misc.addInput(ctx.params, 'sunAngleDelta', {
|
||||
label: 'Sun angle delta',
|
||||
min: 0,
|
||||
max: 2 * Math.PI,
|
||||
})
|
||||
}
|
||||
27
apps/website/src/modules/globe/vertex.glsl
Normal file
27
apps/website/src/modules/globe/vertex.glsl
Normal file
@@ -0,0 +1,27 @@
|
||||
varying vec3 vNormal;
|
||||
|
||||
attribute vec2 uv;
|
||||
attribute vec3 position;
|
||||
attribute vec3 normal;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
uniform vec3 sunPosition;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vSunDir;
|
||||
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
float px = sunPosition.x;
|
||||
float py = sunPosition.y;
|
||||
float pz = sunPosition.z;
|
||||
vec3 uLightPos = vec3(px, py, pz);
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
|
||||
vNormal = normalMatrix * normal;
|
||||
vSunDir = mat3(normalMatrix) * uLightPos;
|
||||
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
43
apps/website/src/routes/(shop)/shop/+error.svelte
Normal file
43
apps/website/src/routes/(shop)/shop/+error.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import ShopHeader from '$components/organisms/ShopBanner.svelte'
|
||||
import PostersGrid from '$components/organisms/PostersGrid.svelte'
|
||||
|
||||
const { posters }: any = getContext('shop')
|
||||
const errors = {
|
||||
404: {
|
||||
title: 'Product not found',
|
||||
message: 'The product you are looking for does not exist… yet!',
|
||||
},
|
||||
500: {
|
||||
title: 'Server error',
|
||||
message: "That is embarassing, the problem is on our side.",
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="{errors[$page.status].title} – Houses Of"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="shop-page">
|
||||
<ShopHeader />
|
||||
|
||||
<section class="shop-page__error">
|
||||
<div class="container grid">
|
||||
<div class="inner">
|
||||
<h2 class="title-big">Uh oh!</h2>
|
||||
<p class="text-medium">{errors[$page.status].message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PostersGrid {posters} />
|
||||
</main>
|
||||
</PageTransition>
|
||||
78
apps/website/src/routes/(shop)/shop/+layout.server.ts
Normal file
78
apps/website/src/routes/(shop)/shop/+layout.server.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { fetchSwell } from '$utils/functions/shopServer'
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
try {
|
||||
// Get content from API
|
||||
const res = await fetchAPI(`query {
|
||||
shop {
|
||||
page_heroimage { id }
|
||||
}
|
||||
|
||||
location (
|
||||
filter: {
|
||||
has_poster: { _eq: true },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "name"
|
||||
) {
|
||||
name
|
||||
slug
|
||||
}
|
||||
|
||||
posters: product (
|
||||
filter: { status: { _eq: "published" }}
|
||||
) {
|
||||
name
|
||||
type
|
||||
description
|
||||
details
|
||||
location {
|
||||
name
|
||||
slug
|
||||
}
|
||||
product_id
|
||||
photos_product {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
photos_preview {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
seo_image_shop { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
const { data: { shop, location, posters, settings }} = res
|
||||
|
||||
|
||||
/**
|
||||
* Get products data from Swell
|
||||
*/
|
||||
const shopProducts: any = await fetchSwell('/products', {
|
||||
category: 'posters',
|
||||
})
|
||||
|
||||
if (shopProducts) {
|
||||
return {
|
||||
shop,
|
||||
locations: location,
|
||||
posters,
|
||||
shopProducts: shopProducts.results,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
50
apps/website/src/routes/(shop)/shop/+layout.svelte
Normal file
50
apps/website/src/routes/(shop)/shop/+layout.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/shop";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { setContext } from 'svelte'
|
||||
import { cartNotifications } from '$utils/stores/shop'
|
||||
// Components
|
||||
import Cart from '$components/organisms/Cart.svelte'
|
||||
import NotificationCart from '$components/molecules/NotificationCart.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { shop, locations, posters, shopProducts, settings } = data
|
||||
|
||||
let scrollY: number
|
||||
|
||||
// Locations with an existing poster product
|
||||
const shopLocations = locations.filter(({ slug }: any) => {
|
||||
if (posters.find((poster: any) => poster.location.slug === slug)) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
setContext('shop', {
|
||||
shop,
|
||||
posters,
|
||||
shopLocations,
|
||||
shopProducts,
|
||||
settings,
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
|
||||
<Cart />
|
||||
|
||||
<div class="notifications" class:is-top={scrollY <= 100}>
|
||||
{#each $cartNotifications as { id, title, name, image } (id)}
|
||||
<NotificationCart
|
||||
title={title}
|
||||
name={name}
|
||||
image={image}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
56
apps/website/src/routes/(shop)/shop/+page.server.ts
Normal file
56
apps/website/src/routes/(shop)/shop/+page.server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { getRandomItem } from '$utils/functions'
|
||||
import { fetchSwell } from '$utils/functions/shopServer'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
// Get content from API
|
||||
const data = await fetchAPI(`query {
|
||||
posters: product (
|
||||
filter: { status: { _eq: "published" }}
|
||||
) {
|
||||
name
|
||||
type
|
||||
description
|
||||
details
|
||||
location {
|
||||
name
|
||||
slug
|
||||
}
|
||||
product_id
|
||||
photos_product {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
photos_preview {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (data) {
|
||||
const randomPoster: any = getRandomItem(data.data.posters)
|
||||
|
||||
// Fetch Swell API for product
|
||||
const shopProduct: any = await fetchSwell(`/products/${randomPoster.product_id}`)
|
||||
|
||||
if (shopProduct) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=86399' })
|
||||
|
||||
return {
|
||||
product: randomPoster,
|
||||
shopProduct,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
40
apps/website/src/routes/(shop)/shop/+page.svelte
Normal file
40
apps/website/src/routes/(shop)/shop/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { getContext } from 'svelte'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { shopCurrentProductSlug } from '$utils/stores/shop'
|
||||
// Components
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PostersGrid from '$components/organisms/PostersGrid.svelte'
|
||||
import ShopHeader from '$components/organisms/ShopBanner.svelte'
|
||||
import PosterLayout from '$components/layouts/PosterLayout.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { product, shopProduct }: { product: any, shopProduct: any } = data
|
||||
const { posters, settings }: any = getContext('shop')
|
||||
|
||||
// Update current random product slug
|
||||
$shopCurrentProductSlug = product.location.slug
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Shop – Houses Of"
|
||||
description="Welcome to the Houses Of Shop, discover our original series of graphic posters made for lovers of design, travel and photography"
|
||||
image={getAssetUrlKey(settings.seo_image_shop.id, 'share-image')}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="shop-page">
|
||||
<ShopHeader {product} />
|
||||
|
||||
<PosterLayout
|
||||
product={product}
|
||||
shopProduct={shopProduct}
|
||||
/>
|
||||
|
||||
<PostersGrid {posters} />
|
||||
</main>
|
||||
</PageTransition>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { fetchSwell } from '$utils/functions/shopServer'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
try {
|
||||
// Get content from API
|
||||
const data = await fetchAPI(`query {
|
||||
poster: product (search: "${params.name}") {
|
||||
name
|
||||
type
|
||||
description
|
||||
details
|
||||
location {
|
||||
name
|
||||
slug
|
||||
}
|
||||
product_id
|
||||
photos_product {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
photos_preview {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (data) {
|
||||
const poster = data.data.poster[0]
|
||||
|
||||
// Fetch Swell API for product
|
||||
const shopProduct: any = await fetchSwell(`/products/${poster.product_id}`)
|
||||
|
||||
if (shopProduct) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
product: poster,
|
||||
shopProduct,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(404)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { getContext } from 'svelte'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { shopCurrentProductSlug } from '$utils/stores/shop'
|
||||
import { capitalizeFirstLetter } from '$utils/functions'
|
||||
// Components
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import ShopHeader from '$components/organisms/ShopBanner.svelte'
|
||||
import PostersGrid from '$components/organisms/PostersGrid.svelte'
|
||||
import PosterLayout from '$components/layouts/PosterLayout.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { posters }: any = getContext('shop')
|
||||
|
||||
$shopCurrentProductSlug = data.product.location.slug
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="{data.product.location.name} {capitalizeFirstLetter(data.product.type)} – Houses Of"
|
||||
description={data.product.description}
|
||||
image={getAssetUrlKey(data.product.photos_product[2].directus_files_id.id, 'share-image')}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="shop-page">
|
||||
<ShopHeader product={data.product} />
|
||||
|
||||
<PosterLayout
|
||||
product={data.product}
|
||||
shopProduct={data.shopProduct}
|
||||
/>
|
||||
|
||||
<PostersGrid {posters} />
|
||||
</main>
|
||||
</PageTransition>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { PUBLIC_LIST_AMOUNT } from '$env/static/public'
|
||||
import { fetchAPI, photoFields } from '$utils/api'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
try {
|
||||
const { location: slug } = params
|
||||
|
||||
// Query
|
||||
const res = await fetchAPI(`query {
|
||||
location (
|
||||
filter: {
|
||||
slug: { _eq: "${slug}" },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
date_updated
|
||||
illustration_desktop { id }
|
||||
illustration_desktop_2x { id }
|
||||
illustration_mobile { id }
|
||||
credits {
|
||||
credit_id {
|
||||
name
|
||||
website
|
||||
}
|
||||
}
|
||||
country {
|
||||
name
|
||||
slug
|
||||
flag { id }
|
||||
}
|
||||
has_poster
|
||||
acknowledgement
|
||||
}
|
||||
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${slug}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${PUBLIC_LIST_AMOUNT},
|
||||
page: 1,
|
||||
) {
|
||||
${photoFields}
|
||||
}
|
||||
|
||||
# Total
|
||||
total_published: photo_aggregated (filter: { location: { slug: { _eq: "${slug}" }}}) {
|
||||
count { location }
|
||||
}
|
||||
|
||||
# Shop product
|
||||
product (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${slug}" }},
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
) {
|
||||
photos_product {
|
||||
directus_files_id {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
const { data: { location: location, photos, total_published, product }} = res
|
||||
|
||||
if (!location.length || location.length && params.country !== location[0].country.slug) {
|
||||
throw error(404, "This location is not available… yet!")
|
||||
}
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
location: location[0],
|
||||
photos,
|
||||
totalPhotos: photos.length ? total_published[0].count.location : 0,
|
||||
product: product[0],
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
368
apps/website/src/routes/(site)/[country]/[location]/+page.svelte
Normal file
368
apps/website/src/routes/(site)/[country]/[location]/+page.svelte
Normal file
@@ -0,0 +1,368 @@
|
||||
<style lang="scss">
|
||||
@import "../../../../style/pages/location";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { quartOut } from '$animations/easings'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { seenLocations } from '$utils/stores'
|
||||
import { photoFields } from '$utils/api'
|
||||
import { PUBLIC_LIST_INCREMENT } from '$env/static/public'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Button from '$components/atoms/Button.svelte'
|
||||
import IconEarth from '$components/atoms/IconEarth.svelte'
|
||||
import House from '$components/molecules/House.svelte'
|
||||
import Pagination from '$components/molecules/Pagination.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let { photos, totalPhotos }: { photos: any[], totalPhotos: number } = data
|
||||
$: ({ photos, totalPhotos } = data)
|
||||
const { location, product = undefined }: { location: any, totalPhotos: number, product: any } = data
|
||||
const { params } = $page
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
let introEl: HTMLElement
|
||||
let photosListEl: HTMLElement
|
||||
let scrollY: number
|
||||
let observerPhotos: IntersectionObserver
|
||||
let mutationPhotos: MutationObserver
|
||||
let currentPage = 1
|
||||
let ended: boolean
|
||||
let currentPhotosAmount: number
|
||||
let illustrationOffsetY = 0
|
||||
|
||||
$: latestPhoto = photos[0]
|
||||
$: currentPhotosAmount = photos.length
|
||||
$: ended = currentPhotosAmount === totalPhotos
|
||||
$: hasIllustration = location.illustration_desktop && location.illustration_desktop_2x && location.illustration_mobile
|
||||
|
||||
|
||||
/**
|
||||
* Load photos
|
||||
*/
|
||||
// Load more photos from CTA
|
||||
const loadMorePhotos = async () => {
|
||||
// Append more photos from API
|
||||
const newPhotos: any = await loadPhotos(currentPage + 1)
|
||||
|
||||
if (newPhotos) {
|
||||
photos = [...photos, ...newPhotos]
|
||||
|
||||
// Define actions if the number of new photos is the expected ones
|
||||
if (newPhotos.length === Number(PUBLIC_LIST_INCREMENT)) {
|
||||
// Increment the current page
|
||||
currentPage++
|
||||
}
|
||||
|
||||
// Increment the currently visible amount of photos
|
||||
currentPhotosAmount += newPhotos.length
|
||||
}
|
||||
}
|
||||
|
||||
// Load photos helper
|
||||
const loadPhotos = async (page?: number) => {
|
||||
const res = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
body: `query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${params.location}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${PUBLIC_LIST_INCREMENT},
|
||||
page: ${page},
|
||||
) {
|
||||
${photoFields}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
const { data: { photos }} = await res.json()
|
||||
|
||||
if (photos) {
|
||||
// Return new photos
|
||||
return photos
|
||||
} else {
|
||||
throw new Error('Error while loading new photos')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add parallax on illustration when scrolling
|
||||
*/
|
||||
$: if (scrollY && scrollY < introEl.offsetHeight) {
|
||||
illustrationOffsetY = scrollY * 0.1
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Define location's last seen state
|
||||
$seenLocations = JSON.stringify({
|
||||
// Add existing values
|
||||
...JSON.parse($seenLocations),
|
||||
// Add location ID with current time
|
||||
[location.id]: new Date(),
|
||||
})
|
||||
|
||||
// Photos IntersectionObserver
|
||||
observerPhotos = new IntersectionObserver(entries => {
|
||||
entries.forEach(({ isIntersecting, target }: IntersectionObserverEntry) => {
|
||||
target.classList.toggle('is-visible', isIntersecting)
|
||||
|
||||
// Run effect once
|
||||
isIntersecting && observerPhotos.unobserve(target)
|
||||
})
|
||||
}, { threshold: 0.3 })
|
||||
|
||||
// Photos MutationObserver
|
||||
if (photos.length) {
|
||||
mutationPhotos = new MutationObserver((mutationsList) => {
|
||||
// When adding new childs
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
// Observe new items
|
||||
Array.from(mutation.addedNodes)
|
||||
.filter(item => item.nodeType === Node.ELEMENT_NODE)
|
||||
.forEach((item: HTMLElement) => observerPhotos.observe(item))
|
||||
}
|
||||
}
|
||||
})
|
||||
mutationPhotos.observe(photosListEl, {
|
||||
childList: true,
|
||||
})
|
||||
|
||||
// Observe existing elements
|
||||
const existingPhotos = photosListEl.querySelectorAll('.house')
|
||||
existingPhotos.forEach(el => observerPhotos.observe(el))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Title word
|
||||
['.location-page__intro .word', {
|
||||
y: ['110%', 0],
|
||||
}, {
|
||||
at: 0.2,
|
||||
delay: stagger(0.4)
|
||||
}],
|
||||
|
||||
// Illustration
|
||||
['.location-page__illustration', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
duration: 2.4,
|
||||
}],
|
||||
|
||||
// Title of
|
||||
['.location-page__intro .of', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.95,
|
||||
duration: 1.2,
|
||||
}],
|
||||
|
||||
// Description
|
||||
['.location-page__description', {
|
||||
y: ['10%', 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.9,
|
||||
duration: 1.2,
|
||||
}]
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
|
||||
|
||||
// Destroy
|
||||
return () => {
|
||||
observerPhotos && observerPhotos.disconnect()
|
||||
mutationPhotos && mutationPhotos.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
<Metas
|
||||
title="Houses Of {location.name}"
|
||||
description="Discover {totalPhotos} beautiful homes from {location.name}, {location.country.name}"
|
||||
image={latestPhoto ? getAssetUrlKey(latestPhoto.image.id, 'share-image') : null}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="location-page">
|
||||
<section class="location-page__intro grid" bind:this={introEl}>
|
||||
<h1 class="title" class:is-short={location.name.length <= 4}>
|
||||
<span class="housesof mask">
|
||||
<strong class="word">Houses</strong>
|
||||
<span class="of">of</span>
|
||||
</span>
|
||||
<strong class="city mask">
|
||||
<span class="word">{location.name}</span>
|
||||
</strong>
|
||||
</h1>
|
||||
|
||||
<div class="location-page__description grid">
|
||||
<div class="wrap">
|
||||
<div class="text-medium">
|
||||
Houses of {location.name} {location.description ?? 'has no description yet'}
|
||||
</div>
|
||||
<div class="info">
|
||||
<p class="text-label">
|
||||
Photos by
|
||||
{#each location.credits as { credit_id: { name, website }}}
|
||||
{#if website}
|
||||
<a href={website} target="_blank" rel="noopener external">
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
<span>{name}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{#if latestPhoto}
|
||||
·
|
||||
<p class="text-label" title={dayjs(latestPhoto.date_created).format('DD/MM/YYYY, hh:mm')}>
|
||||
Updated <time datetime={dayjs(latestPhoto.date_created).format('YYYY-MM-DD')}>
|
||||
{dayjs().to(dayjs(latestPhoto.date_created))}
|
||||
</time>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ctas">
|
||||
<Button url="/locations" text="Change location" class="shadow-small">
|
||||
<IconEarth />
|
||||
</Button>
|
||||
|
||||
{#if location.has_poster}
|
||||
<Button url="/shop/poster-{location.slug}" text="Buy the poster" color="pinklight" class="shadow-small">
|
||||
<!-- <IconEarth /> -->
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasIllustration}
|
||||
<picture class="location-page__illustration" style:--parallax-y="{illustrationOffsetY}px">
|
||||
<source media="(min-width: 1200px)" srcset={getAssetUrlKey(location.illustration_desktop_2x.id, 'illustration-desktop-2x')}>
|
||||
<source media="(min-width: 768px)" srcset={getAssetUrlKey(location.illustration_desktop.id, 'illustration-desktop-1x')}>
|
||||
<img
|
||||
src={getAssetUrlKey(location.illustration_mobile.id, 'illustration-mobile')}
|
||||
width={320}
|
||||
height={824}
|
||||
alt="Illustration for {location.name}"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if photos.length}
|
||||
<section class="location-page__houses" bind:this={photosListEl} data-sveltekit-noscroll>
|
||||
{#each photos as { title, image: { id, title: alt, width, height }, slug, city, date_taken }, index}
|
||||
<House
|
||||
{title}
|
||||
photoId={id}
|
||||
photoAlt={alt}
|
||||
url="/{params.country}/{params.location}/{slug}"
|
||||
{city}
|
||||
location={location.name}
|
||||
ratio={width / height}
|
||||
date={date_taken}
|
||||
index={(totalPhotos - index < 10) ? '0' : ''}{totalPhotos - index}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="location-page__next container">
|
||||
<Pagination
|
||||
ended={ended}
|
||||
current={currentPhotosAmount}
|
||||
total={totalPhotos}
|
||||
on:click={!ended && loadMorePhotos}
|
||||
>
|
||||
{#if !ended}
|
||||
<p class="more">See more photos</p>
|
||||
{:else}
|
||||
<p>You've seen it all!</p>
|
||||
{/if}
|
||||
</Pagination>
|
||||
|
||||
{#if ended}
|
||||
<div class="grid-modules">
|
||||
<div class="container grid">
|
||||
<div class="wrap">
|
||||
{#if location.has_poster}
|
||||
<ShopModule
|
||||
title="Poster available"
|
||||
text="Houses of {location.name} is available as a poster on our shop."
|
||||
images={product.photos_product}
|
||||
textBottom={null}
|
||||
buttonText="Buy"
|
||||
url="/shop/poster-{location.slug}"
|
||||
/>
|
||||
{:else}
|
||||
<ShopModule />
|
||||
{/if}
|
||||
<NewsletterModule theme="light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if location.acknowledgement}
|
||||
<div class="acknowledgement">
|
||||
<Image
|
||||
class="flag"
|
||||
id={location.country.flag.id}
|
||||
sizeKey="square-small"
|
||||
width={32} height={32}
|
||||
alt="Flag of {location.country.name}"
|
||||
/>
|
||||
<p>{location.acknowledgement}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<div class="location-page__message">
|
||||
<p>
|
||||
No photos available for {location.name}.<br>
|
||||
Come back later!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</PageTransition>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
try {
|
||||
// Get the first photo ID
|
||||
const firstPhoto = await fetchAPI(`query {
|
||||
photo (search: "${params.photo}") {
|
||||
id
|
||||
}
|
||||
}`)
|
||||
const firstPhotoId = firstPhoto?.data?.photo[0]?.id
|
||||
|
||||
// TODO: use same request for both queries (photo.id)
|
||||
const photosBeforeFirst = await fetchAPI(`query {
|
||||
count: photo_aggregated (
|
||||
filter: {
|
||||
id: { _gt: ${firstPhotoId} },
|
||||
location: { slug: { _eq: "${params.location}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-id",
|
||||
) {
|
||||
count {
|
||||
id
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
// Define offset from the current count
|
||||
const offset = Math.max(photosBeforeFirst?.data?.count[0]?.count.id - 5, 0)
|
||||
const limit = 10
|
||||
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${params.location}" }}
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${limit},
|
||||
offset: ${offset},
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
date_taken
|
||||
image {
|
||||
id
|
||||
title
|
||||
width, height
|
||||
}
|
||||
city
|
||||
}
|
||||
|
||||
location (filter: { slug: { _eq: "${params.location}" }}) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
country {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
total_published: photo_aggregated (filter: { location: { slug: { _eq: "${params.location}" }}}) {
|
||||
count { location }
|
||||
}
|
||||
}`)
|
||||
|
||||
const { data } = res
|
||||
|
||||
if (data) {
|
||||
const currentIndex = data.photos.findIndex((photo: any) => photo.slug === params.photo)
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
photos: data.photos,
|
||||
location: data.location[0],
|
||||
currentIndex,
|
||||
countPhotos: data.total_published[0].count.location,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
<style lang="scss">
|
||||
@import "../../../../../style/pages/viewer";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { page, navigating } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount, tick } from 'svelte'
|
||||
import { fade, scale } from 'svelte/transition'
|
||||
import { quartOut } from 'svelte/easing'
|
||||
import dayjs from 'dayjs'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { previousPage } from '$utils/stores'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { throttle } from '$utils/functions'
|
||||
import { swipe } from '$utils/interactions/swipe'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import SplitText from '$components/SplitText.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Icon from '$components/atoms/Icon.svelte'
|
||||
import IconArrow from '$components/atoms/IconArrow.svelte'
|
||||
import ButtonCircle from '$components/atoms/ButtonCircle.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let { photos, currentIndex }: { photos: any[], currentIndex: number } = data
|
||||
const { location, countPhotos, limit, offset }: { location: any, countPhotos: number, limit: number, offset: number } = data
|
||||
|
||||
enum directions { PREV, NEXT }
|
||||
|
||||
let innerWidth: number
|
||||
let fullscreenEl: HTMLElement
|
||||
let globalOffset = offset
|
||||
let isLoading = false
|
||||
let isFullscreen = false
|
||||
let hasNext = offset + limit < countPhotos
|
||||
let hasPrev = offset > 0
|
||||
|
||||
// Define if we can navigate depending on loading state, existing photos and index being first or last
|
||||
$: canGoPrev = !isLoading && (hasNext || currentIndex !== photos.length - 1)
|
||||
$: canGoNext = !isLoading && (hasPrev || currentIndex !== 0)
|
||||
// Define current photo
|
||||
$: currentPhoto = photos[currentIndex]
|
||||
$: currentPhotoIndex = globalOffset + currentIndex + 1
|
||||
|
||||
// Take 7 photos in the global photos array (5 for current, 1 before first and 1 after last)
|
||||
// Start one index before the current image since the first one will be invisible
|
||||
$: sliceStart = Math.max(currentIndex - 1, 0)
|
||||
$: visiblePhotos = photos.slice(sliceStart, sliceStart + 7)
|
||||
|
||||
// Load previous photos
|
||||
$: if (browser && currentIndex === 0 && hasPrev) {
|
||||
loadPhotos(photos[0].id)
|
||||
}
|
||||
// Load next photos
|
||||
$: if (browser && currentIndex === photos.length - 5 && hasNext) {
|
||||
loadPhotos(photos[photos.length - 1].id, directions.NEXT)
|
||||
}
|
||||
|
||||
// Change URL to current photo slug
|
||||
$: if (browser && currentPhoto) {
|
||||
window.history.replaceState(null, '', $page.url.pathname.replace($page.params.photo, currentPhoto.slug))
|
||||
}
|
||||
|
||||
// Define previous URL
|
||||
$: previousUrl = $previousPage ? $previousPage : `/${location.country.slug}/${location.slug}`
|
||||
|
||||
|
||||
/**
|
||||
* Photo navigation
|
||||
*/
|
||||
// Go to next photo
|
||||
const goToNext = throttle(() => {
|
||||
canGoPrev && currentIndex++
|
||||
}, 200)
|
||||
|
||||
// Go to previous photo
|
||||
const goToPrevious = throttle(() => {
|
||||
canGoNext && (currentIndex = Math.max(currentIndex - 1, 0))
|
||||
}, 200)
|
||||
|
||||
// Close viewer and go to previous page
|
||||
const closeViewer = () => {
|
||||
goto(previousUrl, { replaceState: false, noscroll: true, keepfocus: true })
|
||||
}
|
||||
|
||||
// Enable navigation with keyboard
|
||||
const handleKeydown = ({ key, defaultPrevented }: KeyboardEvent) => {
|
||||
if (defaultPrevented) return
|
||||
switch (key) {
|
||||
case 'ArrowLeft': goToPrevious(); break;
|
||||
case 'ArrowRight': goToNext(); break;
|
||||
case 'Escape': closeViewer(); break;
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enable swipe gestures
|
||||
const handleSwipe = ({ detail }: CustomEvent<string>) => {
|
||||
// Swipe up and down on mobile/small screens
|
||||
if (innerWidth < 992) {
|
||||
switch (detail) {
|
||||
case '-y': goToNext(); break;
|
||||
case 'y': goToPrevious(); break;
|
||||
}
|
||||
}
|
||||
// Swipe left and right on larger screens
|
||||
else {
|
||||
switch (detail) {
|
||||
case '-x': goToNext(); break;
|
||||
case 'x': goToPrevious(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fullscreen for mobile
|
||||
*/
|
||||
const toggleFullscreen = async () => {
|
||||
if (innerWidth < 992) {
|
||||
isFullscreen = !isFullscreen
|
||||
|
||||
// Scroll at middle of photo
|
||||
if (isFullscreen) {
|
||||
// Wait for fullscreen children to be mounted
|
||||
await tick()
|
||||
const picture = fullscreenEl.querySelector('picture')
|
||||
const image = fullscreenEl.querySelector('img')
|
||||
picture.scrollTo((image.offsetWidth - innerWidth / 2), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load photos
|
||||
*/
|
||||
const loadPhotos = async (id: number, direction: directions = directions.PREV) => {
|
||||
// Block loading new photos if already loading
|
||||
if (isLoading) return
|
||||
|
||||
// Set state to loading
|
||||
isLoading = true
|
||||
|
||||
// Load new prev or next photos
|
||||
const isPrev = direction === directions.PREV
|
||||
const res = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
body: `query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${location.slug}" }},
|
||||
id: { _${isPrev ? 'gt' : 'lt'}: ${id} },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "${isPrev ? '' : '-'}id",
|
||||
limit: ${limit},
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
date_taken
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
city
|
||||
}
|
||||
}`,
|
||||
})
|
||||
const { data: { photos: newPhotos }} = await res.json()
|
||||
|
||||
// Not loading anymore
|
||||
isLoading = false
|
||||
|
||||
if (newPhotos) {
|
||||
// Direction: Previous
|
||||
if (direction === directions.PREV) {
|
||||
// Append new photos
|
||||
photos = [
|
||||
...newPhotos.reverse(), // Reverse array from isPrev sort
|
||||
...photos
|
||||
]
|
||||
// Increment current index by new amount of photos
|
||||
currentIndex += newPhotos.length
|
||||
// Decrement global offset by new amount of photos
|
||||
globalOffset -= newPhotos.length
|
||||
|
||||
// No more prev photos available
|
||||
if (newPhotos.length === 0) {
|
||||
hasPrev = false
|
||||
}
|
||||
}
|
||||
// Direction: Next
|
||||
else {
|
||||
// Append new photos
|
||||
photos = [
|
||||
...photos,
|
||||
...newPhotos
|
||||
]
|
||||
|
||||
// No more next photos available
|
||||
if (newPhotos.length === 0) {
|
||||
hasNext = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Error while loading new photos')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// First photo
|
||||
['.photo-page__picture.is-1', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
duration: 0.9,
|
||||
}],
|
||||
// Other photos
|
||||
['.photo-page__picture:not(.is-1)', {
|
||||
x: ['-150%', 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.1),
|
||||
opacity: { duration: 0.25 },
|
||||
}],
|
||||
|
||||
// Prev/Next buttons
|
||||
['.photo-page__controls .prev', {
|
||||
x: [-16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.45,
|
||||
}],
|
||||
['.photo-page__controls .next', {
|
||||
x: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.45,
|
||||
}],
|
||||
|
||||
// Infos
|
||||
['.photo-page__info > *', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.3)
|
||||
}],
|
||||
|
||||
// Index
|
||||
['.photo-page__index', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.6,
|
||||
delay: stagger(0.2),
|
||||
duration: 0.9,
|
||||
}],
|
||||
// Fly each number
|
||||
['.photo-page__index .char', {
|
||||
y: ['300%', 0],
|
||||
}, {
|
||||
at: 1.1,
|
||||
delay: stagger(0.2),
|
||||
duration: 1,
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth on:keydown={handleKeydown} />
|
||||
|
||||
{#if currentPhoto}
|
||||
<Metas
|
||||
title="{currentPhoto.title} - Houses Of {location.name}"
|
||||
description="Photo of a beautiful home from {location.name}, {location.country.name}"
|
||||
image={getAssetUrlKey(currentPhoto.image.id, 'share')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="photo-page">
|
||||
<div class="container grid">
|
||||
<p class="photo-page__notice text-label">Tap for fullscreen</p>
|
||||
|
||||
<ButtonCircle
|
||||
tag="a"
|
||||
url={previousUrl}
|
||||
color="purple"
|
||||
class="close shadow-box-dark"
|
||||
label="Close"
|
||||
>
|
||||
<svg width="12" height="12">
|
||||
<use xlink:href="#cross">
|
||||
</svg>
|
||||
</ButtonCircle>
|
||||
|
||||
<div class="photo-page__carousel">
|
||||
<div class="photo-page__images" use:swipe on:swipe={handleSwipe} on:tap={toggleFullscreen}>
|
||||
{#each visiblePhotos as { id, image, title }, index (id)}
|
||||
<div class="photo-page__picture is-{currentIndex === 0 ? index + 1 : index}">
|
||||
<Image
|
||||
class="photo {image.width / image.height < 1.475 ? 'not-landscape' : ''}"
|
||||
id={image.id}
|
||||
alt={title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 500 },
|
||||
medium: { width: 850 },
|
||||
large: { width: 1280 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="photo-page__controls">
|
||||
<ButtonCircle class="prev shadow-box-dark" label="Previous" disabled={!canGoNext} clone={true} on:click={goToPrevious}>
|
||||
<IconArrow color="pink" flip={true} />
|
||||
</ButtonCircle>
|
||||
<ButtonCircle class="next shadow-box-dark" label="Next" disabled={!canGoPrev} clone={true} on:click={goToNext}>
|
||||
<IconArrow color="pink" />
|
||||
</ButtonCircle>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="photo-page__index title-index">
|
||||
<SplitText text="{(currentPhotoIndex < 10) ? '0' : ''}{currentPhotoIndex}" mode="chars" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="photo-page__info">
|
||||
<h1 class="title-medium">{currentPhoto.title}</h1>
|
||||
|
||||
<div class="detail text-info">
|
||||
<a href="/{location.country.slug}/{location.slug}" data-sveltekit-noscroll>
|
||||
<Icon class="icon" icon="map-pin" label="Map pin" />
|
||||
<span>
|
||||
{#if currentPhoto.city}
|
||||
{currentPhoto.city}, {location.name}, {location.country.name}
|
||||
{:else}
|
||||
{location.name}, {location.country.name}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{#if currentPhoto.date_taken}
|
||||
<span class="sep">·</span>
|
||||
<time datetime={dayjs(currentPhoto.date_taken).format('YYYY-MM-DD')}>{dayjs(currentPhoto.date_taken).format('MMMM YYYY')}</time>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isFullscreen}
|
||||
<div class="photo-page__fullscreen" bind:this={fullscreenEl} on:click={toggleFullscreen}
|
||||
in:fade={{ easing: quartOut, duration: 1000 }}
|
||||
out:fade={{ easing: quartOut, duration: 1000, delay: 300 }}
|
||||
>
|
||||
<div class="inner" transition:scale={{ easing: quartOut, start: 1.1, duration: 1000 }}>
|
||||
<Image
|
||||
id={currentPhoto.image.id}
|
||||
sizeKey="photo-grid-large"
|
||||
width={1266}
|
||||
height={844}
|
||||
alt={currentPhoto.title}
|
||||
/>
|
||||
<ButtonCircle color="gray-medium" class="close">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="#fff" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.751 0c4.274 0 7.752 3.477 7.752 7.751 0 1.846-.65 3.543-1.73 4.875l3.99 3.991a.81.81 0 1 1-1.146 1.146l-3.99-3.991a7.714 7.714 0 0 1-4.876 1.73C3.477 15.503 0 12.027 0 7.753 0 3.476 3.477 0 7.751 0Zm0 1.62a6.138 6.138 0 0 0-6.13 6.131 6.138 6.138 0 0 0 6.13 6.132 6.138 6.138 0 0 0 6.131-6.132c0-3.38-2.75-6.13-6.13-6.13Zm2.38 5.321a.81.81 0 1 1 0 1.62h-4.76a.81.81 0 1 1 0-1.62h4.76Z" />
|
||||
</svg>
|
||||
</ButtonCircle>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</PageTransition>
|
||||
104
apps/website/src/routes/(site)/about/+page.server.ts
Normal file
104
apps/website/src/routes/(site)/about/+page.server.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { getRandomItems } from '$utils/functions'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
// Get data and total of published photos
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
favorite: { _eq: true },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
limit: -1,
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
about {
|
||||
description
|
||||
intro_title
|
||||
intro_heading
|
||||
intro_text
|
||||
intro_firstphoto { id, title }
|
||||
intro_firstphoto_caption
|
||||
intro_firstlocation {
|
||||
slug
|
||||
name
|
||||
country {
|
||||
flag { id, title }
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
creation_title
|
||||
creation_heading
|
||||
creation_text
|
||||
creation_portrait { id, title }
|
||||
creation_portrait_caption
|
||||
|
||||
present_image { id, title }
|
||||
present_title
|
||||
present_heading
|
||||
present_text
|
||||
present_conclusion
|
||||
|
||||
image_showcase { id, title }
|
||||
|
||||
process_title
|
||||
process_subtitle
|
||||
process_steps {
|
||||
title
|
||||
text
|
||||
media_type
|
||||
image {
|
||||
id
|
||||
title
|
||||
width, height
|
||||
}
|
||||
video_mp4 { id }
|
||||
video_webm { id }
|
||||
}
|
||||
|
||||
contact_title
|
||||
contact_blocks
|
||||
|
||||
seo_title
|
||||
seo_description
|
||||
seo_image { id }
|
||||
}
|
||||
}`)
|
||||
const { data: { about, photos: photosIds }} = res
|
||||
|
||||
// Get random photos
|
||||
const randomPhotosIds = [...getRandomItems(photosIds, 42)].map(({ id }) => id)
|
||||
|
||||
// Query these random photos from IDs
|
||||
const photosRes = await fetchAPI(`query {
|
||||
photo (filter: { id: { _in: "${randomPhotosIds}" }}) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (photosRes) {
|
||||
const { data: { photo: photos }} = photosRes
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
about,
|
||||
photos,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
402
apps/website/src/routes/(site)/about/+page.svelte
Normal file
402
apps/website/src/routes/(site)/about/+page.svelte
Normal file
@@ -0,0 +1,402 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/about";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating, page } from '$app/stores'
|
||||
import { onMount, afterUpdate } from 'svelte'
|
||||
import { quartOut as quartOutSvelte } from 'svelte/easing'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import type { PageData } from './$types'
|
||||
import { animate, inView, stagger, timeline } from 'motion'
|
||||
import { mailtoClipboard, map } from '$utils/functions'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from '$animations/easings'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Button from '$components/atoms/Button.svelte'
|
||||
import AboutGridPhoto from '$components/atoms/AboutGridPhoto.svelte'
|
||||
import ProcessStep from '$components/molecules/ProcessStep.svelte'
|
||||
import Banner from '$components/organisms/Banner.svelte'
|
||||
import { sendEvent } from '$utils/analytics';
|
||||
|
||||
export let data: PageData
|
||||
const { about, photos } = data
|
||||
|
||||
let scrollY: number, innerWidth: number, innerHeight: number
|
||||
let photosGridEl: HTMLElement
|
||||
let photosGridOffset: number = photosGridEl && photosGridEl.offsetTop
|
||||
let currentStep: number = 0
|
||||
let emailCopied: string = null
|
||||
let emailCopiedTimeout: ReturnType<typeof setTimeout> | number
|
||||
|
||||
$: parallaxPhotos = photosGridEl && map(scrollY, photosGridOffset - innerHeight, photosGridOffset + innerHeight / 1.5, 0, innerHeight * 0.15, true)
|
||||
$: fadedPhotosIndexes = innerWidth > 768
|
||||
? [0, 2, 5, 7, 9, 12, 17, 20, 22, 26, 30, 32, 34]
|
||||
: [1, 4, 5, 7, 11, 14, 17, 20, 24, 27, 30, 33, 34, 36, 40, 43]
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Banner
|
||||
['.banner picture', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0.4,
|
||||
duration: 2.4,
|
||||
}],
|
||||
['.banner h1', {
|
||||
y: [32, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.5,
|
||||
}],
|
||||
['.banner__top > *', {
|
||||
y: [-100, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.25),
|
||||
}],
|
||||
|
||||
// Intro elements
|
||||
['.about__introduction .container > *', {
|
||||
y: ['20%', 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0.75,
|
||||
delay: stagger(0.25),
|
||||
}],
|
||||
['.first-photo', {
|
||||
y: ['10%', 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 1.2,
|
||||
}],
|
||||
['.first-photo img', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 1.5,
|
||||
duration: 2.4,
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Sections
|
||||
inView('[data-reveal]', ({ target }) => {
|
||||
animate(target, {
|
||||
opacity: [0, 1],
|
||||
y: ['20%', 0],
|
||||
z: 0,
|
||||
}, {
|
||||
delay: 0.2,
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
})
|
||||
})
|
||||
|
||||
// Global images
|
||||
inView('[data-reveal-image] img', ({ target }) => {
|
||||
animate(target, {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
delay: 0.3,
|
||||
duration: 2.4,
|
||||
easing: quartOut,
|
||||
})
|
||||
})
|
||||
|
||||
// Process
|
||||
const processTimeline = timeline([
|
||||
// Step links
|
||||
['.about__process li a', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0,
|
||||
delay: stagger(0.15),
|
||||
}],
|
||||
|
||||
// First step
|
||||
['.about__process .step', {
|
||||
scale: [1.1, 1],
|
||||
opacity: [0, 1],
|
||||
x: [20, 0]
|
||||
}, {
|
||||
at: 0.6,
|
||||
duration: 1,
|
||||
}]
|
||||
], {
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
}
|
||||
})
|
||||
processTimeline.stop()
|
||||
|
||||
inView('.about__process', () => {
|
||||
requestAnimationFrame(processTimeline.play)
|
||||
}, {
|
||||
amount: 0.35,
|
||||
})
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
|
||||
|
||||
afterUpdate(() => {
|
||||
// Update photos grid top offset
|
||||
photosGridOffset = photosGridEl.offsetTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY bind:innerWidth bind:innerHeight />
|
||||
|
||||
<Metas
|
||||
title={about.seo_title}
|
||||
description={about.seo_description}
|
||||
image={about.seo_image ? getAssetUrlKey(about.seo_image.id, 'share-image') : null}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="about">
|
||||
<Banner
|
||||
title="About"
|
||||
image={{
|
||||
id: '699b4050-6bbf-4a40-be53-d84aca484f9d',
|
||||
alt: 'Photo caption',
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class="about__introduction">
|
||||
<div class="container grid">
|
||||
<h2 class="title-small">{about.intro_title}</h2>
|
||||
<div class="heading text-big">
|
||||
{@html about.intro_heading}
|
||||
</div>
|
||||
|
||||
<div class="text text-small">
|
||||
{@html about.intro_text}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__creation">
|
||||
<div class="container grid">
|
||||
<figure class="first-photo">
|
||||
<Image
|
||||
class="picture shadow-box-dark"
|
||||
id={about.intro_firstphoto.id}
|
||||
alt={about.intro_firstphoto.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 600 },
|
||||
large: { width: 800 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
<figcaption class="text-info">
|
||||
{about.intro_firstphoto_caption}<br>
|
||||
in
|
||||
<a href="/{about.intro_firstlocation.country.slug}/{about.intro_firstlocation.slug}" data-sveltekit-noscroll>
|
||||
<img src="{getAssetUrlKey(about.intro_firstlocation.country.flag.id, 'square-small-jpg')}" width="32" height="32" alt="{about.intro_firstlocation.country.flag.title}">
|
||||
<span>Naarm Australia (Melbourne)</span>
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 class="title-small" data-reveal>{about.creation_title}</h2>
|
||||
<div class="heading text-huge" data-reveal>
|
||||
{@html about.creation_heading}
|
||||
</div>
|
||||
|
||||
<div class="text text-small" data-reveal>
|
||||
{@html about.creation_text}
|
||||
</div>
|
||||
|
||||
<figure class="picture portrait-photo" data-reveal-image>
|
||||
<Image
|
||||
class="shadow-box-dark"
|
||||
id={about.creation_portrait.id}
|
||||
alt={about.creation_portrait.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 750 },
|
||||
}}
|
||||
ratio={1.425}
|
||||
/>
|
||||
</figure>
|
||||
<span class="portrait-photo__caption text-info">
|
||||
{about.creation_portrait_caption}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__present">
|
||||
<div class="container grid">
|
||||
<figure class="picture" data-reveal-image>
|
||||
<Image
|
||||
class="shadow-box-dark"
|
||||
id={about.present_image.id}
|
||||
alt={about.present_image.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 600 },
|
||||
large: { width: 800 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<h2 class="title-small" data-reveal>{about.present_title}</h2>
|
||||
<div class="text text-small" data-reveal>
|
||||
<p>{about.present_text}</p>
|
||||
</div>
|
||||
|
||||
<div class="heading text-big" data-reveal>
|
||||
{@html about.present_heading}
|
||||
</div>
|
||||
|
||||
<div class="conclusion text-small" data-reveal>
|
||||
<p>{about.present_conclusion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if about.image_showcase}
|
||||
<div class="about__showcase container grid">
|
||||
<Image
|
||||
id={about.image_showcase.id}
|
||||
alt={about.image_showcase.title}
|
||||
sizeKey="showcase"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 1000 },
|
||||
large: { width: 1800 },
|
||||
}}
|
||||
ratio={1.2}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="about__process">
|
||||
<div class="container grid">
|
||||
<aside>
|
||||
<div class="heading">
|
||||
<h2 class="title-medium">{about.process_title}</h2>
|
||||
<p class="text-xsmall">{about.process_subtitle}</p>
|
||||
</div>
|
||||
|
||||
<ol>
|
||||
{#each about.process_steps as { title }, index}
|
||||
<li class:is-active={index === currentStep}>
|
||||
<a href="#step-{index + 1}" class="title-big"
|
||||
on:click|preventDefault={() => {
|
||||
currentStep = index
|
||||
sendEvent('aboutStepSwitch')
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<div class="steps">
|
||||
{#each about.process_steps as { text, image, video_mp4, video_webm }, index}
|
||||
{#if index === currentStep}
|
||||
<ProcessStep
|
||||
{index} {text}
|
||||
image={image ?? undefined}
|
||||
video={{
|
||||
mp4: video_mp4?.id,
|
||||
webm: video_webm?.id
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__photos" bind:this={photosGridEl}>
|
||||
<div class="container-wide">
|
||||
<div class="photos-grid" style:--parallax-y="{parallaxPhotos}px">
|
||||
{#each photos as { image: { id }, title }, index}
|
||||
<AboutGridPhoto class="grid-photo"
|
||||
{id}
|
||||
alt={title}
|
||||
disabled={fadedPhotosIndexes.includes(index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__interest container grid">
|
||||
<div class="container grid">
|
||||
<h2 class="title-xl">{about.contact_title}</h2>
|
||||
<div class="blocks">
|
||||
{#each about.contact_blocks as { title, text, link, button }}
|
||||
<div class="block">
|
||||
<h3 class="text-label">{title}</h3>
|
||||
<div class="text text-normal">
|
||||
{@html text}
|
||||
</div>
|
||||
<div class="button-container">
|
||||
{#if link}
|
||||
{#key emailCopied === link}
|
||||
<div class="wrap"
|
||||
in:fly={{ y: 4, duration: 325, easing: quartOutSvelte, delay: 250 }}
|
||||
out:fade={{ duration: 250, easing: quartOutSvelte }}
|
||||
use:mailtoClipboard
|
||||
on:copied={({ detail }) => {
|
||||
emailCopied = detail.email
|
||||
// Clear timeout and add timeout to hide message
|
||||
clearTimeout(emailCopiedTimeout)
|
||||
emailCopiedTimeout = setTimeout(() => emailCopied = null, 2500)
|
||||
}}
|
||||
>
|
||||
{#if emailCopied !== link}
|
||||
<Button size="small" url="mailto:{link}" text={button} />
|
||||
{:else}
|
||||
<span class="clipboard">Email copied in clipboard</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
25
apps/website/src/routes/(site)/api/data/+server.ts
Normal file
25
apps/website/src/routes/(site)/api/data/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
|
||||
export const POST: RequestHandler = async ({ request, setHeaders }) => {
|
||||
try {
|
||||
const body = await request.text()
|
||||
|
||||
if (body) {
|
||||
const req = await fetchAPI(body)
|
||||
const res = await req
|
||||
|
||||
if (res) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=59' })
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
...res
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
43
apps/website/src/routes/(site)/credits/+page.server.ts
Normal file
43
apps/website/src/routes/(site)/credits/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
credits {
|
||||
text
|
||||
list
|
||||
}
|
||||
|
||||
credit (filter: { status: { _eq: "published" }}) {
|
||||
name
|
||||
website
|
||||
location {
|
||||
location_id (filter: { status: { _eq: "published" }}) {
|
||||
name
|
||||
slug
|
||||
country {
|
||||
slug
|
||||
flag {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
...data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
149
apps/website/src/routes/(site)/credits/+page.svelte
Normal file
149
apps/website/src/routes/(site)/credits/+page.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/credits";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from 'svelte/easing'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
|
||||
export let data: PageData
|
||||
const { credit } = data
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Heading
|
||||
['.heading .text', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}],
|
||||
|
||||
// Categories
|
||||
['.credits__category', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0,
|
||||
delay: stagger(0.35, { start: 0.5 }),
|
||||
}],
|
||||
|
||||
// Names
|
||||
['.credits__category > ul > li', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 1.1,
|
||||
delay: stagger(0.35),
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Credits – Houses Of"
|
||||
description={data.credits.text}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="credits">
|
||||
<Heading
|
||||
text={data.credits.text}
|
||||
/>
|
||||
|
||||
<section class="credits__list">
|
||||
<div class="grid container">
|
||||
{#each data.credits.list as { title, credits }}
|
||||
<div class="credits__category grid">
|
||||
<h2 class="title-small">{title}</h2>
|
||||
<ul>
|
||||
{#each credits as { name, role, website }}
|
||||
<li>
|
||||
<dl>
|
||||
<dt>
|
||||
{#if website}
|
||||
<h3>
|
||||
<a href={website} rel="noopener external" target="_blank" tabindex="0">{name}</a>
|
||||
</h3>
|
||||
{:else}
|
||||
<h3>{name}</h3>
|
||||
{/if}
|
||||
</dt>
|
||||
<dd>
|
||||
{role}
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="credits__category grid">
|
||||
<h2 class="title-small">Photography</h2>
|
||||
<ul>
|
||||
{#each credit as { name, website, location }}
|
||||
<li>
|
||||
<dl>
|
||||
<dt>
|
||||
{#if website}
|
||||
<h3>
|
||||
<a href={website} rel="noopener external" target="_blank" tabindex="0">{name}</a>
|
||||
</h3>
|
||||
{:else}
|
||||
<h3>{name}</h3>
|
||||
{/if}
|
||||
</dt>
|
||||
<dd>
|
||||
<ul data-sveltekit-noscroll>
|
||||
{#each location as loc}
|
||||
{#if loc.location_id}
|
||||
<li>
|
||||
<a href="/{loc.location_id.country.slug}/{loc.location_id.slug}" tabindex="0">
|
||||
<Image
|
||||
id={loc.location_id.country.flag.id}
|
||||
sizeKey="square-small"
|
||||
width={16}
|
||||
height={16}
|
||||
alt="Flag of {loc.location_id.country.slug}"
|
||||
/>
|
||||
<span>{loc.location_id.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InteractiveGlobe type="cropped" />
|
||||
</main>
|
||||
</PageTransition>
|
||||
94
apps/website/src/routes/(site)/feed/products.xml/+server.ts
Normal file
94
apps/website/src/routes/(site)/feed/products.xml/+server.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { fetchSwell } from '$utils/functions/shopServer'
|
||||
import { fetchAPI, getAssetUrlKey } from '$utils/api'
|
||||
|
||||
const gCategories = [
|
||||
{
|
||||
id: '61851d83cd16416c78a8e5ef',
|
||||
type: 'Posters, Prints, & Visual Artwork',
|
||||
value: 'Home & Garden > Decor > Artwork > Posters, Prints, & Visual Artwork'
|
||||
}
|
||||
]
|
||||
|
||||
export const GET: RequestHandler = async ({ url, setHeaders }) => {
|
||||
try {
|
||||
const products = []
|
||||
|
||||
// Get products from Swell API
|
||||
const shopProducts: any = await fetchSwell(`/products`)
|
||||
|
||||
// Get products from site API
|
||||
const siteProducts = await fetchAPI(`query {
|
||||
products: product (filter: { status: { _eq: "published" }}) {
|
||||
location { slug }
|
||||
name
|
||||
description
|
||||
details
|
||||
product_id
|
||||
photos_product {
|
||||
directus_files_id { id }
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (shopProducts && siteProducts) {
|
||||
const { data } = siteProducts
|
||||
|
||||
// Loop through shop products
|
||||
shopProducts.results.forEach((product: any) => {
|
||||
// Find matching product from site to platform
|
||||
const siteProduct = data.products.find((p: any) => p.product_id === product.id)
|
||||
const category = gCategories.find(p => p.id === product.category_index.id[0])
|
||||
|
||||
products.push({
|
||||
id: product.id,
|
||||
name: `${product.name} - Poster`,
|
||||
slug: siteProduct.location.slug,
|
||||
description: siteProduct.description,
|
||||
price: product.price,
|
||||
images: siteProduct.photos_product.map(({ directus_files_id: { id }}: any) => getAssetUrlKey(id, `product-large-jpg`)),
|
||||
gCategory: category.value,
|
||||
gType: category.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sitemap = render(url.origin, products)
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=0, s-max-age=600',
|
||||
})
|
||||
|
||||
return new Response(sitemap)
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const render = (origin: string, products: any[]) => {
|
||||
return `<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
|
||||
<channel>
|
||||
${products.map((product) => `<item>
|
||||
<g:id>${product.id}</g:id>
|
||||
<title>${product.name}</title>
|
||||
<description>${product.description}</description>
|
||||
<g:product_type>${product.gType}</g:product_type>
|
||||
<g:google_product_category>${product.gCategory}</g:google_product_category>
|
||||
<link>${origin}/shop/poster-${product.slug}</link>
|
||||
<g:image_link>${product.images[0]}</g:image_link>
|
||||
<g:condition>New</g:condition>
|
||||
<g:availability>In Stock</g:availability>
|
||||
<g:price>${product.price} EUR</g:price>
|
||||
<g:brand>Houses Of</g:brand>
|
||||
<g:identifier_exists>FALSE</g:identifier_exists>
|
||||
${product.images.slice(1).map((image: any) => `
|
||||
<g:additional_image_link>${image}</g:additional_image_link>
|
||||
`).join('')}
|
||||
</item>
|
||||
`).join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
}
|
||||
42
apps/website/src/routes/(site)/locations/+page.svelte
Normal file
42
apps/website/src/routes/(site)/locations/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/explore";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
import Locations from '$components/organisms/Locations.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
|
||||
const { locations }: any = getContext('global')
|
||||
const text = "Explore the globe to discover unique locations across the world"
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Locations – Houses Of"
|
||||
description={text}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="explore">
|
||||
<Heading {text} />
|
||||
|
||||
<section class="explore__locations">
|
||||
<InteractiveGlobe />
|
||||
<Locations {locations} />
|
||||
</section>
|
||||
|
||||
<section class="grid-modules is-spaced grid">
|
||||
<div class="wrap">
|
||||
<ShopModule />
|
||||
<NewsletterModule />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
91
apps/website/src/routes/(site)/photos/+page.server.ts
Normal file
91
apps/website/src/routes/(site)/photos/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { PUBLIC_FILTERS_DEFAULT_COUNTRY, PUBLIC_FILTERS_DEFAULT_SORT, PUBLIC_GRID_AMOUNT } from '$env/static/public'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ url, setHeaders }) => {
|
||||
try {
|
||||
// Query parameters
|
||||
const queryCountry = url.searchParams.get('country') || PUBLIC_FILTERS_DEFAULT_COUNTRY
|
||||
const querySort = url.searchParams.get('sort') || PUBLIC_FILTERS_DEFAULT_SORT
|
||||
|
||||
// Query
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
${queryCountry !== 'all' ? `location: { country: { slug: { _eq: "${queryCountry}" }}},` : ''}
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "${querySort === 'latest' ? '-' : ''}date_created",
|
||||
limit: ${PUBLIC_GRID_AMOUNT},
|
||||
page: 1,
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
location {
|
||||
slug
|
||||
name
|
||||
region
|
||||
country {
|
||||
slug
|
||||
name
|
||||
flag { id }
|
||||
}
|
||||
}
|
||||
city
|
||||
date_created
|
||||
}
|
||||
|
||||
country: country (
|
||||
filter: {
|
||||
slug: { _eq: "${queryCountry}" },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
) {
|
||||
slug
|
||||
}
|
||||
|
||||
# Total
|
||||
total_published: photo_aggregated ${queryCountry !== 'all' ? `(
|
||||
filter: {
|
||||
location: { country: { slug: { _eq: "${queryCountry}" }}},
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
)` : `(
|
||||
filter: {
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
)`} {
|
||||
count { id }
|
||||
}
|
||||
|
||||
settings {
|
||||
seo_image_photos { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=86399' })
|
||||
|
||||
const { data } = res
|
||||
|
||||
return {
|
||||
photos: data.photos,
|
||||
filteredCountryExists: data.country.length > 0,
|
||||
totalPhotos: data.total_published[0].count.id,
|
||||
settings: data.settings,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
499
apps/website/src/routes/(site)/photos/+page.svelte
Normal file
499
apps/website/src/routes/(site)/photos/+page.svelte
Normal file
@@ -0,0 +1,499 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/photos";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { PageData } from './$types'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { quartOut as quartOutSvelte } from 'svelte/easing'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { map, lerp, throttle } from '$utils/functions'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { quartOut } from '$animations/easings'
|
||||
import { PUBLIC_FILTERS_DEFAULT_COUNTRY, PUBLIC_FILTERS_DEFAULT_SORT, PUBLIC_GRID_INCREMENT } from '$env/static/public'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import SplitText from '$components/SplitText.svelte'
|
||||
import IconEarth from '$components/atoms/IconEarth.svelte'
|
||||
import Button from '$components/atoms/Button.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import ScrollingTitle from '$components/atoms/ScrollingTitle.svelte'
|
||||
import DiscoverText from '$components/atoms/DiscoverText.svelte'
|
||||
import PostCard from '$components/molecules/PostCard.svelte'
|
||||
import Select from '$components/molecules/Select.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let { photos, totalPhotos }: { photos: any[], totalPhotos: number } = data
|
||||
$: ({ photos, totalPhotos } = data)
|
||||
const { filteredCountryExists, settings }: { filteredCountryExists: boolean, settings: any } = data
|
||||
const { countries, locations }: any = getContext('global')
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
let photosContentEl: HTMLElement
|
||||
let photosGridEl: HTMLElement
|
||||
let observerPhotos: IntersectionObserver
|
||||
let mutationPhotos: MutationObserver
|
||||
let scrollY: number
|
||||
let innerWidth: number, innerHeight: number
|
||||
// Filters related
|
||||
let scrollDirection = 0
|
||||
let lastScrollTop = 0
|
||||
let scrolledPastIntro: boolean
|
||||
let filtersOver: boolean
|
||||
let filtersVisible: boolean
|
||||
let filtersTransitioning: boolean
|
||||
|
||||
|
||||
/**
|
||||
* Filters
|
||||
*/
|
||||
const defaultCountry: string = PUBLIC_FILTERS_DEFAULT_COUNTRY
|
||||
const defaultSort: string = PUBLIC_FILTERS_DEFAULT_SORT
|
||||
const urlFiltersParams = new URLSearchParams()
|
||||
let filtered: boolean
|
||||
let filterCountry = $page.url.searchParams.get('country') || defaultCountry
|
||||
let filterSort = $page.url.searchParams.get('sort') || defaultSort
|
||||
let countryFlagId: string
|
||||
$: filtered = filterCountry !== defaultCountry || filterSort !== defaultSort
|
||||
$: latestPhoto = photos && photos[0]
|
||||
$: currentCountry = countries.find((country: any) => country.slug === filterCountry)
|
||||
|
||||
// Pages related informations
|
||||
let currentPage = 1
|
||||
let ended: boolean
|
||||
let currentPhotosAmount: number
|
||||
$: currentPhotosAmount = photos && photos.length
|
||||
$: ended = currentPhotosAmount === totalPhotos
|
||||
|
||||
|
||||
/**
|
||||
* Container margins
|
||||
*/
|
||||
let scrollProgress: number
|
||||
let sideMargins: number = innerWidth < 1200 ? 16 : 8
|
||||
$: viewportScroll = (innerHeight / innerWidth) <= 0.6 ? innerHeight * 1.5 : innerHeight
|
||||
|
||||
// Define sides margin on scroll
|
||||
const setSidesMargin = throttle(() => {
|
||||
if (window.innerWidth >= 992) {
|
||||
scrollProgress = map(scrollY, 0, viewportScroll, 0, 1, true)
|
||||
sideMargins = lerp(innerWidth < 1200 ? 16 : 8, 30, scrollProgress)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
|
||||
/**
|
||||
* Handle URL query params
|
||||
*/
|
||||
$: countryFlagId = currentCountry ? currentCountry.flag.id : undefined
|
||||
|
||||
// Update URL filtering params from filter values
|
||||
const applyFilters = () => {
|
||||
urlFiltersParams.set('country', filterCountry)
|
||||
urlFiltersParams.set('sort', filterSort)
|
||||
|
||||
let path = `${$page.url.pathname}?${urlFiltersParams.toString()}`
|
||||
goto(path, { replaceState: true, keepfocus: true, noscroll: true })
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Define small photo size from index
|
||||
* With different grid patterns depending on window width
|
||||
*/
|
||||
$: isSmall = (index: number) => {
|
||||
let modulo = index % 5
|
||||
let notOn = [0]
|
||||
|
||||
// Change pattern on small desktop
|
||||
if (innerWidth >= 768 && innerWidth < 1200) {
|
||||
modulo = index % 11
|
||||
notOn = [0, 7, 10]
|
||||
} else if (innerWidth >= 1200) {
|
||||
// Disable on larger desktop
|
||||
return false
|
||||
}
|
||||
return !notOn.includes(modulo)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filters change events
|
||||
*/
|
||||
// Country select
|
||||
const handleCountryChange = ({ detail: value }) => {
|
||||
filterCountry = value === defaultCountry ? defaultCountry : value
|
||||
currentPage = 1
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Sort select
|
||||
const handleSortChange = ({ detail: value }) => {
|
||||
filterSort = value === defaultSort ? defaultSort : value
|
||||
currentPage = 1
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Reset filters
|
||||
const resetFiltered = () => {
|
||||
filterCountry = defaultCountry
|
||||
filterSort = defaultSort
|
||||
currentPage = 1
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load photos
|
||||
*/
|
||||
// [function] Load photos helper
|
||||
const loadPhotos = async (page: number) => {
|
||||
const res = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
body: `query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
${filterCountry !== 'all' ? `location: { country: { slug: { _eq: "${filterCountry}" }} },` : ''}
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "${filterSort === 'latest' ? '-' : ''}date_created",
|
||||
limit: ${PUBLIC_GRID_INCREMENT},
|
||||
page: ${page},
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
location {
|
||||
slug
|
||||
name
|
||||
region
|
||||
country {
|
||||
slug
|
||||
name
|
||||
flag { id }
|
||||
}
|
||||
}
|
||||
city
|
||||
}
|
||||
}`,
|
||||
})
|
||||
|
||||
const { data: { photos }} = await res.json()
|
||||
|
||||
if (photos) {
|
||||
// Return new photos
|
||||
return photos
|
||||
} else {
|
||||
throw new Error('Error while loading new photos')
|
||||
}
|
||||
}
|
||||
|
||||
// Load more photos from CTA
|
||||
const loadMorePhotos = async () => {
|
||||
// Append more photos from API including options and page
|
||||
const newPhotos: any = await loadPhotos(currentPage + 1)
|
||||
|
||||
if (newPhotos) {
|
||||
photos = [...photos, ...newPhotos]
|
||||
|
||||
// Define actions if the number of new photos is the expected ones
|
||||
if (newPhotos.length === Number(PUBLIC_GRID_INCREMENT)) {
|
||||
// Increment the current page
|
||||
currentPage++
|
||||
}
|
||||
|
||||
// Increment the currently visible amount of photos
|
||||
currentPhotosAmount += newPhotos.length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scroll detection when entering content
|
||||
*/
|
||||
$: if (scrollY) {
|
||||
// Detect scroll direction
|
||||
throttle(() => {
|
||||
scrollDirection = scrollY > lastScrollTop ? 1 : -1
|
||||
lastScrollTop = scrollY
|
||||
|
||||
// Show filters bar when scrolling back up
|
||||
filtersVisible = scrollDirection < 0
|
||||
|
||||
// Scrolled past grid of photos
|
||||
if (scrollY > photosContentEl.offsetTop) {
|
||||
if (!scrolledPastIntro) {
|
||||
filtersOver = true
|
||||
// Hacky: Set filters as transitioning after a little delay to avoid an transition jump
|
||||
setTimeout(() => filtersTransitioning = true, 30)
|
||||
}
|
||||
scrolledPastIntro = true
|
||||
} else {
|
||||
if (scrolledPastIntro) {
|
||||
filtersOver = false
|
||||
filtersTransitioning = false
|
||||
}
|
||||
scrolledPastIntro = false
|
||||
}
|
||||
}, 200)()
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Observers
|
||||
*/
|
||||
// Photos IntersectionObserver
|
||||
observerPhotos = new IntersectionObserver(entries => {
|
||||
entries.forEach(({ isIntersecting, target }: IntersectionObserverEntry) => {
|
||||
target.classList.toggle('is-visible', isIntersecting)
|
||||
|
||||
// Run effect once
|
||||
isIntersecting && observerPhotos.unobserve(target)
|
||||
})
|
||||
}, {
|
||||
threshold: 0.25,
|
||||
rootMargin: '0px 0px 15%'
|
||||
})
|
||||
|
||||
// Photos MutationObserver
|
||||
mutationPhotos = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
// When adding new childs
|
||||
if (mutation.type === 'childList') {
|
||||
// Observe new items
|
||||
mutation.addedNodes.forEach((item: HTMLElement) => observerPhotos.observe(item))
|
||||
}
|
||||
}
|
||||
})
|
||||
mutationPhotos.observe(photosGridEl, {
|
||||
childList: true,
|
||||
})
|
||||
|
||||
// Observe existing elements
|
||||
const existingPhotos = photosGridEl.querySelectorAll('.photo')
|
||||
existingPhotos.forEach(el => observerPhotos.observe(el))
|
||||
|
||||
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Reveal text
|
||||
['.photos-page__intro .discover, .photos-page__intro .filters__bar', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.5,
|
||||
delay: stagger(0.3),
|
||||
}]
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
|
||||
|
||||
// Destroy
|
||||
return () => {
|
||||
// Disconnect observers
|
||||
observerPhotos && observerPhotos.disconnect()
|
||||
mutationPhotos && mutationPhotos.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="{filterCountry === defaultCountry ? `All Photos` : `Photos of ${currentCountry.name}`} - Houses Of"
|
||||
description="Discover {totalPhotos} homes from {filterCountry === defaultCountry ? `${locations.length} places of ${countries.length} countries in the World` : currentCountry.name}"
|
||||
image={getAssetUrlKey(settings.seo_image_photos.id, 'share-image')}
|
||||
/>
|
||||
|
||||
<svelte:window
|
||||
bind:scrollY bind:innerWidth bind:innerHeight
|
||||
on:scroll={setSidesMargin}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="photos-page">
|
||||
<section class="photos-page__intro"
|
||||
class:is-passed={scrolledPastIntro}
|
||||
>
|
||||
<ScrollingTitle tag="h1" text="Houses">
|
||||
<SplitText text="Houses" mode="chars" />
|
||||
</ScrollingTitle>
|
||||
|
||||
<DiscoverText />
|
||||
|
||||
<div class="filters"
|
||||
class:is-over={filtersOver}
|
||||
class:is-transitioning={filtersTransitioning}
|
||||
class:is-visible={filtersVisible}
|
||||
>
|
||||
<div class="filters__bar">
|
||||
<span class="text-label filters__label">Filter photos</span>
|
||||
<ul>
|
||||
<li>
|
||||
<Select
|
||||
name="country" id="filter_country"
|
||||
options={[
|
||||
{
|
||||
value: defaultCountry,
|
||||
name: 'Worldwide',
|
||||
default: true,
|
||||
selected: filterCountry === defaultCountry,
|
||||
},
|
||||
...countries.map(({ slug, name }) => ({
|
||||
value: slug,
|
||||
name,
|
||||
selected: filterCountry === slug,
|
||||
}))
|
||||
]}
|
||||
on:change={handleCountryChange}
|
||||
value={filterCountry}
|
||||
>
|
||||
{#if countryFlagId}
|
||||
<Image
|
||||
class="icon"
|
||||
id={countryFlagId}
|
||||
sizeKey="square-small"
|
||||
width={26} height={26}
|
||||
alt="{filterCountry} flag"
|
||||
/>
|
||||
{:else}
|
||||
<IconEarth class="icon" />
|
||||
{/if}
|
||||
</Select>
|
||||
</li>
|
||||
<li>
|
||||
<Select
|
||||
name="sort" id="filter_sort"
|
||||
options={[
|
||||
{
|
||||
value: 'latest',
|
||||
name: 'Latest',
|
||||
default: true,
|
||||
selected: filterSort === defaultSort
|
||||
},
|
||||
{
|
||||
value: 'oldest',
|
||||
name: 'Oldest',
|
||||
selected: filterSort === 'oldest'
|
||||
},
|
||||
]}
|
||||
on:change={handleSortChange}
|
||||
value={filterSort}
|
||||
>
|
||||
<svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-label="Sort icon">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.878 15.93h-4.172c-.638 0-1.153.516-1.153 1.154 0 .639.515 1.154 1.153 1.154h4.172c.638 0 1.153-.515 1.153-1.154a1.152 1.152 0 0 0-1.153-1.153Zm3.244-5.396h-7.405c-.639 0-1.154.515-1.154 1.153 0 .639.515 1.154 1.154 1.154h7.405c.639 0 1.154-.515 1.154-1.154a1.145 1.145 0 0 0-1.154-1.153Zm3.244-5.408h-10.65c-.638 0-1.153.515-1.153 1.154 0 .639.515 1.154 1.154 1.154h10.65c.638 0 1.153-.515 1.153-1.154 0-.639-.515-1.154-1.154-1.154ZM7.37 20.679V3.376c0-.145-.03-.289-.082-.433a1.189 1.189 0 0 0-.628-.618 1.197 1.197 0 0 0-.886 0 1.045 1.045 0 0 0-.36.237c-.01 0-.01 0-.021.01L.82 7.145a1.156 1.156 0 0 0 0 1.638 1.156 1.156 0 0 0 1.637 0l2.596-2.596v11.7l-2.596-2.595a1.156 1.156 0 0 0-1.637 0 1.156 1.156 0 0 0 0 1.638l4.573 4.573c.103.103.237.185.37.247.135.062.289.082.433.082h.02c.145 0 .3-.03.433-.093a1.14 1.14 0 0 0 .629-.628.987.987 0 0 0 .092-.432Z" />
|
||||
</svg>
|
||||
</Select>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="filters__actions">
|
||||
{#if filtered}
|
||||
<button class="reset button-link"
|
||||
on:click={resetFiltered}
|
||||
transition:fly={{ y: 4, duration: 600, easing: quartOutSvelte }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="photos-page__content" bind:this={photosContentEl} style:--margin-sides="{sideMargins}px">
|
||||
<div class="grid container">
|
||||
{#if photos}
|
||||
<div class="photos-page__grid" bind:this={photosGridEl} data-sveltekit-noscroll>
|
||||
{#each photos as { id, image, slug, location, title, city }, index (id)}
|
||||
<figure class="photo shadow-photo">
|
||||
<a href="/{location.country.slug}/{location.slug}/{slug}" tabindex="0">
|
||||
<Image
|
||||
id={image.id}
|
||||
sizeKey="photo-grid"
|
||||
sizes={{
|
||||
small: { width: 500 },
|
||||
medium: { width: 900 },
|
||||
large: { width: 1440 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
alt={image.title}
|
||||
/>
|
||||
</a>
|
||||
<figcaption>
|
||||
<PostCard
|
||||
street={title}
|
||||
location={city ? `${city}, ${location.name}` : location.name}
|
||||
region={location.region}
|
||||
country={location.country.name}
|
||||
flagId={location.country.flag.id}
|
||||
size={isSmall(index) ? 'small' : null}
|
||||
/>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="controls grid">
|
||||
<p class="controls__date" title={dayjs(latestPhoto.date_created).format('DD/MM/YYYY, hh:mm')}>
|
||||
Last updated: <time datetime={dayjs(latestPhoto.date_created).format('YYYY-MM-DD')}>{dayjs().to(dayjs(latestPhoto.date_created))}</time>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
tag="button"
|
||||
text={!ended ? 'See more photos' : "You've seen it all!"}
|
||||
size="large" color="beige"
|
||||
on:click={loadMorePhotos}
|
||||
disabled={ended}
|
||||
/>
|
||||
|
||||
<div class="controls__count">
|
||||
<span class="current">{currentPhotosAmount}</span>
|
||||
<span>/</span>
|
||||
<span class="total">{totalPhotos}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !filteredCountryExists}
|
||||
<div class="photos-page__message">
|
||||
<p>
|
||||
<strong>{$page.url.searchParams.get('country').replace(/(^|\s)\S/g, letter => letter.toUpperCase())}</strong> is not available… yet 👀
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid-modules">
|
||||
<div class="wrap">
|
||||
<ShopModule />
|
||||
<NewsletterModule theme="light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
42
apps/website/src/routes/(site)/subscribe/+page.server.ts
Normal file
42
apps/website/src/routes/(site)/subscribe/+page.server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
settings {
|
||||
newsletter_page_text
|
||||
}
|
||||
|
||||
newsletter (
|
||||
limit: -1,
|
||||
sort: "-issue",
|
||||
filter: { status: { _eq: "published" }},
|
||||
) {
|
||||
issue
|
||||
title
|
||||
date_sent
|
||||
link
|
||||
thumbnail { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=86399' })
|
||||
|
||||
return {
|
||||
...data.settings,
|
||||
issues: data.newsletter,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
99
apps/website/src/routes/(site)/subscribe/+page.svelte
Normal file
99
apps/website/src/routes/(site)/subscribe/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/subscribe";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from '$animations/easings'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
import EmailForm from '$components/molecules/EmailForm.svelte'
|
||||
import NewsletterIssue from '$components/molecules/NewsletterIssue.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { issues } = data
|
||||
const latestIssue = issues[0]
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Elements
|
||||
['.heading .text, .subscribe__top .newsletter-form', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.5,
|
||||
delay: stagger(0.35),
|
||||
}],
|
||||
|
||||
// Reveal each issue
|
||||
['.subscribe__issues h2, .subscribe__issues > .issue-container, .subscribe__issues > ul', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
duration: 1,
|
||||
at: 1.5,
|
||||
delay: stagger(0.15),
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Subscribe – Houses Of"
|
||||
description="Subscribe to the Houses Of newsletter to be notified when new photos or locations are added to the site and when more prints are available on our shop"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="subscribe">
|
||||
<div class="subscribe__top">
|
||||
<Heading
|
||||
text={data.newsletter_page_text}
|
||||
/>
|
||||
|
||||
<EmailForm />
|
||||
</div>
|
||||
|
||||
<section class="subscribe__issues">
|
||||
<h2 class="title-small">Latest Issue</h2>
|
||||
<div class="issue-container">
|
||||
<NewsletterIssue size="large" date={latestIssue.date_sent} {...latestIssue} />
|
||||
</div>
|
||||
|
||||
{#if issues.length > 1}
|
||||
<h2 class="title-small">Past Issues</h2>
|
||||
<ul>
|
||||
{#each issues.slice(1) as { issue, title, date_sent: date, link, thumbnail }}
|
||||
<li class="issue-container">
|
||||
<NewsletterIssue {issue} {title} {link} {thumbnail} {date} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<InteractiveGlobe type="cropped" />
|
||||
</main>
|
||||
</PageTransition>
|
||||
26
apps/website/src/routes/(site)/terms/+page.server.ts
Normal file
26
apps/website/src/routes/(site)/terms/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
legal {
|
||||
terms
|
||||
date_updated
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
...data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
49
apps/website/src/routes/(site)/terms/+page.svelte
Normal file
49
apps/website/src/routes/(site)/terms/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/terms";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
// Components
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
|
||||
export let data: PageData
|
||||
const { legal } = data
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Terms and Conditions – Houses Of"
|
||||
description="Everything you need to know about using our website or buying our products"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="terms">
|
||||
<Heading text="Everything you need to know about using our website or buying our products" />
|
||||
|
||||
<section class="terms__categories">
|
||||
<div class="container grid">
|
||||
{#each legal.terms as { title, text }, index}
|
||||
<article class="terms__section grid">
|
||||
<h2 class="title-small">{index + 1}. {title}</h2>
|
||||
<div class="text text-info">
|
||||
{@html text}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
<footer>
|
||||
<p class="text-info">
|
||||
Updated: <time datetime={dayjs(legal.date_updated).format('YYYY-MM-DD')}>{dayjs().to(dayjs(legal.date_updated))}</time>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
85
apps/website/src/routes/+error.svelte
Normal file
85
apps/website/src/routes/+error.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<style lang="scss">
|
||||
@import "../style/pages/error";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import BoxCTA from '$components/atoms/BoxCTA.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
import ListCTAs from '$components/organisms/ListCTAs.svelte'
|
||||
import Locations from '$components/organisms/Locations.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
|
||||
const { locations }: any = getContext('global')
|
||||
const errors = {
|
||||
404: {
|
||||
title: 'Page not found',
|
||||
message: 'You seem lost…',
|
||||
},
|
||||
500: {
|
||||
title: 'Error',
|
||||
message: 'Server error…',
|
||||
},
|
||||
}
|
||||
const defaultMessage = 'You are welcome to explore our locations or discover our shop.'
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="{errors[$page.status].title} – Houses Of"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="page-error">
|
||||
<div class="page-error__top">
|
||||
<Heading
|
||||
text="{$page.error.message ?? errors[$page.status].message} <br>{defaultMessage}"
|
||||
/>
|
||||
|
||||
<ListCTAs>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/photos"
|
||||
icon="photos"
|
||||
label="Browse all photos"
|
||||
alt="Photos"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/shop"
|
||||
icon="bag"
|
||||
label="Shop our products"
|
||||
alt="Shopping bag"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/about"
|
||||
icon="compass"
|
||||
label="Learn about the project"
|
||||
alt="Compass"
|
||||
/>
|
||||
</li>
|
||||
</ListCTAs>
|
||||
</div>
|
||||
|
||||
<InteractiveGlobe />
|
||||
<Locations {locations} />
|
||||
|
||||
<div class="grid-modules">
|
||||
<div class="container grid">
|
||||
<div class="wrap">
|
||||
<ShopModule />
|
||||
<NewsletterModule />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageTransition>
|
||||
105
apps/website/src/routes/+layout.server.ts
Normal file
105
apps/website/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { PUBLIC_PREVIEW_COUNT } from '$env/static/public'
|
||||
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
locations: location (filter: { status: { _eq: "published" }}) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
coordinates
|
||||
country {
|
||||
name
|
||||
slug
|
||||
flag { id }
|
||||
continent { slug }
|
||||
}
|
||||
date_updated
|
||||
photos (
|
||||
sort: "-date_created",
|
||||
limit: ${PUBLIC_PREVIEW_COUNT}
|
||||
) {
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
date_created
|
||||
}
|
||||
has_poster
|
||||
globe_close
|
||||
}
|
||||
|
||||
countries: country (filter: { status: { _eq: "published" }}) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
flag { id }
|
||||
locations { id slug }
|
||||
}
|
||||
|
||||
continents: continent (filter: { countries: { slug: { _neq: "_empty" }}}) {
|
||||
name
|
||||
slug
|
||||
rotation_x
|
||||
rotation_y
|
||||
}
|
||||
|
||||
settings {
|
||||
seo_name
|
||||
seo_title
|
||||
seo_description
|
||||
seo_image { id }
|
||||
description
|
||||
explore_list
|
||||
limit_new
|
||||
instagram
|
||||
footer_links
|
||||
switcher_links
|
||||
newsletter_subtitle
|
||||
newsletter_text
|
||||
}
|
||||
|
||||
shop {
|
||||
enabled
|
||||
module_title
|
||||
module_text
|
||||
module_images {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Count
|
||||
countPhotos: photo_aggregated (filter: { status: { _eq: "published" }}) {
|
||||
count { id }
|
||||
}
|
||||
countLocations: location_aggregated (filter: { status: { _eq: "published" }}) {
|
||||
count { id }
|
||||
}
|
||||
countCountries: country_aggregated (filter: { status: { _eq: "published" }}) {
|
||||
count { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data: { countPhotos, countLocations, countCountries, ...rest }} = res
|
||||
|
||||
return {
|
||||
...rest,
|
||||
count: {
|
||||
photos: countPhotos[0].count.id,
|
||||
locations: countLocations[0].count.id,
|
||||
countries: countCountries[0].count.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err || 'Failed to fetch data')
|
||||
}
|
||||
}
|
||||
80
apps/website/src/routes/+layout.svelte
Normal file
80
apps/website/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import '../style/global.scss'
|
||||
|
||||
import { browser } from '$app/environment'
|
||||
import { page } from '$app/stores'
|
||||
import { beforeNavigate } from '$app/navigation'
|
||||
import { PUBLIC_ANALYTICS_DOMAIN } from '$env/static/public'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount, setContext } from 'svelte'
|
||||
import { pageLoading, previousPage } from '$utils/stores'
|
||||
import '$utils/polyfills'
|
||||
// Components
|
||||
import SVGSprite from '$components/SVGSprite.svelte'
|
||||
import SmoothScroll from '$components/SmoothScroll.svelte'
|
||||
import Analytics from '$components/Analytics.svelte'
|
||||
import Switcher from '$components/molecules/Switcher.svelte'
|
||||
import Footer from '$components/organisms/Footer.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let innerHeight: number
|
||||
$: innerHeight && document.body.style.setProperty('--vh', `${innerHeight}px`)
|
||||
|
||||
// Fonts to preload
|
||||
const fonts = [
|
||||
'G-Light',
|
||||
'G-Regular',
|
||||
'G-Medium',
|
||||
'G-Semibold',
|
||||
'J-Extralight',
|
||||
'J-Light',
|
||||
]
|
||||
|
||||
// Set global data
|
||||
setContext('global', data)
|
||||
|
||||
|
||||
/**
|
||||
* On page change
|
||||
*/
|
||||
// Store previous page (for photo Viewer close button)
|
||||
beforeNavigate(({ from }) => {
|
||||
$previousPage = from.url.pathname
|
||||
})
|
||||
|
||||
// Define page loading
|
||||
$: browser && document.body.classList.toggle('is-loading', $pageLoading)
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Avoid FOUC
|
||||
document.body.style.opacity = '1'
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight />
|
||||
|
||||
<svelte:head>
|
||||
<link rel="canonical" href={$page.url.href} />
|
||||
|
||||
{#each fonts as font}
|
||||
<link rel="preload" href="/fonts/{font}.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
{/each}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
<Switcher />
|
||||
|
||||
<slot />
|
||||
|
||||
{#if !$page.params.photo}
|
||||
<Footer />
|
||||
{/if}
|
||||
|
||||
<SVGSprite />
|
||||
<SmoothScroll />
|
||||
|
||||
{#if browser}
|
||||
<Analytics domain={PUBLIC_ANALYTICS_DOMAIN} />
|
||||
{/if}
|
||||
59
apps/website/src/routes/+page.server.ts
Normal file
59
apps/website/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { getRandomItems } from '$utils/functions'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
// Get total of published photos
|
||||
const totalRes = await fetchAPI(`query {
|
||||
photo (
|
||||
filter: {
|
||||
favorite: { _eq: true },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
limit: -1,
|
||||
) {
|
||||
id
|
||||
}
|
||||
}`)
|
||||
const { data: { photo: photosIds }} = totalRes
|
||||
|
||||
// Get random photos
|
||||
const randomPhotosIds = [...getRandomItems(photosIds, 11)].map(({ id }) => id)
|
||||
|
||||
// Query these random photos from IDs
|
||||
const photosRes = await fetchAPI(`query {
|
||||
photo (filter: { id: { _in: "${randomPhotosIds}" }}) {
|
||||
slug
|
||||
title
|
||||
city
|
||||
location {
|
||||
name
|
||||
slug
|
||||
country {
|
||||
slug
|
||||
name
|
||||
flag { id }
|
||||
}
|
||||
}
|
||||
image { id }
|
||||
}
|
||||
}`)
|
||||
const { data: { photo: photos }} = photosRes
|
||||
|
||||
if (photos) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=599' })
|
||||
|
||||
return {
|
||||
photos,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
170
apps/website/src/routes/+page.svelte
Normal file
170
apps/website/src/routes/+page.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<style lang="scss">
|
||||
@import "../style/pages/homepage";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating, page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import { timeline, stagger } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { smoothScroll } from '$utils/stores'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import reveal from '$animations/reveal'
|
||||
import { quartOut } from '$animations/easings'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import SplitText from '$components/SplitText.svelte'
|
||||
import Button from '$components/atoms/Button.svelte'
|
||||
import IconEarth from '$components/atoms/IconEarth.svelte'
|
||||
import ScrollingTitle from '$components/atoms/ScrollingTitle.svelte'
|
||||
import BoxCTA from '$components/atoms/BoxCTA.svelte'
|
||||
import DiscoverText from '$components/atoms/DiscoverText.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
import Collage from '$components/organisms/Collage.svelte'
|
||||
import Locations from '$components/organisms/Locations.svelte'
|
||||
import ListCTAs from '$components/organisms/ListCTAs.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { photos } = data
|
||||
const { settings, locations }: any = getContext('global')
|
||||
|
||||
let scrollY: number, innerHeight: number
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Reveal text
|
||||
['.homepage__headline', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.75,
|
||||
}],
|
||||
|
||||
// Animate collage photos
|
||||
['.collage .photo-card', {
|
||||
y: ['33.33%', 0],
|
||||
rotate: [-4, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0,
|
||||
duration: 1.2,
|
||||
delay: stagger(0.075),
|
||||
}]
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY bind:innerHeight />
|
||||
|
||||
<Metas
|
||||
title={settings.seo_title}
|
||||
description={settings.seo_description}
|
||||
image={getAssetUrlKey(settings.seo_image.id, 'share-image')}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="homepage">
|
||||
<section class="homepage__intro"
|
||||
use:reveal={{
|
||||
animation: { opacity: [0, 1] },
|
||||
options: {
|
||||
duration: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ScrollingTitle
|
||||
tag="h1"
|
||||
class="title-houses"
|
||||
label="Houses of the World"
|
||||
offsetStart={-300}
|
||||
offsetEnd={400}
|
||||
>
|
||||
<SplitText text="Houses" mode="chars" />
|
||||
</ScrollingTitle>
|
||||
|
||||
<div class="homepage__headline">
|
||||
<p class="text-medium">
|
||||
{settings.description}
|
||||
</p>
|
||||
|
||||
<Button url="#locations" text="Explore locations" on:click={() => $smoothScroll.scrollTo('#locations', { duration: 2 })}>
|
||||
<IconEarth animate={true} />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="homepage__photos">
|
||||
<Collage {photos} />
|
||||
</section>
|
||||
|
||||
<div class="homepage__ctas">
|
||||
<DiscoverText />
|
||||
|
||||
<ListCTAs>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/photos"
|
||||
icon="photos"
|
||||
label="Browse all photos"
|
||||
alt="Photos"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/shop"
|
||||
icon="bag"
|
||||
label="Shop our products"
|
||||
alt="Shopping bag"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<BoxCTA
|
||||
url="/about"
|
||||
icon="compass"
|
||||
label="Learn about the project"
|
||||
alt="Compass"
|
||||
/>
|
||||
</li>
|
||||
</ListCTAs>
|
||||
</div>
|
||||
|
||||
<section class="homepage__locations" id="locations">
|
||||
<InteractiveGlobe />
|
||||
|
||||
<ScrollingTitle tag="p" class="title-world mask">
|
||||
<SplitText text="World" mode="chars" />
|
||||
</ScrollingTitle>
|
||||
|
||||
<Locations {locations} />
|
||||
</section>
|
||||
|
||||
<div class="grid-modules">
|
||||
<div class="container grid">
|
||||
<div class="wrap">
|
||||
<ShopModule />
|
||||
<NewsletterModule />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageTransition>
|
||||
40
apps/website/src/routes/api/newsletter/+server.ts
Normal file
40
apps/website/src/routes/api/newsletter/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NEWSLETTER_API_TOKEN, NEWSLETTER_LIST_ID } from '$env/static/private'
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
export const POST = (async ({ request, fetch }) => {
|
||||
const data: { email: string } = await request.json()
|
||||
const { email } = data
|
||||
|
||||
// No email
|
||||
if (!email) {
|
||||
return new Response(JSON.stringify({ message: 'NO_EMAIL' }), { status: 400 })
|
||||
}
|
||||
// Invalid email
|
||||
if (!email.match(/^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/)) {
|
||||
return new Response(JSON.stringify({ message: 'INVALID_EMAIL' }), { status: 400 })
|
||||
}
|
||||
|
||||
// Newsletter API request
|
||||
const req = await fetch(`https://emailoctopus.com/api/1.6/lists/${NEWSLETTER_LIST_ID}/contacts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: NEWSLETTER_API_TOKEN,
|
||||
email_address: email,
|
||||
})
|
||||
})
|
||||
const res = await req.json()
|
||||
console.log('server API response:', res)
|
||||
|
||||
// Other error
|
||||
if (res && res.status !== 'PENDING') {
|
||||
return new Response(JSON.stringify({ message: res.error.code }), { status: 400 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: res.status,
|
||||
}), {
|
||||
status: 200
|
||||
})
|
||||
}) satisfies RequestHandler
|
||||
93
apps/website/src/routes/sitemap.xml/+server.ts
Normal file
93
apps/website/src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ url, setHeaders }) => {
|
||||
try {
|
||||
const locations = []
|
||||
const products = []
|
||||
|
||||
// Get dynamic data from API
|
||||
const res = await fetchAPI(`query {
|
||||
locations: location (filter: { status: { _eq: "published" }}) {
|
||||
slug
|
||||
country { slug }
|
||||
}
|
||||
products: product (filter: { status: { _eq: "published" }}) {
|
||||
location { slug }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
locations.push(...data.locations)
|
||||
products.push(...data.products)
|
||||
}
|
||||
|
||||
// Static pages
|
||||
const pages = [
|
||||
['/', '1.0', 'daily'],
|
||||
['/photos', '1.0', 'daily'],
|
||||
['/locations', '0.6', 'weekly'],
|
||||
['/shop', '0.8', 'weekly'],
|
||||
['/about', '0.6', 'weekly'],
|
||||
['/terms', '0.6', 'weekly'],
|
||||
['/subscribe', '0.6', 'weekly'],
|
||||
['/credits', '0.6', 'monthly'],
|
||||
]
|
||||
|
||||
// All pages
|
||||
const allPages = [
|
||||
// Static pages
|
||||
...pages.map(([path, priority, frequency]) => ({
|
||||
path,
|
||||
priority,
|
||||
frequency,
|
||||
})),
|
||||
|
||||
// Locations
|
||||
...locations.map(({ slug, country }) => ({
|
||||
path: `/${country.slug}/${slug}`,
|
||||
priority: 0.7,
|
||||
frequency: 'monthly',
|
||||
})),
|
||||
|
||||
// Products
|
||||
...products.map(({ location: { slug }}) => ({
|
||||
path: `/shop/poster-${slug}`,
|
||||
priority: 0.7,
|
||||
frequency: 'monthly',
|
||||
})),
|
||||
]
|
||||
|
||||
const sitemap = render(url.origin, allPages)
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=0, s-max-age=600',
|
||||
})
|
||||
|
||||
return new Response(sitemap)
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const render = (origin: string, pages: any[]) => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="https://www.w3.org/1999/xhtml"
|
||||
xmlns:mobile="https://www.google.com/schemas/sitemap-mobile/1.0"
|
||||
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9"
|
||||
xmlns:image="https://www.google.com/schemas/sitemap-image/1.1"
|
||||
xmlns:video="https://www.google.com/schemas/sitemap-video/1.1"
|
||||
>
|
||||
${pages.map(({ path, priority, frequency }) => `<url>
|
||||
<loc>${origin}${path}</loc>
|
||||
<priority>${priority}</priority>
|
||||
<changefreq>${frequency}</changefreq>
|
||||
</url>`).join('')}
|
||||
</urlset>`
|
||||
}
|
||||
114
apps/website/src/service-workers.ts
Normal file
114
apps/website/src/service-workers.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { build, files, version } from '$service-worker'
|
||||
|
||||
const ASSETS = 'assets' + version
|
||||
|
||||
// `build` is an array of all the files generated by the bundler, `files` is an
|
||||
// array of everything in the `static` directory (except exlucdes defined in svelte.config.js)
|
||||
const cached = build.concat(files);
|
||||
|
||||
// if you use typescript:
|
||||
(self as unknown as ServiceWorkerGlobalScope).addEventListener('install', event => {
|
||||
// self.addEventListener(
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(ASSETS)
|
||||
.then(cache => cache.addAll(cached))
|
||||
.then(() => {
|
||||
// if you use typescript:
|
||||
(self as unknown as ServiceWorkerGlobalScope).skipWaiting();
|
||||
// self.skipWaiting();
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// self.addEventListener(
|
||||
// if you use typescript:
|
||||
(self as unknown as ServiceWorkerGlobalScope).addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(async keys => {
|
||||
// delete old caches
|
||||
keys.map(async key => {
|
||||
if (key !== ASSETS) {
|
||||
await caches.delete(key)
|
||||
}
|
||||
});
|
||||
|
||||
// if you use typescript:
|
||||
(self as unknown as ServiceWorkerGlobalScope).clients.claim()
|
||||
// self.clients.claim()
|
||||
})
|
||||
);
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Fetch the asset from the network and store it in the cache.
|
||||
* Fall back to the cache if the user is offline.
|
||||
*/
|
||||
// async function fetchAndCache (request) {
|
||||
// if you use typescript:
|
||||
async function fetchAndCache(request: Request) {
|
||||
const cache = await caches.open(`offline${version}`)
|
||||
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
|
||||
if (response.status === 200) {
|
||||
cache.put(request, response.clone())
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
const response = await cache.match(request)
|
||||
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// self.addEventListener('fetch', event => {
|
||||
// if you use typescript:
|
||||
(self as unknown as ServiceWorkerGlobalScope).addEventListener('fetch', event => {
|
||||
if (event.request.method !== 'GET' || event.request.headers.has('range')) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
if (
|
||||
// don't try to handle e.g. data: URIs
|
||||
!url.protocol.startsWith('http') ||
|
||||
// ignore dev server requests
|
||||
(url.hostname === self.location.hostname &&
|
||||
url.port !== self.location.port) ||
|
||||
// ignore /_app/version.json
|
||||
url.pathname === '/_app/version.json'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// always serve static files and bundler-generated assets from cache
|
||||
const isStaticAsset = url.host === self.location.host && cached.indexOf(url.pathname) > -1
|
||||
|
||||
if (event.request.cache === 'only-if-cached' && !isStaticAsset) {
|
||||
return
|
||||
}
|
||||
|
||||
// for everything else, try the network first, falling back to cache if the
|
||||
// user is offline. (If the pages never change, you might prefer a cache-first
|
||||
// approach to a network-first one.)
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
// always serve static files and bundler-generated assets from cache.
|
||||
// if your application has other URLs with data that will never change,
|
||||
// set this variable to true for them and they will only be fetched once.
|
||||
const cachedAsset = isStaticAsset && (await caches.match(event.request))
|
||||
return cachedAsset || fetchAndCache(event.request)
|
||||
})()
|
||||
)
|
||||
}
|
||||
)
|
||||
10
apps/website/src/style/_animations.scss
Normal file
10
apps/website/src/style/_animations.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
** Earth icon
|
||||
*/
|
||||
.anim-earth {
|
||||
animation: moveEarth 4s linear infinite;
|
||||
}
|
||||
@keyframes moveEarth {
|
||||
0% { transform: translate3d(-87px, 0, 0); }
|
||||
100% { transform: translate3d(0,0,0); }
|
||||
}
|
||||
157
apps/website/src/style/_base.scss
Normal file
157
apps/website/src/style/_base.scss
Normal file
@@ -0,0 +1,157 @@
|
||||
// CSS Variables
|
||||
:root {
|
||||
// Sizes
|
||||
--container-width: #{$container-width};
|
||||
|
||||
// Offsets
|
||||
--switcher-offset: 16px;
|
||||
|
||||
// Animation
|
||||
--ease-quart: cubic-bezier(.165, .84, .44, 1);
|
||||
--ease-cubic: cubic-bezier(.785, .135, .15, .86);
|
||||
--ease-inout-quart: cubic-bezier(.76, 0, .24, 1);
|
||||
}
|
||||
@include bp (sm) {
|
||||
:root {
|
||||
// Offsets
|
||||
--switcher-offset: clamp(20px, 3vw, 40px);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font: #{$base-font-size}/1.2 $font-sans;
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
min-width: 320px;
|
||||
word-break: normal;
|
||||
}
|
||||
body {
|
||||
@include font-smooth;
|
||||
background: $color-primary;
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
|
||||
&.block-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
&.is-loading * {
|
||||
cursor: wait !important;
|
||||
}
|
||||
}
|
||||
*, *:before, *:after {
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
em {
|
||||
font-style: normal;
|
||||
}
|
||||
figure, p, dl, dt, dd, ul, ol, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
figure, picture {
|
||||
display: block;
|
||||
}
|
||||
nav li:before {
|
||||
display: none;
|
||||
}
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:global([disabled]) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollbar
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba($color-tertiary, 0.6) $color-primary-darker;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: $color-primary-darker;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba($color-tertiary, 0.6);
|
||||
border: 4px solid $color-primary-darker;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// Accessibility outline
|
||||
// Remove default focus styles for mouse users if :focus-visible is supported
|
||||
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
|
||||
outline: none;
|
||||
}
|
||||
[data-focus-visible-added], *:focus-visible {
|
||||
outline: 1px dashed $color-secondary;
|
||||
}
|
||||
input[type="text"], input[type="email"], input[type="password"] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Selection
|
||||
::selection { color: #fff; background: $color-secondary; }
|
||||
::-moz-selection { color: #fff; background: $color-secondary; }
|
||||
|
||||
// Images glitches fix
|
||||
// img {backface-visibility: hidden;}
|
||||
|
||||
|
||||
|
||||
/* Titles
|
||||
========================================================================== */
|
||||
#{headings(1,6)} {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
/* Global elements
|
||||
========================================================================== */
|
||||
// Split text elements
|
||||
.word, .char {
|
||||
display: inline-block;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
.text-split {
|
||||
span, &__line {
|
||||
display: inline-block;
|
||||
transition: opacity 0.7s var(--ease-quart), transform 0.7s var(--ease-quart);
|
||||
}
|
||||
}
|
||||
|
||||
// Mask for animations
|
||||
.mask {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
// Page loading overlay
|
||||
.page-loading {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: wait;
|
||||
}
|
||||
81
apps/website/src/style/_effects.scss
Normal file
81
apps/website/src/style/_effects.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
** Box shadows
|
||||
*/
|
||||
// Box: Dark
|
||||
.shadow-box-dark {
|
||||
box-shadow:
|
||||
0 6px 6px rgba(#000, 0.05),
|
||||
0 12px 12px rgba(#000, 0.05),
|
||||
0 24px 24px rgba(#000, 0.05);
|
||||
}
|
||||
|
||||
// Box: Light
|
||||
.shadow-box-light {
|
||||
box-shadow:
|
||||
0 6px 6px rgba(#736356, 0.05),
|
||||
0 12px 12px rgba(#736356, 0.05),
|
||||
0 24px 24px rgba(#736356, 0.05);
|
||||
}
|
||||
|
||||
// Box: Photo
|
||||
.shadow-photo {
|
||||
$shadow-color: rgba(122, 93, 68, 0.075);
|
||||
box-shadow:
|
||||
0 1px 1px $shadow-color,
|
||||
0 2px 2px $shadow-color,
|
||||
0 4px 4px $shadow-color,
|
||||
0 8px 8px $shadow-color,
|
||||
0 16px 16px $shadow-color;
|
||||
}
|
||||
|
||||
// Shadow: Small
|
||||
.shadow-small {
|
||||
$shadow-color: rgba(122, 93, 68, 0.05);
|
||||
box-shadow:
|
||||
0 1px 1px $shadow-color,
|
||||
0 2px 2px $shadow-color,
|
||||
0 4px 4px $shadow-color,
|
||||
0 16px 16px $shadow-color;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
** Hovers
|
||||
*/
|
||||
// 3D effect link
|
||||
.link-3d {
|
||||
overflow: hidden;
|
||||
|
||||
.text-split {
|
||||
perspective: 300px;
|
||||
}
|
||||
.text-split__line {
|
||||
--offset-y: 66%;
|
||||
transform-origin: bottom center;
|
||||
|
||||
&:last-child {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $color-secondary;
|
||||
transform: translate3d(0, var(--offset-y), 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
&:not([disabled]):hover {
|
||||
.text-split__line {
|
||||
&:first-child {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translate3d(0, 12%, 0);
|
||||
}
|
||||
&:last-child {
|
||||
opacity: 1;
|
||||
transform: translate3d(0,0,0);
|
||||
transition-delay: 55ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
apps/website/src/style/_fonts.scss
Normal file
8
apps/website/src/style/_fonts.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
/* Fonts list
|
||||
========================================================================== */
|
||||
@include font-face("Garnett", "G-Light", 200);
|
||||
@include font-face("Garnett", "G-Regular", 400);
|
||||
@include font-face("Garnett", "G-Medium", 500);
|
||||
@include font-face("Garnett", "G-Semibold", 600);
|
||||
@include font-face("Jazmin", "J-Extralight", 200);
|
||||
@include font-face("Jazmin", "J-Light", 300);
|
||||
232
apps/website/src/style/_typography.scss
Normal file
232
apps/website/src/style/_typography.scss
Normal file
@@ -0,0 +1,232 @@
|
||||
/* ==========================================================================
|
||||
TITLES
|
||||
========================================================================== */
|
||||
// Huge
|
||||
.title-huge {
|
||||
font-family: $font-serif;
|
||||
font-size: clamp(#{rem(200px)}, 38vw, #{rem(700px)});
|
||||
font-weight: 200;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// House Name
|
||||
.title-image {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(40px);
|
||||
line-height: 1;
|
||||
color: $color-secondary;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: clamp(#{rem(40px)}, 7vw, #{rem(88px)});
|
||||
}
|
||||
}
|
||||
|
||||
// House Number
|
||||
.title-index {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(80px);
|
||||
line-height: 1;
|
||||
font-weight: 200;
|
||||
letter-spacing: -0.05em;
|
||||
color: rgba($color-tertiary, 0.6);
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: clamp(#{rem(80px)}, 24vw, #{rem(280px)});
|
||||
}
|
||||
}
|
||||
|
||||
// X-Large
|
||||
.title-xl {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(34px);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.025em;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: clamp(#{rem(34px)}, 4vw, #{rem(60px)});
|
||||
}
|
||||
}
|
||||
|
||||
// Big
|
||||
.title-big {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(28px);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.025em;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(32px);
|
||||
}
|
||||
@include bp (md) {
|
||||
font-size: clamp(#{rem(32px)}, 3.5vw, #{rem(48px)});
|
||||
}
|
||||
}
|
||||
|
||||
// Big - Sans Sefif
|
||||
.title-big-sans {
|
||||
font-size: rem(40px);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: clamp(#{rem(40px)}, 6vw, #{rem(56px)})
|
||||
}
|
||||
}
|
||||
|
||||
// Medium
|
||||
.title-medium {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(26px);
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
|
||||
@include bp (sd) {
|
||||
font-size: rem(32px);
|
||||
}
|
||||
}
|
||||
|
||||
// Small
|
||||
.title-small {
|
||||
font-family: $font-serif;
|
||||
color: $color-secondary;
|
||||
font-size: rem(24px);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(28px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
TEXT
|
||||
========================================================================== */
|
||||
// Huge
|
||||
.text-huge {
|
||||
font-size: rem(34px);
|
||||
font-weight: 300;
|
||||
line-height: 1.25;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(44px);
|
||||
}
|
||||
@include bp (sd) {
|
||||
font-size: clamp(#{rem(44px)}, 5vw, #{rem(72px)});
|
||||
}
|
||||
}
|
||||
|
||||
// Big
|
||||
.text-big {
|
||||
font-size: rem(32px);
|
||||
font-weight: 300;
|
||||
line-height: 1.3;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(36px);
|
||||
}
|
||||
@include bp (sd) {
|
||||
font-size: rem(44px);
|
||||
}
|
||||
}
|
||||
|
||||
// Large
|
||||
.text-large {
|
||||
font-size: rem(24px);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(28px);
|
||||
}
|
||||
@include bp (sd) {
|
||||
font-size: rem(32px);
|
||||
}
|
||||
}
|
||||
|
||||
// Medium
|
||||
.text-medium {
|
||||
font-size: rem(20px);
|
||||
font-weight: 200;
|
||||
line-height: 1.4;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(24px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@include bp (sd) {
|
||||
font-size: rem(28px);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal
|
||||
.text-normal {
|
||||
font-size: rem(18px);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(20px);
|
||||
}
|
||||
@include bp (sd) {
|
||||
font-size: rem(22px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Small
|
||||
.text-small {
|
||||
font-size: rem(16px);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
@include bp (sd) {
|
||||
font-size: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
// XSmall
|
||||
.text-xsmall {
|
||||
font-size: rem(16px);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Information
|
||||
.text-info {
|
||||
font-size: rem(14px);
|
||||
line-height: 1.3;
|
||||
|
||||
@include bp (md) {
|
||||
font-size: rem(16px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Label
|
||||
.text-label {
|
||||
font-size: rem(12px);
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
|
||||
&--small {
|
||||
font-size: rem(10px);
|
||||
}
|
||||
}
|
||||
58
apps/website/src/style/_variables.scss
Normal file
58
apps/website/src/style/_variables.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
// Colors
|
||||
$color-primary: #3C0576;
|
||||
$color-primary-dark: #36046A;
|
||||
$color-primary-darker: #2D0458;
|
||||
$color-primary-tertiary20: #633185;
|
||||
$color-secondary: #FF6C89;
|
||||
$color-secondary-light: #FFB3C2;
|
||||
$color-secondary-bright: #FF0536;
|
||||
$color-text: #333;
|
||||
$color-tertiary: #FFE0C5;
|
||||
$color-lightpurple: #8B50B2;
|
||||
$color-lilas-bright: #C78FEC;
|
||||
$color-gray: #666;
|
||||
$color-lightgray: #999;
|
||||
$color-shadow-brown: #7A5D44;
|
||||
$color-cream: #FEF6EF;
|
||||
|
||||
|
||||
/* Fonts
|
||||
========================================================================== */
|
||||
$base-font-size: 28px !default;
|
||||
|
||||
// Families
|
||||
$replacement-sans: "Helvetica, Arial, sans-serif";
|
||||
$replacement-serif: "Georgia, serif";
|
||||
$font-sans: "Garnett", #{$replacement-sans};
|
||||
$font-serif: "Jazmin", #{$replacement-serif};
|
||||
|
||||
|
||||
/* Sizes, margins and spacing
|
||||
====================================================================== */
|
||||
$base-width: 1600 !default;
|
||||
$container-width: 1800px !default;
|
||||
|
||||
// Grid
|
||||
$cols-m: 8 !default;
|
||||
$cols-d: 24 !default;
|
||||
|
||||
|
||||
/* Directories
|
||||
========================================================================== */
|
||||
$dir-img: "/images" !default;
|
||||
$dir-fonts: "/fonts" !default;
|
||||
|
||||
|
||||
/* Responsive breakpoints
|
||||
========================================================================== */
|
||||
$breakpoints: (
|
||||
mob: 450px,
|
||||
mob-lg: 550px,
|
||||
xs: 767px,
|
||||
sm: 768px,
|
||||
md: 992px,
|
||||
sd: 1200px,
|
||||
lg: 1440px,
|
||||
xl: 1600px,
|
||||
fhd: 1920px,
|
||||
) !default;
|
||||
16
apps/website/src/style/atoms/_arrow.scss
Normal file
16
apps/website/src/style/atoms/_arrow.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.arrow {
|
||||
display: block;
|
||||
|
||||
// Colors
|
||||
&--white {
|
||||
color: #fff;
|
||||
}
|
||||
&--pink {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
// Variants
|
||||
&--flip {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
21
apps/website/src/style/atoms/_badge.scss
Normal file
21
apps/website/src/style/atoms/_badge.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16px;
|
||||
margin: 0 auto;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
background: $color-secondary-light;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1;
|
||||
color: $color-primary-darker;
|
||||
border-radius: 100vh;
|
||||
|
||||
// Small size
|
||||
&--small {
|
||||
font-size: rem(7px);
|
||||
}
|
||||
}
|
||||
63
apps/website/src/style/atoms/_box-cta.scss
Normal file
63
apps/website/src/style/atoms/_box-cta.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
.box-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
width: 260px;
|
||||
height: 88px;
|
||||
padding: 24px 32px 24px 48px;
|
||||
background-color: $color-primary-tertiary20;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
box-shadow: none;
|
||||
transition: background-color 0.8s var(--ease-quart), box-shadow 1.0s var(--ease-quart);
|
||||
|
||||
@include bp (sm) {
|
||||
flex-direction: column;
|
||||
width: 144px;
|
||||
height: 176px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.6s var(--ease-quart);
|
||||
|
||||
@include bp (sm) {
|
||||
margin: auto 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
span {
|
||||
display: block;
|
||||
margin-left: 20px;
|
||||
color: $color-secondary-light;
|
||||
text-align: left;
|
||||
font-weight: 300;
|
||||
|
||||
@include bp (sm) {
|
||||
margin-left: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Hover
|
||||
&:hover {
|
||||
$shadow-color: rgba(0, 0, 0, 0.05);
|
||||
background-color: #8f3d7b;
|
||||
box-shadow: 0 6px 6px $shadow-color, 0 12px 12px $shadow-color, 0 24px 24px $shadow-color;
|
||||
|
||||
.icon {
|
||||
transform: translate3d(0, -3px, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/website/src/style/atoms/_button-cart.scss
Normal file
49
apps/website/src/style/atoms/_button-cart.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
.button-cart {
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
|
||||
@include bp (sm) {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:global(button) {
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Icon
|
||||
:global(svg) {
|
||||
display: block;
|
||||
width: min(65%, 22px);
|
||||
height: min(65%, 22px);
|
||||
margin-top: -3px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
// Quantity label
|
||||
.quantity {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateX(-33%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: rem(11px);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background-color: $color-secondary;
|
||||
border-radius: 100%;
|
||||
opacity: 1;
|
||||
|
||||
@include bp (md) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: rem(12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
127
apps/website/src/style/atoms/_button-circle.scss
Normal file
127
apps/website/src/style/atoms/_button-circle.scss
Normal file
@@ -0,0 +1,127 @@
|
||||
.button-circle {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border-radius: 100vh;
|
||||
transition: background-color 0.8s var(--ease-quart);
|
||||
|
||||
@include bp (md) {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
& > :global(*) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
width: clamp(16px, 50%, 24px);
|
||||
height: clamp(16px, 50%, 24px);
|
||||
transform-origin: center center;
|
||||
transition: opacity 0.7s var(--ease-quart), transform 0.7s var(--ease-quart);
|
||||
|
||||
// Last clone
|
||||
&:nth-child(2) {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
transform: translate3d(-150%, -50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
** States
|
||||
*/
|
||||
// Hover
|
||||
&.has-clone:not([disabled]):hover {
|
||||
& > :global(*) {
|
||||
&:first-child {
|
||||
opacity: 0;
|
||||
transform: scale(0.75) translate3d(20%, 0, 0);
|
||||
}
|
||||
&:last-child {
|
||||
opacity: 1;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Disabled
|
||||
&[disabled] {
|
||||
background: $color-primary;
|
||||
border: 3px solid rgba(#fff, 0.2);
|
||||
|
||||
:global(svg) {
|
||||
color: $color-primary-tertiary20;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
** Variants
|
||||
*/
|
||||
// Tiny size
|
||||
&--tiny {
|
||||
height: 24px !important;
|
||||
width: 24px !important;
|
||||
|
||||
:global(img), :global(svg) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
// Small size
|
||||
&--small {
|
||||
height: 32px !important;
|
||||
width: 32px !important;
|
||||
|
||||
:global(img), :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
// Pink color
|
||||
&--pink {
|
||||
background: $color-secondary;
|
||||
|
||||
&:hover {
|
||||
background: darken($color-secondary, 7);
|
||||
}
|
||||
}
|
||||
|
||||
// Purple color
|
||||
&--purple {
|
||||
color: #fff;
|
||||
background: $color-primary-tertiary20;
|
||||
|
||||
&:hover {
|
||||
background: $color-lightpurple;
|
||||
}
|
||||
}
|
||||
|
||||
// Gray color
|
||||
&--gray {
|
||||
background: #F2F2F2;
|
||||
|
||||
&:hover {
|
||||
background: #D2D2D2;
|
||||
}
|
||||
}
|
||||
|
||||
// Gray color
|
||||
&--gray-medium {
|
||||
background: $color-gray;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user