@struq/skill 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +3 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// footer-contact (Struq footer recipe)
|
|
3
|
+
// A footer whose hero is the way to reach the studio: the mail address as a real mailto link, the studio's city with
|
|
4
|
+
// its live local time and a calm marker of whether the studio is open (computed from that time and the opening hours,
|
|
5
|
+
// never invented), a phone line, the address, three or four plain links and a small foot line. Three compositions:
|
|
6
|
+
//
|
|
7
|
+
// - address: the mail address is the footer's headline, set at the top and fitted to the width with the loaded face
|
|
8
|
+
// (on a phone it breaks at the @ when one line would set too small). Its characters roll up into their own slots
|
|
9
|
+
// from blank as the footer arrives, staggered across the width, and on hover or focus each rolls up again to the
|
|
10
|
+
// same character, staggered from the side the pointer came in. The details close the footer as one ruled line.
|
|
11
|
+
// - clock: a large live clock for the studio's city is the centre, hours and minutes at display size, the colon
|
|
12
|
+
// breathing once a second and a changed digit rolling over on the minute (every digit in a slot as wide as the
|
|
13
|
+
// face's widest, so nothing shifts). The mail address leads the ruled rows under it, the phone, the address and the
|
|
14
|
+
// hours beneath it, like a timetable. Without scripts the slot holds the opening hours instead of a time.
|
|
15
|
+
// - columns: a quiet editorial grid. A short closing line at display size, then three columns (Contact, Studio, Volg
|
|
16
|
+
// ons); the address is a link whose underline draws from the side the pointer came in. The most restrained.
|
|
17
|
+
//
|
|
18
|
+
// <FooterContact variant="address" studio="Noord" email="hallo@noord.studio" phone={{ label: "010 214 63 80",
|
|
19
|
+
// href: "tel:+31102146380" }} address={["Maashaven Noordzijde 12", "3072 AA Rotterdam"]} city="Rotterdam"
|
|
20
|
+
// timeZone="Europe/Amsterdam" hours={{ days: [1, 2, 3, 4, 5], from: "9:00", to: "18:00" }} links={[...]} foot="..." />
|
|
21
|
+
//
|
|
22
|
+
// The entrance is scrubbed to the scroll over the footer's own arrival (from its top meeting the bottom of the screen
|
|
23
|
+
// to its bottom meeting it), with 0.4 s of smoothing, so scrolling back runs it backwards and a flick never stacks
|
|
24
|
+
// states. In the address and clock variants every part starts its move at the scroll position where it comes into
|
|
25
|
+
// view (its own offset in the footer), so nothing plays out below the fold. Each variant enters its own way: the
|
|
26
|
+
// address's characters roll into their slots; the clock's hours and minutes slide out from the colon; the closing
|
|
27
|
+
// line rises line by line and the columns follow. Transforms
|
|
28
|
+
// are whole pixels. Start states wait behind the `.sq-js` gate with a 3 s CSS fallback (blocks.css, "--- footer-contact
|
|
29
|
+
// ---"); the fitted type stays unseen until it is measured with the face it is read in (capped), and refits when a face
|
|
30
|
+
// lands or the width changes.
|
|
31
|
+
//
|
|
32
|
+
// The time is never rendered on the server: the first paint holds a stable placeholder (an unseen 00:00, an empty
|
|
33
|
+
// marker) and the real time arrives after mount, so there is nothing to mismatch. It ticks on the minute.
|
|
34
|
+
// Reduced motion: the composed footer, no travel; the colon holds and the digits cut; hovers show a plain underline.
|
|
35
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
|
|
36
|
+
import { gsap, ScrollTrigger, SplitText, useGSAP } from "../kit/gsap";
|
|
37
|
+
|
|
38
|
+
export const FOOTER_CONTACT_VARIANTS = [
|
|
39
|
+
{ id: "address", title: "Adres" },
|
|
40
|
+
{ id: "clock", title: "Klok" },
|
|
41
|
+
{ id: "columns", title: "Kolommen" },
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type FooterContactVariant = (typeof FOOTER_CONTACT_VARIANTS)[number]["id"];
|
|
45
|
+
|
|
46
|
+
type Link = { href: string; label: string };
|
|
47
|
+
|
|
48
|
+
type Props = {
|
|
49
|
+
variant?: FooterContactVariant;
|
|
50
|
+
studio: string;
|
|
51
|
+
email: string;
|
|
52
|
+
phone: Link;
|
|
53
|
+
/** The visiting address, one entry per line. */
|
|
54
|
+
address: string[];
|
|
55
|
+
city: string;
|
|
56
|
+
timeZone: string;
|
|
57
|
+
/** Opening hours: weekdays 1 (Monday) to 7 (Sunday), times as "9:00". */
|
|
58
|
+
hours: { days: number[]; from: string; to: string };
|
|
59
|
+
/** The closing line the columns variant opens with. */
|
|
60
|
+
line?: string;
|
|
61
|
+
/** Three or four plain links (the studio's channels). */
|
|
62
|
+
links: Link[];
|
|
63
|
+
/** The small foot line. */
|
|
64
|
+
foot: string;
|
|
65
|
+
className?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Seconds of smoothing on the scrub: a camera settling, never a delay. */
|
|
69
|
+
/** The address on one line, with a Dutch postcode (1234 AB) kept together so it never breaks across lines. */
|
|
70
|
+
const oneLine = (parts: string[]) => parts.map((l) => l.replace(/(\d{4}) ([A-Z]{2})/, "$1\u00A0$2")).join(", ");
|
|
71
|
+
|
|
72
|
+
const SMOOTH = 0.4;
|
|
73
|
+
/** The longest the fit and the line cut wait for the display face, in ms. */
|
|
74
|
+
const FONT_CAP = 1200;
|
|
75
|
+
/** Below this fitted size (px) the address breaks at the @ and fits its longer half instead. */
|
|
76
|
+
const BREAK_BELOW = 56;
|
|
77
|
+
/** The address never sets taller than this share of the screen's height. */
|
|
78
|
+
const ADDRESS_CAP = 0.3;
|
|
79
|
+
/** The clock fills this share of the width, and never more than this share of the height. */
|
|
80
|
+
const CLOCK_WIDTH = 0.82;
|
|
81
|
+
const CLOCK_CAP = 0.4;
|
|
82
|
+
/** The whole hover roll, first character to last, in seconds. */
|
|
83
|
+
const ROLL_SPAN = 0.36;
|
|
84
|
+
/** The soft rise of the details, in px. */
|
|
85
|
+
const SOFT = 24;
|
|
86
|
+
|
|
87
|
+
const DAYS = ["maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag", "zondag"];
|
|
88
|
+
const WEEKDAY: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
|
89
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
90
|
+
const toMinutes = (s: string) => {
|
|
91
|
+
const [h, m] = s.split(":").map(Number);
|
|
92
|
+
return (h || 0) * 60 + (m || 0);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
96
|
+
const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
|
|
97
|
+
const out3 = gsap.parseEase("power3.out");
|
|
98
|
+
const out2 = gsap.parseEase("power2.out");
|
|
99
|
+
const inOut2 = gsap.parseEase("power2.inOut");
|
|
100
|
+
|
|
101
|
+
/** Staggered windows for n items inside [from, to]: each runs `span` of it, the last one ends at `to`. */
|
|
102
|
+
function windows(n: number, from: number, to: number, span: number) {
|
|
103
|
+
const len = to - from;
|
|
104
|
+
if (n <= 1) return [[from, to]];
|
|
105
|
+
const step = (len * (1 - span)) / (n - 1);
|
|
106
|
+
return Array.from({ length: n }, (_, i) => [from + i * step, from + i * step + len * span]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A node's distance from the top of `root` in px, from layout (offsets ignore the transforms the scrub writes). */
|
|
110
|
+
function offsetIn(node: HTMLElement, root: HTMLElement) {
|
|
111
|
+
let y = 0;
|
|
112
|
+
let n: HTMLElement | null = node;
|
|
113
|
+
while (n && n !== root) {
|
|
114
|
+
y += n.offsetTop;
|
|
115
|
+
n = n.offsetParent as HTMLElement | null;
|
|
116
|
+
}
|
|
117
|
+
return n === root ? y : node.getBoundingClientRect().top - root.getBoundingClientRect().top;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The window a part enters in: from just before the scroll position where it comes into view (t), a little later the
|
|
121
|
+
* further right it stands (x), over about a quarter of the arrival, never shorter than 0.12 and never past the end. */
|
|
122
|
+
function ownWindow(t: number, x: number) {
|
|
123
|
+
const s = t - 0.04 + x * 0.08;
|
|
124
|
+
const end = Math.min(1, s + 0.28);
|
|
125
|
+
return [Math.min(s, end - 0.12), end] as const;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The widest of the digits 0 to 9 in the clock's own face, written to --fc-dw (in em) so every digit gets that slot. */
|
|
129
|
+
function setDigitWidth(clock: HTMLElement) {
|
|
130
|
+
const probe = document.createElement("span");
|
|
131
|
+
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font-size:100px";
|
|
132
|
+
clock.appendChild(probe);
|
|
133
|
+
let w = 0;
|
|
134
|
+
for (let d = 0; d < 10; d += 1) {
|
|
135
|
+
probe.textContent = String(d);
|
|
136
|
+
w = Math.max(w, probe.getBoundingClientRect().width);
|
|
137
|
+
}
|
|
138
|
+
probe.remove();
|
|
139
|
+
if (w > 0) clock.style.setProperty("--fc-dw", `${(w / 100).toFixed(4)}em`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The display face, waited for at most FONT_CAP ms. */
|
|
143
|
+
function faceReady() {
|
|
144
|
+
const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
|
|
145
|
+
return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The time now, only after mount (null on the server and the first paint), ticking on the minute. */
|
|
149
|
+
function useNow() {
|
|
150
|
+
const [now, setNow] = useState<Date | null>(null);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
let timer = 0;
|
|
153
|
+
const tick = () => {
|
|
154
|
+
const d = new Date();
|
|
155
|
+
setNow(d);
|
|
156
|
+
window.clearTimeout(timer);
|
|
157
|
+
timer = window.setTimeout(tick, 60_000 - (d.getSeconds() * 1000 + d.getMilliseconds()) + 30);
|
|
158
|
+
};
|
|
159
|
+
tick();
|
|
160
|
+
// A background tab's timers are held back; on return the clock catches up at once.
|
|
161
|
+
const onShow = () => {
|
|
162
|
+
if (document.visibilityState === "visible") tick();
|
|
163
|
+
};
|
|
164
|
+
document.addEventListener("visibilitychange", onShow);
|
|
165
|
+
return () => {
|
|
166
|
+
window.clearTimeout(timer);
|
|
167
|
+
document.removeEventListener("visibilitychange", onShow);
|
|
168
|
+
};
|
|
169
|
+
}, []);
|
|
170
|
+
return now;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type Wall = { dow: number; h: number; m: number; hh: string; mm: string; date: string };
|
|
174
|
+
|
|
175
|
+
/** The studio's wall clock and whether it is open, from the real time and the given hours. */
|
|
176
|
+
function useWall(now: Date | null, timeZone: string, hours: Props["hours"]) {
|
|
177
|
+
const fmt = useMemo(
|
|
178
|
+
() => ({
|
|
179
|
+
parts: new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }),
|
|
180
|
+
date: new Intl.DateTimeFormat("nl-NL", { timeZone, weekday: "long", day: "numeric", month: "long" }),
|
|
181
|
+
}),
|
|
182
|
+
[timeZone],
|
|
183
|
+
);
|
|
184
|
+
return useMemo(() => {
|
|
185
|
+
if (!now) return { wall: null, status: null };
|
|
186
|
+
const parts = fmt.parts.formatToParts(now);
|
|
187
|
+
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
188
|
+
const h = Number(get("hour")) % 24;
|
|
189
|
+
const m = Number(get("minute"));
|
|
190
|
+
const wall: Wall = {
|
|
191
|
+
dow: WEEKDAY[get("weekday")] ?? 1,
|
|
192
|
+
h,
|
|
193
|
+
m,
|
|
194
|
+
hh: String(h).padStart(2, "0"),
|
|
195
|
+
mm: String(m).padStart(2, "0"),
|
|
196
|
+
date: fmt.date.format(now),
|
|
197
|
+
};
|
|
198
|
+
const mins = h * 60 + m;
|
|
199
|
+
const from = toMinutes(hours.from);
|
|
200
|
+
const to = toMinutes(hours.to);
|
|
201
|
+
const openDay = (d: number) => hours.days.includes(d);
|
|
202
|
+
let status: { open: boolean; text: string };
|
|
203
|
+
if (openDay(wall.dow) && mins >= from && mins < to) status = { open: true, text: `Nu open, tot ${hours.to}` };
|
|
204
|
+
else if (openDay(wall.dow) && mins < from) status = { open: false, text: `Vandaag vanaf ${hours.from}` };
|
|
205
|
+
else {
|
|
206
|
+
let text = "";
|
|
207
|
+
for (let k = 1; k <= 7; k += 1) {
|
|
208
|
+
const d = ((wall.dow - 1 + k) % 7) + 1;
|
|
209
|
+
if (openDay(d)) {
|
|
210
|
+
text = k === 1 ? `Morgen vanaf ${hours.from}` : `${cap(DAYS[d - 1])} vanaf ${hours.from}`;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
status = { open: false, text };
|
|
215
|
+
}
|
|
216
|
+
return { wall, status };
|
|
217
|
+
}, [now, fmt, hours]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** "Maandag tot en met vrijdag, 9:00 tot 18:00" from the hours. */
|
|
221
|
+
function hoursText(hours: Props["hours"]) {
|
|
222
|
+
const days = [...hours.days].sort((a, b) => a - b);
|
|
223
|
+
if (!days.length) return "";
|
|
224
|
+
const first = DAYS[days[0] - 1];
|
|
225
|
+
const last = DAYS[days[days.length - 1] - 1];
|
|
226
|
+
const run = days.length === 1 ? cap(first) : `${cap(first)} tot en met ${last}`;
|
|
227
|
+
return `${run}, ${hours.from} tot ${hours.to}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The open marker: a small ring, filled while the studio is open, and the words. Empty until the time is known. */
|
|
231
|
+
function OpenMark({ status }: { status: { open: boolean; text: string } | null }) {
|
|
232
|
+
return (
|
|
233
|
+
<span data-fc-mark="" className="inline-flex items-center gap-[0.6em]">
|
|
234
|
+
<span aria-hidden data-fc-dot="" data-open={status?.open ? "" : undefined} data-pending={status ? undefined : ""} />
|
|
235
|
+
<span data-fc-fade="" data-pending={status ? undefined : ""}>{status ? status.text : " "}</span>
|
|
236
|
+
</span>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** "Rotterdam 14:32": the city, and the local time once it is known (an unseen placeholder until then). */
|
|
241
|
+
function CityTime({ city, wall }: { city: string; wall: Wall | null }) {
|
|
242
|
+
return (
|
|
243
|
+
<span className="tabular-nums">
|
|
244
|
+
{city}{" "}
|
|
245
|
+
<time data-fc-fade="" data-pending={wall ? undefined : ""} dateTime={wall ? `${wall.hh}:${wall.mm}` : undefined}>
|
|
246
|
+
{wall ? `${wall.hh}:${wall.mm}` : "00:00"}
|
|
247
|
+
</time>
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A plain link whose underline draws from the side the pointer came in, and retracts toward the side it leaves. */
|
|
253
|
+
function DrawLink({ href, children, className = "" }: { href: string; children: ReactNode; className?: string }) {
|
|
254
|
+
const entered = useRef(0);
|
|
255
|
+
const side = (e: PointerEvent<HTMLAnchorElement>) => {
|
|
256
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
257
|
+
return e.clientX - r.left < r.width / 2 ? "left" : "right";
|
|
258
|
+
};
|
|
259
|
+
const external = /^https?:/.test(href);
|
|
260
|
+
return (
|
|
261
|
+
<a
|
|
262
|
+
href={href}
|
|
263
|
+
data-fc-draw=""
|
|
264
|
+
className={`relative inline-block ${className}`}
|
|
265
|
+
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
266
|
+
onPointerEnter={(e) => {
|
|
267
|
+
entered.current = performance.now();
|
|
268
|
+
e.currentTarget.style.setProperty("--fc-o", side(e));
|
|
269
|
+
}}
|
|
270
|
+
onPointerLeave={(e) => {
|
|
271
|
+
// Only a finished line changes side; one still drawing goes back the way it came.
|
|
272
|
+
if (performance.now() - entered.current > 480) e.currentTarget.style.setProperty("--fc-o", side(e));
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{children}
|
|
276
|
+
</a>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The address as rolling characters: each in its own mask, a second copy waiting below it. `parts` splits it in
|
|
281
|
+
* halves that can stand on two lines (at the @). With `enter`, every character also sits on a layer the scroll lifts
|
|
282
|
+
* into its slot from blank. Read once by screen readers, from the hidden plain copy. */
|
|
283
|
+
function RollAddress({ email, parts, className = "", enter = false }: { email: string; parts: string[]; className?: string; enter?: boolean }) {
|
|
284
|
+
const total = parts.join("").length;
|
|
285
|
+
let index = 0;
|
|
286
|
+
const onPointer = (e: PointerEvent<HTMLAnchorElement>) => {
|
|
287
|
+
// Every character's delay from its distance to the edge the pointer is on, so two lines stagger by column.
|
|
288
|
+
const a = e.currentTarget;
|
|
289
|
+
const r = a.getBoundingClientRect();
|
|
290
|
+
const fromLeft = e.clientX - r.left < r.width / 2;
|
|
291
|
+
a.querySelectorAll<HTMLElement>("[data-fc-ch]").forEach((c) => {
|
|
292
|
+
const b = c.getBoundingClientRect();
|
|
293
|
+
const x = (b.left + b.width / 2 - r.left) / (r.width || 1);
|
|
294
|
+
c.style.setProperty("--d", `${((fromLeft ? x : 1 - x) * ROLL_SPAN).toFixed(3)}s`);
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
return (
|
|
298
|
+
<a href={`mailto:${email}`} data-fc-address="" data-fc-roll-host="" className={className} onPointerEnter={onPointer} onPointerLeave={onPointer}>
|
|
299
|
+
<span className="sr-only">{email}</span>
|
|
300
|
+
<span aria-hidden data-fc-parts="">
|
|
301
|
+
{parts.map((part, p) => (
|
|
302
|
+
<span key={p} data-fc-part="">
|
|
303
|
+
{Array.from(part).map((ch) => {
|
|
304
|
+
const i = index++;
|
|
305
|
+
const roll = (
|
|
306
|
+
<span data-fc-roll="">
|
|
307
|
+
<span>{ch}</span>
|
|
308
|
+
<span data-fc-copy="">{ch}</span>
|
|
309
|
+
</span>
|
|
310
|
+
);
|
|
311
|
+
return (
|
|
312
|
+
<span key={i} data-fc-ch="" style={{ "--d": `${((i / Math.max(1, total - 1)) * ROLL_SPAN).toFixed(3)}s` } as CSSProperties}>
|
|
313
|
+
{enter ? <span data-fc-enter="">{roll}</span> : roll}
|
|
314
|
+
</span>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
317
|
+
</span>
|
|
318
|
+
))}
|
|
319
|
+
</span>
|
|
320
|
+
</a>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** One clock digit in its mask: on a change the old one rolls up and out and the new one rises in after it. */
|
|
325
|
+
function Digit({ ch }: { ch: string }) {
|
|
326
|
+
const [s, setS] = useState<{ cur: string; prev: string | null; n: number }>({ cur: ch, prev: null, n: 0 });
|
|
327
|
+
if (s.cur !== ch) setS({ cur: ch, prev: s.cur, n: s.n + 1 });
|
|
328
|
+
return (
|
|
329
|
+
<span data-fc-digit="">
|
|
330
|
+
{s.prev !== null ? (
|
|
331
|
+
<span key={`o${s.n}`} data-fc-out="">
|
|
332
|
+
{s.prev}
|
|
333
|
+
</span>
|
|
334
|
+
) : null}
|
|
335
|
+
<span key={`i${s.n}`} data-fc-in={s.n ? "" : undefined} className="block">
|
|
336
|
+
{s.cur}
|
|
337
|
+
</span>
|
|
338
|
+
</span>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Fits `el` (measured at 100px) into `room` px, capped. Returns the size it set. */
|
|
343
|
+
function fitTo(el: HTMLElement, width: () => number, room: number, max: number) {
|
|
344
|
+
el.style.fontSize = "100px";
|
|
345
|
+
const w = width() || 1;
|
|
346
|
+
const size = Math.min((100 * room) / w, max);
|
|
347
|
+
el.style.fontSize = `${Math.floor(size * 100) / 100}px`;
|
|
348
|
+
return size;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function FooterContact({
|
|
352
|
+
variant = "address",
|
|
353
|
+
studio,
|
|
354
|
+
email,
|
|
355
|
+
phone,
|
|
356
|
+
address,
|
|
357
|
+
city,
|
|
358
|
+
timeZone,
|
|
359
|
+
hours,
|
|
360
|
+
line = "Zullen we een keer komen kijken?",
|
|
361
|
+
links,
|
|
362
|
+
foot,
|
|
363
|
+
className = "",
|
|
364
|
+
}: Props) {
|
|
365
|
+
const root = useRef<HTMLElement>(null);
|
|
366
|
+
const now = useNow();
|
|
367
|
+
const { wall, status } = useWall(now, timeZone, hours);
|
|
368
|
+
const known = wall !== null;
|
|
369
|
+
const at = email.indexOf("@");
|
|
370
|
+
const halves = at > 0 ? [email.slice(0, at), email.slice(at)] : [email];
|
|
371
|
+
|
|
372
|
+
// The colon breathes on the second: its cycle starts where the current second already is.
|
|
373
|
+
const colon = useRef<HTMLSpanElement>(null);
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (now && colon.current && !colon.current.dataset.synced) {
|
|
376
|
+
colon.current.style.animationDelay = `${-(now.getMilliseconds() / 1000).toFixed(3)}s`;
|
|
377
|
+
colon.current.dataset.synced = "1";
|
|
378
|
+
}
|
|
379
|
+
}, [now]);
|
|
380
|
+
|
|
381
|
+
// Fit with the face it is read in: the address to the width (breaking at the @ on a narrow screen), the clock to its
|
|
382
|
+
// share of the width. Unseen until fitted; refits when a face lands or the width changes.
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
const el = root.current;
|
|
385
|
+
if (!el) return;
|
|
386
|
+
if (variant === "columns") {
|
|
387
|
+
el.dataset.fit = "1";
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let alive = true;
|
|
391
|
+
let lastWidth = -1;
|
|
392
|
+
let raf = 0;
|
|
393
|
+
const fit = () => {
|
|
394
|
+
if (!alive) return;
|
|
395
|
+
if (variant === "address") {
|
|
396
|
+
const link = el.querySelector<HTMLElement>("[data-fc-address]");
|
|
397
|
+
const box = link?.parentElement;
|
|
398
|
+
const parts = Array.from(el.querySelectorAll<HTMLElement>("[data-fc-address] [data-fc-part]"));
|
|
399
|
+
if (!link || !box || !parts.length) return;
|
|
400
|
+
const room = box.clientWidth;
|
|
401
|
+
const max = window.innerHeight * ADDRESS_CAP;
|
|
402
|
+
link.dataset.lines = "1";
|
|
403
|
+
let size = fitTo(link, () => parts.reduce((s, p) => s + p.getBoundingClientRect().width, 0), room, max);
|
|
404
|
+
if (size < BREAK_BELOW && parts.length > 1) {
|
|
405
|
+
link.dataset.lines = "2";
|
|
406
|
+
size = fitTo(link, () => Math.max(...parts.map((p) => p.getBoundingClientRect().width)), room, max);
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const clock = el.querySelector<HTMLElement>("[data-fc-clock]");
|
|
410
|
+
if (!clock) return;
|
|
411
|
+
setDigitWidth(clock);
|
|
412
|
+
const room = (clock.parentElement?.clientWidth ?? el.clientWidth) * CLOCK_WIDTH;
|
|
413
|
+
fitTo(clock, () => clock.scrollWidth, room, window.innerHeight * CLOCK_CAP);
|
|
414
|
+
}
|
|
415
|
+
el.dataset.fit = "1";
|
|
416
|
+
cancelAnimationFrame(raf);
|
|
417
|
+
raf = requestAnimationFrame(() => ScrollTrigger.refresh());
|
|
418
|
+
};
|
|
419
|
+
faceReady().then(fit);
|
|
420
|
+
const onFonts = () => fit();
|
|
421
|
+
document.fonts?.addEventListener?.("loadingdone", onFonts);
|
|
422
|
+
const ro = new ResizeObserver(() => {
|
|
423
|
+
if (el.clientWidth === lastWidth) return;
|
|
424
|
+
lastWidth = el.clientWidth;
|
|
425
|
+
if (el.dataset.fit) fit();
|
|
426
|
+
});
|
|
427
|
+
ro.observe(el);
|
|
428
|
+
return () => {
|
|
429
|
+
alive = false;
|
|
430
|
+
cancelAnimationFrame(raf);
|
|
431
|
+
ro.disconnect();
|
|
432
|
+
document.fonts?.removeEventListener?.("loadingdone", onFonts);
|
|
433
|
+
};
|
|
434
|
+
}, [variant, email]);
|
|
435
|
+
|
|
436
|
+
// The clock refits once the real digits replace the placeholder.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (variant !== "clock" || !known) return;
|
|
439
|
+
const el = root.current;
|
|
440
|
+
const clock = el?.querySelector<HTMLElement>("[data-fc-clock]");
|
|
441
|
+
if (!el?.dataset.fit || !clock) return;
|
|
442
|
+
setDigitWidth(clock);
|
|
443
|
+
const room = (clock.parentElement?.clientWidth ?? el.clientWidth) * CLOCK_WIDTH;
|
|
444
|
+
fitTo(clock, () => clock.scrollWidth, room, window.innerHeight * CLOCK_CAP);
|
|
445
|
+
const raf = requestAnimationFrame(() => ScrollTrigger.refresh());
|
|
446
|
+
return () => cancelAnimationFrame(raf);
|
|
447
|
+
}, [variant, known]);
|
|
448
|
+
|
|
449
|
+
useGSAP(
|
|
450
|
+
() => {
|
|
451
|
+
const el = root.current;
|
|
452
|
+
if (!el) return;
|
|
453
|
+
const kind = (el.dataset.footerContact ?? "address") as FooterContactVariant;
|
|
454
|
+
const rules = Array.from(el.querySelectorAll<HTMLElement>("[data-fc-rule]"));
|
|
455
|
+
const softs = Array.from(el.querySelectorAll<HTMLElement>("[data-fc-soft]"));
|
|
456
|
+
const chars = Array.from(el.querySelectorAll<HTMLElement>("[data-fc-enter]"));
|
|
457
|
+
const clockEl = el.querySelector<HTMLElement>("[data-fc-clock]");
|
|
458
|
+
const head = el.querySelector<HTMLElement>("[data-fc-split]");
|
|
459
|
+
const hSlide = el.querySelector<HTMLElement>("[data-fc-h]");
|
|
460
|
+
const mSlide = el.querySelector<HTMLElement>("[data-fc-m]");
|
|
461
|
+
const colonWrap = el.querySelector<HTMLElement>("[data-fc-colon-wrap]");
|
|
462
|
+
|
|
463
|
+
const mm = gsap.matchMedia();
|
|
464
|
+
mm.add("(prefers-reduced-motion: reduce)", () => {
|
|
465
|
+
el.dataset.ready = "1";
|
|
466
|
+
});
|
|
467
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
468
|
+
let alive = true;
|
|
469
|
+
let split: SplitText | null = null;
|
|
470
|
+
let lines: HTMLElement[] = [];
|
|
471
|
+
let heights: number[] = [];
|
|
472
|
+
let hW = 0;
|
|
473
|
+
let mW = 0;
|
|
474
|
+
// Where each part comes into view, as scroll progress over the footer's arrival (its offset / the height),
|
|
475
|
+
// and how far right it stands (0 to 1), so every move starts once its part is on screen.
|
|
476
|
+
let charAt: number[] = [];
|
|
477
|
+
let charX: number[] = [];
|
|
478
|
+
let charH = 0;
|
|
479
|
+
let softAt: number[] = [];
|
|
480
|
+
let softX: number[] = [];
|
|
481
|
+
let ruleAt: number[] = [];
|
|
482
|
+
let ruleX: number[] = [];
|
|
483
|
+
let clockAt = 0;
|
|
484
|
+
const prog = { p: 0 };
|
|
485
|
+
|
|
486
|
+
const measure = () => {
|
|
487
|
+
hW = hSlide?.parentElement?.offsetWidth ?? 0;
|
|
488
|
+
mW = mSlide?.parentElement?.offsetWidth ?? 0;
|
|
489
|
+
if (lines.length) heights = lines.map((l) => l.offsetHeight);
|
|
490
|
+
const H = el.offsetHeight || 1;
|
|
491
|
+
const box = el.getBoundingClientRect();
|
|
492
|
+
const at = (n: HTMLElement) => offsetIn(n, el) / H;
|
|
493
|
+
const x = (n: HTMLElement) => clamp01((n.getBoundingClientRect().left - box.left) / (box.width || 1));
|
|
494
|
+
softAt = softs.map(at);
|
|
495
|
+
softX = softs.map(x);
|
|
496
|
+
ruleAt = rules.map(at);
|
|
497
|
+
ruleX = rules.map(x);
|
|
498
|
+
clockAt = clockEl ? at(clockEl) : 0;
|
|
499
|
+
// The characters stagger across the address's own width, so two lines on a phone stagger by column.
|
|
500
|
+
const host = chars[0]?.closest<HTMLElement>("[data-fc-address]")?.getBoundingClientRect();
|
|
501
|
+
charAt = chars.map(at);
|
|
502
|
+
charX = chars.map((c) => {
|
|
503
|
+
const b = c.getBoundingClientRect();
|
|
504
|
+
return host ? clamp01((b.left + b.width / 2 - host.left) / (host.width || 1)) : 0;
|
|
505
|
+
});
|
|
506
|
+
charH = chars[0]?.offsetHeight ?? 0;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const rise = (items: HTMLElement[], hs: number[], from: number, to: number, span: number) => {
|
|
510
|
+
const spans = windows(items.length, from, to, span);
|
|
511
|
+
items.forEach((it, i) => {
|
|
512
|
+
const e = out3(within(prog.p, spans[i][0], spans[i][1]));
|
|
513
|
+
it.style.transform = `translate3d(0, ${Math.round((1 - e) * (hs[i] ?? 0) * 1.08)}px, 0)`;
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
const draw = (from: number, to: number) => {
|
|
517
|
+
const spans = windows(rules.length, from, to, 0.75);
|
|
518
|
+
rules.forEach((r, i) => {
|
|
519
|
+
r.style.transform = `scaleX(${inOut2(within(prog.p, spans[i][0], spans[i][1])).toFixed(4)})`;
|
|
520
|
+
});
|
|
521
|
+
};
|
|
522
|
+
const soften = (from: number, to: number) => {
|
|
523
|
+
const spans = windows(softs.length, from, to, 0.6);
|
|
524
|
+
softs.forEach((s, i) => {
|
|
525
|
+
const e = out2(within(prog.p, spans[i][0], spans[i][1]));
|
|
526
|
+
s.style.transform = `translate3d(0, ${Math.round((1 - e) * SOFT)}px, 0)`;
|
|
527
|
+
s.style.opacity = e.toFixed(3);
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// Rules and details each from where they come into view.
|
|
532
|
+
const drawOwn = () => {
|
|
533
|
+
rules.forEach((r, i) => {
|
|
534
|
+
const [a, b] = ownWindow(ruleAt[i] ?? 0, ruleX[i] ?? 0);
|
|
535
|
+
r.style.transform = `scaleX(${inOut2(within(prog.p, a, b)).toFixed(4)})`;
|
|
536
|
+
});
|
|
537
|
+
};
|
|
538
|
+
const softenOwn = () => {
|
|
539
|
+
softs.forEach((s, i) => {
|
|
540
|
+
const [a, b] = ownWindow(softAt[i] ?? 0, softX[i] ?? 0);
|
|
541
|
+
const e = out2(within(prog.p, a, b));
|
|
542
|
+
s.style.transform = `translate3d(0, ${Math.round((1 - e) * SOFT)}px, 0)`;
|
|
543
|
+
s.style.opacity = e.toFixed(3);
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const paint = () => {
|
|
548
|
+
if (!alive) return;
|
|
549
|
+
if (kind === "address") {
|
|
550
|
+
// Every character rolls up into its slot from blank, starting once its line is on screen and running
|
|
551
|
+
// left to right across the width; the last one lands just before the footer's foot reaches the edge.
|
|
552
|
+
chars.forEach((c, i) => {
|
|
553
|
+
const from = (charAt[i] ?? 0) + 0.03;
|
|
554
|
+
const len = Math.min(1, Math.max(0.92, from + 0.25)) - from;
|
|
555
|
+
const s = from + (charX[i] ?? 0) * len * 0.5;
|
|
556
|
+
const e = out3(within(prog.p, s, s + len * 0.5));
|
|
557
|
+
c.style.transform = `translate3d(0, ${Math.round((1 - e) * charH)}px, 0)`;
|
|
558
|
+
});
|
|
559
|
+
drawOwn();
|
|
560
|
+
softenOwn();
|
|
561
|
+
} else if (kind === "clock") {
|
|
562
|
+
const p0 = clockAt;
|
|
563
|
+
if (colonWrap) colonWrap.style.opacity = out2(within(prog.p, p0, p0 + 0.1)).toFixed(3);
|
|
564
|
+
// The hours slide out from behind the colon to the left, the minutes to the right.
|
|
565
|
+
const e = out3(within(prog.p, p0 + 0.05, Math.min(1, Math.max(0.8, p0 + 0.35))));
|
|
566
|
+
if (hSlide) hSlide.style.transform = `translate3d(${Math.round((1 - e) * hW)}px, 0, 0)`;
|
|
567
|
+
if (mSlide) mSlide.style.transform = `translate3d(${-Math.round((1 - e) * mW)}px, 0, 0)`;
|
|
568
|
+
drawOwn();
|
|
569
|
+
softenOwn();
|
|
570
|
+
} else {
|
|
571
|
+
rise(lines, heights, 0.02, 0.5, 0.7);
|
|
572
|
+
draw(0.3, 0.7);
|
|
573
|
+
soften(0.4, 1);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
gsap.to(prog, {
|
|
578
|
+
p: 1,
|
|
579
|
+
ease: "none",
|
|
580
|
+
onUpdate: paint,
|
|
581
|
+
scrollTrigger: { trigger: el, start: "top bottom", end: "bottom bottom", scrub: SMOOTH },
|
|
582
|
+
});
|
|
583
|
+
const remeasure = () => {
|
|
584
|
+
measure();
|
|
585
|
+
paint();
|
|
586
|
+
};
|
|
587
|
+
ScrollTrigger.addEventListener("refresh", remeasure);
|
|
588
|
+
measure();
|
|
589
|
+
|
|
590
|
+
if (kind === "columns" && head) {
|
|
591
|
+
// The closing line is cut with the face it is read in; until then it stays unseen (the CSS gate).
|
|
592
|
+
faceReady().then(() => {
|
|
593
|
+
if (!alive) return;
|
|
594
|
+
split = SplitText.create(head, {
|
|
595
|
+
type: "lines",
|
|
596
|
+
mask: "lines",
|
|
597
|
+
linesClass: "struq-line",
|
|
598
|
+
autoSplit: true,
|
|
599
|
+
onSplit(self) {
|
|
600
|
+
lines = self.lines as HTMLElement[];
|
|
601
|
+
heights = lines.map((l) => l.offsetHeight);
|
|
602
|
+
paint();
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
el.dataset.ready = "1";
|
|
606
|
+
});
|
|
607
|
+
paint();
|
|
608
|
+
} else {
|
|
609
|
+
paint();
|
|
610
|
+
el.dataset.ready = "1";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return () => {
|
|
614
|
+
alive = false;
|
|
615
|
+
ScrollTrigger.removeEventListener("refresh", remeasure);
|
|
616
|
+
split?.revert();
|
|
617
|
+
for (const n of [...rules, ...softs, ...chars, hSlide, mSlide, colonWrap]) {
|
|
618
|
+
if (!n) continue;
|
|
619
|
+
n.style.transform = "";
|
|
620
|
+
n.style.opacity = "";
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
return () => mm.revert();
|
|
625
|
+
},
|
|
626
|
+
{ scope: root, dependencies: [variant] },
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const time = wall ? `${wall.hh}:${wall.mm}` : "";
|
|
630
|
+
const telLink = <DrawLink href={phone.href}>{phone.label}</DrawLink>;
|
|
631
|
+
const linkList = (cls: string) => (
|
|
632
|
+
<ul className={cls}>
|
|
633
|
+
{links.map((l) => (
|
|
634
|
+
<li key={l.href}>
|
|
635
|
+
<DrawLink href={l.href}>{l.label}</DrawLink>
|
|
636
|
+
</li>
|
|
637
|
+
))}
|
|
638
|
+
</ul>
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
if (variant === "clock") {
|
|
642
|
+
const digits = (s: string) => Array.from(s).map((c, i) => <Digit key={i} ch={c} />);
|
|
643
|
+
// The mail address leads as a full-width row; the phone, the address and the hours stand beneath it.
|
|
644
|
+
const rows: { label: string; value: ReactNode }[] = [
|
|
645
|
+
{ label: "Bel", value: telLink },
|
|
646
|
+
{ label: "Adres", value: oneLine(address) },
|
|
647
|
+
{ label: "Open", value: hoursText(hours) },
|
|
648
|
+
];
|
|
649
|
+
const row = (label: string, value: ReactNode, valueClass = "text-body") => (
|
|
650
|
+
<div key={label} className="relative grid grid-cols-[5.5rem_1fr] items-baseline gap-x-6 py-4 md:grid-cols-[7rem_1fr]">
|
|
651
|
+
<span aria-hidden data-fc-rule="" className="absolute inset-x-0 top-0 h-px bg-line" />
|
|
652
|
+
<span data-fc-soft="" className="text-small text-ink-muted">
|
|
653
|
+
{label}
|
|
654
|
+
</span>
|
|
655
|
+
<div data-fc-soft="" className={`min-w-0 ${valueClass}`}>
|
|
656
|
+
{value}
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
);
|
|
660
|
+
return (
|
|
661
|
+
<footer
|
|
662
|
+
ref={root}
|
|
663
|
+
data-footer-contact="clock"
|
|
664
|
+
className={`relative flex min-h-svh flex-col bg-surface-0 px-6 pb-6 pt-16 text-ink md:px-[3vw] md:pt-[10svh] ${className}`}
|
|
665
|
+
>
|
|
666
|
+
<div className="flex flex-wrap items-start justify-between gap-x-10 gap-y-4 text-body">
|
|
667
|
+
<p data-fc-soft="">
|
|
668
|
+
{studio}, {city}
|
|
669
|
+
</p>
|
|
670
|
+
<p data-fc-soft="" className="text-ink-muted">
|
|
671
|
+
<OpenMark status={status} />
|
|
672
|
+
</p>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div className="flex flex-1 flex-col items-center justify-center py-[8svh]">
|
|
676
|
+
<p className="relative w-full text-center">
|
|
677
|
+
<span className="sr-only">{wall ? `Het is nu ${time} in ${city}.` : `De tijd in ${city}.`}</span>
|
|
678
|
+
<span aria-hidden data-fc-clock="" data-pending={known ? undefined : ""} className="font-display">
|
|
679
|
+
<span data-fc-hmask="">
|
|
680
|
+
<span data-fc-h="" className="block">
|
|
681
|
+
{known ? digits(wall.hh) : "00"}
|
|
682
|
+
</span>
|
|
683
|
+
</span>
|
|
684
|
+
<span data-fc-colon-wrap="">
|
|
685
|
+
<span ref={colon} data-fc-colon="">
|
|
686
|
+
:
|
|
687
|
+
</span>
|
|
688
|
+
</span>
|
|
689
|
+
<span data-fc-mmask="">
|
|
690
|
+
<span data-fc-m="" className="block">
|
|
691
|
+
{known ? digits(wall.mm) : "00"}
|
|
692
|
+
</span>
|
|
693
|
+
</span>
|
|
694
|
+
</span>
|
|
695
|
+
<noscript>
|
|
696
|
+
<span className="block font-display text-h2">{hoursText(hours)}</span>
|
|
697
|
+
</noscript>
|
|
698
|
+
</p>
|
|
699
|
+
<p data-fc-soft="" className="mt-4 text-body text-ink-muted">
|
|
700
|
+
<span data-fc-fade="" data-pending={known ? undefined : ""}>{wall ? cap(wall.date) : " "}</span>
|
|
701
|
+
</p>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
{row("Mail", <RollAddress email={email} parts={halves} className="font-display" />, "text-h3")}
|
|
705
|
+
<div className="grid md:grid-cols-3 md:gap-x-[3vw]">{rows.map((r) => row(r.label, r.value))}</div>
|
|
706
|
+
<div className="relative mt-2 flex flex-col gap-3 pt-4 text-small text-ink-muted md:flex-row md:items-baseline md:justify-between">
|
|
707
|
+
<span aria-hidden data-fc-rule="" className="absolute inset-x-0 top-0 h-px bg-line" />
|
|
708
|
+
<p data-fc-soft="">{foot}</p>
|
|
709
|
+
<div data-fc-soft="">{linkList("flex flex-wrap gap-x-6 gap-y-2 text-ink")}</div>
|
|
710
|
+
</div>
|
|
711
|
+
</footer>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (variant === "columns") {
|
|
716
|
+
return (
|
|
717
|
+
<footer
|
|
718
|
+
ref={root}
|
|
719
|
+
data-footer-contact="columns"
|
|
720
|
+
className={`relative bg-surface-0 px-6 pb-6 pt-[14svh] text-ink md:px-[4vw] md:pt-[18svh] ${className}`}
|
|
721
|
+
>
|
|
722
|
+
<h2 data-fc-split="" className="font-display max-w-[13ch] text-display">
|
|
723
|
+
{line}
|
|
724
|
+
</h2>
|
|
725
|
+
<div className="relative mt-[10svh] grid gap-10 pt-8 md:grid-cols-12 md:gap-x-[2vw]">
|
|
726
|
+
<span aria-hidden data-fc-rule="" className="absolute inset-x-0 top-0 h-px bg-line" />
|
|
727
|
+
<div data-fc-soft="" className="md:col-span-5">
|
|
728
|
+
<h3 className="text-small text-ink-muted">Contact</h3>
|
|
729
|
+
<p className="mt-4">
|
|
730
|
+
<DrawLink href={`mailto:${email}`} className="text-h3 font-display">
|
|
731
|
+
{email}
|
|
732
|
+
</DrawLink>
|
|
733
|
+
</p>
|
|
734
|
+
<p className="mt-3 text-body">{telLink}</p>
|
|
735
|
+
</div>
|
|
736
|
+
<div data-fc-soft="" className="md:col-span-4 md:col-start-7">
|
|
737
|
+
<h3 className="text-small text-ink-muted">Studio</h3>
|
|
738
|
+
<address className="mt-4 text-body not-italic">
|
|
739
|
+
{address.map((a) => (
|
|
740
|
+
<span key={a} className="block">
|
|
741
|
+
{a}
|
|
742
|
+
</span>
|
|
743
|
+
))}
|
|
744
|
+
</address>
|
|
745
|
+
<p className="mt-4 text-body">
|
|
746
|
+
<CityTime city={city} wall={wall} />
|
|
747
|
+
</p>
|
|
748
|
+
<p className="mt-1 text-small text-ink-muted">
|
|
749
|
+
<OpenMark status={status} />
|
|
750
|
+
</p>
|
|
751
|
+
</div>
|
|
752
|
+
<div data-fc-soft="" className="md:col-span-2 md:col-start-11">
|
|
753
|
+
<h3 className="text-small text-ink-muted">Volg ons</h3>
|
|
754
|
+
{linkList("mt-4 flex flex-col gap-1 text-body")}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div className="mt-[12svh] flex flex-col gap-2 text-small text-ink-muted md:flex-row md:justify-between">
|
|
758
|
+
<p data-fc-soft="">{foot}</p>
|
|
759
|
+
<p data-fc-soft="">{hoursText(hours)}</p>
|
|
760
|
+
</div>
|
|
761
|
+
</footer>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<footer
|
|
767
|
+
ref={root}
|
|
768
|
+
data-footer-contact="address"
|
|
769
|
+
className={`relative bg-surface-0 px-6 pb-6 pt-[14svh] text-ink md:px-[3vw] ${className}`}
|
|
770
|
+
>
|
|
771
|
+
<div>
|
|
772
|
+
<RollAddress email={email} parts={halves} enter className="font-display" />
|
|
773
|
+
</div>
|
|
774
|
+
<div className="relative mt-[12svh] flex flex-wrap items-baseline gap-x-8 gap-y-2 pt-5 text-small text-ink-muted md:justify-between">
|
|
775
|
+
<span aria-hidden data-fc-rule="" className="absolute inset-x-0 top-0 h-px bg-line" />
|
|
776
|
+
<p data-fc-soft="" className="text-ink">
|
|
777
|
+
{telLink}
|
|
778
|
+
</p>
|
|
779
|
+
<p data-fc-soft="">{oneLine(address)}</p>
|
|
780
|
+
<p data-fc-soft="" className="flex flex-wrap gap-x-4">
|
|
781
|
+
<CityTime city={city} wall={wall} />
|
|
782
|
+
<OpenMark status={status} />
|
|
783
|
+
</p>
|
|
784
|
+
<div data-fc-soft="">{linkList("flex gap-x-5 text-ink")}</div>
|
|
785
|
+
<p data-fc-soft="">{foot}</p>
|
|
786
|
+
</div>
|
|
787
|
+
</footer>
|
|
788
|
+
);
|
|
789
|
+
}
|