@okjt/site 0.0.0-stage → 0.2.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 CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # @okjt/site
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The public pages of an OKJTech project: layout, navigation, footer, SEO, and
4
+ the sections pages are built from (photo hero, feature explorer, showcase,
5
+ pricing, FAQ, legal documents). Colours and fonts come from `@okjt/ui`'s
6
+ theme; words, links, pictures and screens come from the project's `site`
7
+ settings.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i @okjt/site
13
+ npm i -D @okjt/ui
14
+ ```
15
+
16
+ Peer dependencies: `react`, `react-router-dom`, `framer-motion`, `lucide-react`.
17
+
18
+ Let Tailwind see the kit's class names, after the theme (see `@okjt/ui`):
19
+
20
+ ```css
21
+ @import "tailwindcss";
22
+ @import "./okjt-theme.css";
23
+ @source "../node_modules/@okjt/site/dist";
24
+ ```
25
+
26
+ ## Set it up
27
+
28
+ ```jsx
29
+ import { SiteProvider } from '@okjt/site';
30
+ import { site } from './site.config';
31
+
32
+ <SiteProvider site={site}>
33
+ <App />
34
+ </SiteProvider>
35
+ ```
36
+
37
+ ```jsx
38
+ // src/site.config.jsx
39
+ import { ShieldCheck, FileText } from 'lucide-react';
40
+ import { useAuth } from './context/AuthContext';
41
+
42
+ export const site = {
43
+ brand: { name: 'Najenga', logo: { light: '/brand/logo-light.png', dark: '/brand/logo-dark.png' } },
44
+
45
+ // `mega: { Menu, MobileList, id, buttonLabel }` on one item opens a large menu under the bar.
46
+ nav: [
47
+ { to: '/', label: 'Home' },
48
+ { to: '/features', label: 'Features' },
49
+ { to: '/#pricing', label: 'Pricing' },
50
+ ],
51
+
52
+ useAccount: () => ({ signedIn: useAuth().isAuthenticated }),
53
+ accountLinks: {
54
+ signIn: { to: '/sign-in', label: 'Sign in' },
55
+ signUp: { to: '/sign-up', label: 'Get started' },
56
+ home: { to: '/projects', label: 'Your projects' },
57
+ },
58
+
59
+ // Photos are keys; these two hooks turn a key into a URL and alt text, and a
60
+ // named place on the pages ("footer", "group-money") into its photo(s).
61
+ usePhotos: () => ({ url: (key, width) => `/photos/${key}${width <= 900 ? '-sm' : ''}.webp`, alt: (key) => ALT[key] }),
62
+ usePlaces: () => (place) => PLACES[place],
63
+
64
+ // Product screens and clips: { src, alt, url, video?, phone? }.
65
+ media: { dashboard: { src: '/screens/dashboard.webp', alt: 'The dashboard', url: 'najenga.co.ke/dashboard' } },
66
+
67
+ footer: {
68
+ about: 'Najenga keeps every shilling and every site photo of your build in one place.',
69
+ columns: [{ title: 'Product', links: [{ to: '/features', label: 'Features' }, { to: '/sign-in', label: 'Sign in', signedIn: false }] }],
70
+ promises: [{ icon: ShieldCheck, text: 'Your records stay yours' }],
71
+ cta: {
72
+ place: 'footer', eyebrow: 'Start today', title: 'Build with your eyes open.', text: 'Set up a project in a minute.',
73
+ primary: { to: '/sign-up', label: 'Start a project' }, secondary: { to: '/features', label: 'Explore features' },
74
+ },
75
+ watermark: 'najenga.',
76
+ copyright: 'Najenga',
77
+ madeBy: { label: 'Built to last by', name: 'OKJ Technologies', href: 'https://okjtech.co.ke', logo: '/okjt-logo.png' },
78
+ },
79
+
80
+ legal: {
81
+ documents: [{ to: '/terms', label: 'Terms of service', icon: FileText }],
82
+ contact: 'Contact OKJ Technologies through okjtech.co.ke.',
83
+ lastUpdated: '2026-10-29',
84
+ seeAlso: [{ to: '/terms', label: 'Terms of service' }],
85
+ },
86
+
87
+ seo: {
88
+ origin: 'https://najenga.co.ke',
89
+ siteName: 'Najenga',
90
+ locale: 'en_KE',
91
+ defaultTitle: 'Najenga — building, accounted for',
92
+ defaultDescription: 'Track the costs, receipts and progress of your construction projects.',
93
+ pages: { '/features': { title: 'Features | Najenga', description: '…' } },
94
+ resolve: (path) => undefined, // titles for pages made from data, e.g. /projects/:slug
95
+ },
96
+ };
97
+ ```
98
+
99
+ Any path not in `seo.pages` (and not resolved) gets the defaults and `noindex`.
100
+
101
+ ## Building blocks
102
+
103
+ | Export | What it is |
104
+ |---|---|
105
+ | `SiteLayout` | Nav, page, footer; `overlay` puts the nav over a full-bleed hero, `cta={false}` drops the footer's call to action |
106
+ | `SiteNav`, `SiteFooter`, `Seo` | The parts `SiteLayout` uses |
107
+ | `Container`, `Logo`, `PillLink`, `Eyebrow`, `Chip`, `SectionHeading` | Page width, the brand mark, call-to-action links, labels, headings |
108
+ | `PhotoHero`, `HeroOrnament` | A full-bleed photo hero with a large light headline |
109
+ | `ImageCard` | A large rounded card with a photo behind its words; a link when given `to` |
110
+ | `Photo` | A photo filling its parent; a list of keys cross-fades. While loading or if it fails, a warm gradient shows (restyle it with `.okjt-photo`) |
111
+ | `MediaFrame`, `MediaFill` | A product screen or clip in a browser or phone frame, or bare |
112
+ | `FeatureExplorer` | Feature groups as tabs beside a large photo card with a summary, numbers and a screen |
113
+ | `FeatureGroup` | One feature group in depth: heading, numbers, photo card and screen, every tool |
114
+ | `JourneyGlance`, `JourneyChapter` | A journey's numbers at a glance, and one chapter of it |
115
+ | `ShowcaseCarousel` | A stack of screens with arrows and a list |
116
+ | `SectionNav`, `SECTION_OFFSET`, `useScrollSpy` | The sticky bar of the page's sections, with "you are here" |
117
+ | `FaqSection` | Questions and answers |
118
+ | `PricingSection` | Plan cards, already worded by the project |
119
+ | `LegalPage` | A legal document with its contents beside it |
120
+ | `RollingWord`, `useRolling` | Words that roll through a list in a headline |
121
+
122
+ Dark public pages: swap the theme's light and dark tokens and set `surface`,
123
+ `onInk` and `panel` (see `packages/UPGRADING.md`, site 0.2.0).
124
+
125
+ Class hooks for a project's CSS: `.okjt-site` (the page behind everything,
126
+ e.g. a gradient or texture), `.okjt-card-shade` (the shade over photos on
127
+ ImageCard and FeatureExplorer, e.g. darker for contrast), `.okjt-photo`
128
+ (a photo's loading or failed state), `.okjt-card` (cards on the page: the
129
+ explorer's summary, questions, a legal document, e.g. to give them their own
130
+ colours), `.okjt-section-nav` (the sticky bar of sections), `.okjt-site-nav`
131
+ with `.okjt-site-nav-over` / `.okjt-site-nav-solid` (the top bar over a hero
132
+ or on its own background), `.okjt-pill` and `.okjt-pill-<variant>`
133
+ (call-to-action links).
134
+
135
+ Headings inside the kit's components carry `!` on their font and colour, so a
136
+ host app's own global `h1`–`h3` styles don't change them.
@@ -0,0 +1,33 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Plus } from "lucide-react";
4
+ import { Container, SectionHeading } from "./ui.js";
5
+ import { SECTION_OFFSET } from "./SectionNav.js";
6
+ function FaqSection({ id = "faq", items, eyebrow = "FAQ", title = "Questions, answered", text }) {
7
+ const [openIndex, setOpenIndex] = useState(0);
8
+ return /* @__PURE__ */ jsx("section", { id, className: `${SECTION_OFFSET} py-24`, children: /* @__PURE__ */ jsxs(Container, { className: "grid gap-12 lg:grid-cols-12", children: [
9
+ /* @__PURE__ */ jsx("div", { className: "lg:col-span-4", children: /* @__PURE__ */ jsx(SectionHeading, { eyebrow, title, text, align: "left", className: "lg:sticky lg:top-28" }) }),
10
+ /* @__PURE__ */ jsx("div", { className: "space-y-3 lg:col-span-8", children: items.map((item, i) => {
11
+ const open = openIndex === i;
12
+ return /* @__PURE__ */ jsxs("div", { className: `okjt-card rounded-3xl border transition-colors ${open ? "border-transparent bg-surface shadow-[0_20px_50px_-35px_color-mix(in_srgb,var(--okjt-shadow,var(--color-ink))_50%,transparent)]" : "border-line bg-surface/50"}`, children: [
13
+ /* @__PURE__ */ jsxs(
14
+ "button",
15
+ {
16
+ type: "button",
17
+ onClick: () => setOpenIndex(open ? -1 : i),
18
+ "aria-expanded": open,
19
+ className: "flex w-full items-center justify-between gap-4 px-6 py-5 text-left font-medium text-ink sm:px-8",
20
+ children: [
21
+ item.q,
22
+ /* @__PURE__ */ jsx("span", { className: `grid h-8 w-8 shrink-0 place-items-center rounded-full transition-all ${open ? "rotate-45 bg-fill text-on-ink" : "bg-tint text-accent"}`, children: /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4", "aria-hidden": "true" }) })
23
+ ]
24
+ }
25
+ ),
26
+ open && /* @__PURE__ */ jsx("div", { className: "px-6 pb-6 text-sm leading-relaxed text-soft sm:px-8", children: item.a })
27
+ ] }, item.q);
28
+ }) })
29
+ ] }) });
30
+ }
31
+ export {
32
+ FaqSection as default
33
+ };
@@ -0,0 +1,78 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Link } from "react-router-dom";
4
+ import { AnimatePresence, motion } from "framer-motion";
5
+ import { ArrowUpRight } from "lucide-react";
6
+ import Photo from "./Photo.js";
7
+ import MediaFrame from "./MediaFrame.js";
8
+ import { usePlaces, useSite } from "./SiteProvider.js";
9
+ function FeatureExplorer({
10
+ groups,
11
+ href = (group) => `/features#${group.id}`,
12
+ linkLabel = "See every detail",
13
+ countLabel = "tools",
14
+ label = "Feature areas"
15
+ }) {
16
+ const place = usePlaces();
17
+ const { media: MEDIA = {} } = useSite();
18
+ const [activeId, setActiveId] = useState(groups[0].id);
19
+ const active = groups.find((g) => g.id === activeId) || groups[0];
20
+ return /* @__PURE__ */ jsxs("div", { className: "grid gap-8 lg:grid-cols-12 lg:gap-12", children: [
21
+ /* @__PURE__ */ jsx("div", { role: "tablist", "aria-label": label, "aria-orientation": "vertical", className: "-mx-4 flex gap-2 overflow-x-auto px-4 pb-2 lg:mx-0 lg:block lg:space-y-1 lg:overflow-visible lg:border-l lg:border-line-strong lg:px-0 lg:pb-0 lg:col-span-3", children: groups.map((group) => {
22
+ const selected = group.id === active.id;
23
+ return /* @__PURE__ */ jsxs(
24
+ "button",
25
+ {
26
+ type: "button",
27
+ role: "tab",
28
+ "aria-selected": selected,
29
+ onClick: () => setActiveId(group.id),
30
+ className: `relative shrink-0 rounded-full px-4 py-2 text-left transition-colors lg:block lg:w-full lg:rounded-none lg:px-6 lg:py-3 ${selected ? "bg-fill text-on-ink lg:bg-transparent lg:text-ink" : "bg-surface/60 text-muted hover:text-ink lg:bg-transparent"}`,
31
+ children: [
32
+ selected && /* @__PURE__ */ jsx(motion.span, { layoutId: "explorer-bar", className: "absolute -left-px top-0 hidden h-full w-0.5 bg-ink lg:block" }),
33
+ /* @__PURE__ */ jsx("span", { className: "block whitespace-nowrap text-sm font-semibold lg:text-base lg:font-medium", children: group.eyebrow }),
34
+ group.features?.length > 0 && /* @__PURE__ */ jsxs("span", { className: "hidden text-xs text-faint lg:block", children: [
35
+ group.features.length,
36
+ " ",
37
+ typeof countLabel === "function" ? countLabel(group.features.length) : countLabel
38
+ ] })
39
+ ]
40
+ },
41
+ group.id
42
+ );
43
+ }) }),
44
+ /* @__PURE__ */ jsx("div", { className: "lg:col-span-9", children: /* @__PURE__ */ jsx(AnimatePresence, { mode: "wait", children: /* @__PURE__ */ jsxs(
45
+ motion.div,
46
+ {
47
+ role: "tabpanel",
48
+ initial: { opacity: 0, y: 12 },
49
+ animate: { opacity: 1, y: 0 },
50
+ exit: { opacity: 0, y: -12 },
51
+ transition: { duration: 0.35 },
52
+ className: "group relative isolate flex min-h-[34rem] flex-col justify-between overflow-hidden rounded-[2rem] p-4 sm:p-6 lg:min-h-[38rem]",
53
+ children: [
54
+ /* @__PURE__ */ jsx(Photo, { image: place(`group-${active.id}`), className: "-z-10" }),
55
+ /* @__PURE__ */ jsx("div", { className: "okjt-card-shade absolute inset-0 -z-10 bg-gradient-to-t from-ink-deep/85 via-ink-deep/10 to-ink-deep/10" }),
56
+ /* @__PURE__ */ jsxs("div", { className: "okjt-card max-w-sm rounded-3xl border border-surface/50 bg-surface/75 p-6 shadow-xl backdrop-blur-xl", children: [
57
+ /* @__PURE__ */ jsx("h3", { className: "!font-display text-2xl !font-normal tracking-tight !text-ink", children: active.title }),
58
+ /* @__PURE__ */ jsx("div", { className: "mt-3 text-sm leading-relaxed text-body", children: active.summary }),
59
+ /* @__PURE__ */ jsxs(Link, { to: href(active), className: "mt-5 inline-flex items-center gap-1 text-[11px] font-bold uppercase tracking-[0.15em] text-ink hover:text-accent", children: [
60
+ linkLabel,
61
+ " ",
62
+ /* @__PURE__ */ jsx(ArrowUpRight, { className: "h-3.5 w-3.5", "aria-hidden": "true" })
63
+ ] })
64
+ ] }),
65
+ /* @__PURE__ */ jsx("div", { className: `pointer-events-none absolute hidden rotate-[-2deg] lg:block ${MEDIA[active.media]?.phone ? "bottom-8 right-10 w-52" : "bottom-24 right-6 w-[46%]"}`, children: /* @__PURE__ */ jsx(MediaFrame, { media: active.media }) }),
66
+ /* @__PURE__ */ jsx("dl", { className: "mt-10 grid grid-cols-3 gap-4 px-2 text-white sm:max-w-xl", children: active.stats.map(([value, label2]) => /* @__PURE__ */ jsxs("div", { children: [
67
+ /* @__PURE__ */ jsx("dt", { className: "text-xs text-white/70", children: label2 }),
68
+ /* @__PURE__ */ jsx("dd", { className: "mt-1 font-display text-xl font-light sm:text-3xl", children: value })
69
+ ] }, label2)) })
70
+ ]
71
+ },
72
+ active.id
73
+ ) }) })
74
+ ] });
75
+ }
76
+ export {
77
+ FeatureExplorer as default
78
+ };
@@ -0,0 +1,47 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Check } from "lucide-react";
3
+ import ImageCard from "./ImageCard.js";
4
+ import MediaFrame from "./MediaFrame.js";
5
+ import { Container, Eyebrow } from "./ui.js";
6
+ import { SECTION_OFFSET } from "./SectionNav.js";
7
+ import { usePlaces, useSite } from "./SiteProvider.js";
8
+ function FeatureGroup({ group, index }) {
9
+ const place = usePlaces();
10
+ const { media: MEDIA = {} } = useSite();
11
+ const media = MEDIA[group.clip] || MEDIA[group.media];
12
+ const flip = index % 2 === 1;
13
+ return /* @__PURE__ */ jsx("section", { id: group.id, className: `${SECTION_OFFSET} py-20 lg:py-28`, children: /* @__PURE__ */ jsxs(Container, { children: [
14
+ /* @__PURE__ */ jsxs("div", { className: "grid items-end gap-8 lg:grid-cols-12", children: [
15
+ /* @__PURE__ */ jsxs("div", { className: "lg:col-span-7", children: [
16
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
17
+ /* @__PURE__ */ jsx("span", { className: "font-display text-sm font-semibold text-accent-soft", children: String(index + 1).padStart(2, "0") }),
18
+ /* @__PURE__ */ jsx(Eyebrow, { children: group.eyebrow })
19
+ ] }),
20
+ /* @__PURE__ */ jsx("h2", { className: "mt-4 !font-display text-4xl !font-light leading-[1.05] tracking-tight !text-ink sm:text-5xl", children: group.title }),
21
+ /* @__PURE__ */ jsx("div", { className: "mt-5 max-w-2xl text-lg leading-relaxed text-soft", children: group.summary })
22
+ ] }),
23
+ /* @__PURE__ */ jsx("dl", { className: "grid grid-cols-3 gap-3 lg:col-span-5", children: group.stats.map(([value, label]) => /* @__PURE__ */ jsxs("div", { className: "rounded-2xl border border-line bg-surface/60 p-4", children: [
24
+ /* @__PURE__ */ jsx("dd", { className: "font-display text-xl font-normal tracking-tight text-ink sm:text-2xl", children: value }),
25
+ /* @__PURE__ */ jsx("dt", { className: "mt-1 text-xs text-muted", children: label })
26
+ ] }, label)) })
27
+ ] }),
28
+ /* @__PURE__ */ jsxs("div", { className: `mt-12 grid gap-5 lg:grid-cols-12 ${flip ? "lg:[&>*:first-child]:order-2" : ""}`, children: [
29
+ /* @__PURE__ */ jsx(ImageCard, { image: place(`group-${group.id}-card`), tags: [group.eyebrow], title: group.features[0].title, text: group.features[0].text, className: "min-h-[24rem] lg:col-span-5" }),
30
+ /* @__PURE__ */ jsxs("div", { className: "relative overflow-hidden rounded-[1.75rem] bg-line p-4 sm:p-8 lg:col-span-7", children: [
31
+ /* @__PURE__ */ jsx("div", { className: "absolute -right-24 -top-24 h-64 w-64 rounded-full bg-surface/50 blur-3xl", "aria-hidden": "true" }),
32
+ /* @__PURE__ */ jsx(MediaFrame, { media, className: "relative" })
33
+ ] })
34
+ ] }),
35
+ /* @__PURE__ */ jsx("div", { className: "mt-5 grid gap-5 sm:grid-cols-2 lg:grid-cols-3", children: group.features.map((feature) => /* @__PURE__ */ jsxs("article", { className: "flex flex-col rounded-[1.75rem] border border-line bg-surface p-6 sm:p-7", children: [
36
+ /* @__PURE__ */ jsx("h3", { className: "!font-display text-xl !font-medium !text-ink", children: feature.title }),
37
+ /* @__PURE__ */ jsx("div", { className: "mt-3 text-sm leading-relaxed text-soft", children: feature.text }),
38
+ feature.details.length > 0 && /* @__PURE__ */ jsx("ul", { className: "mt-5 space-y-2 border-t border-tint pt-5 text-sm text-body", children: feature.details.map((detail) => /* @__PURE__ */ jsxs("li", { className: "flex gap-2.5", children: [
39
+ /* @__PURE__ */ jsx(Check, { className: "mt-0.5 h-4 w-4 shrink-0 text-accent", "aria-hidden": "true" }),
40
+ detail
41
+ ] }, detail)) })
42
+ ] }, feature.title)) })
43
+ ] }) });
44
+ }
45
+ export {
46
+ FeatureGroup as default
47
+ };
@@ -0,0 +1,37 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Link } from "react-router-dom";
3
+ import { ArrowUpRight } from "lucide-react";
4
+ import Photo from "./Photo.js";
5
+ import { Chip } from "./ui.js";
6
+ function ImageCard({
7
+ image,
8
+ title,
9
+ text,
10
+ tags = [],
11
+ to,
12
+ position,
13
+ className = "min-h-[22rem]",
14
+ titleClass = "text-2xl sm:text-3xl",
15
+ children
16
+ }) {
17
+ const Wrapper = to ? Link : "div";
18
+ return /* @__PURE__ */ jsxs(
19
+ Wrapper,
20
+ {
21
+ to,
22
+ className: `group relative isolate flex flex-col justify-end overflow-hidden rounded-[1.75rem] p-6 text-white shadow-[0_30px_60px_-35px_color-mix(in_srgb,var(--okjt-shadow,var(--color-ink))_70%,transparent)] sm:p-8 ${className}`,
23
+ children: [
24
+ /* @__PURE__ */ jsx(Photo, { image, position, className: "-z-10" }),
25
+ /* @__PURE__ */ jsx("div", { className: "okjt-card-shade absolute inset-0 -z-10 bg-gradient-to-t from-ink-deep/85 via-ink-deep/25 to-transparent" }),
26
+ to && /* @__PURE__ */ jsx("span", { className: "absolute right-5 top-5 grid h-10 w-10 place-items-center rounded-full bg-white/15 backdrop-blur transition-colors group-hover:bg-surface group-hover:text-ink", "aria-hidden": "true", children: /* @__PURE__ */ jsx(ArrowUpRight, { className: "h-4 w-4" }) }),
27
+ tags.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-3 flex flex-wrap gap-2", children: tags.map((tag) => /* @__PURE__ */ jsx(Chip, { children: tag }, tag)) }),
28
+ title && /* @__PURE__ */ jsx("h3", { className: `!font-display !font-normal leading-tight tracking-tight !text-white ${titleClass}`, children: title }),
29
+ text && /* @__PURE__ */ jsx("div", { className: "mt-2 max-w-md text-sm leading-relaxed text-white/80", children: text }),
30
+ children
31
+ ]
32
+ }
33
+ );
34
+ }
35
+ export {
36
+ ImageCard as default
37
+ };
@@ -0,0 +1,51 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { CheckCircle2, Lightbulb } from "lucide-react";
3
+ import MediaFrame from "./MediaFrame.js";
4
+ import { Eyebrow } from "./ui.js";
5
+ import { SECTION_OFFSET } from "./SectionNav.js";
6
+ import { useMedia } from "./SiteProvider.js";
7
+ function JourneyGlance({ journey }) {
8
+ return /* @__PURE__ */ jsx("div", { className: "rounded-[2rem] bg-surface p-6 shadow-[0_40px_80px_-40px_color-mix(in_srgb,var(--okjt-shadow,var(--color-ink))_50%,transparent)] sm:p-10", children: /* @__PURE__ */ jsx("dl", { className: "grid grid-cols-2 gap-6 lg:grid-cols-4", children: journey.glance.map(([value, label]) => /* @__PURE__ */ jsxs("div", { className: "rounded-2xl bg-tint p-5", children: [
9
+ /* @__PURE__ */ jsx("dd", { className: "font-display text-3xl font-light tracking-tight text-ink sm:text-4xl", children: value }),
10
+ /* @__PURE__ */ jsx("dt", { className: "mt-1 text-sm text-soft", children: label })
11
+ ] }, label)) }) });
12
+ }
13
+ function JourneyChapter({ chapter, index, detailsLabel = "The nitty-gritty", tipLabel = "Good to know" }) {
14
+ const phone = useMedia(chapter.media)?.phone;
15
+ return /* @__PURE__ */ jsxs("article", { id: chapter.id, className: `${SECTION_OFFSET} grid gap-8 border-t border-line py-14 first:border-t-0 first:pt-0 lg:grid-cols-12 lg:gap-12 lg:py-16`, children: [
16
+ /* @__PURE__ */ jsx("div", { className: `lg:sticky lg:top-40 lg:self-start ${phone ? "lg:col-span-5" : "lg:col-span-7"}`, children: /* @__PURE__ */ jsx(MediaFrame, { media: chapter.media, className: phone ? "max-w-[16rem]" : "" }) }),
17
+ /* @__PURE__ */ jsxs("div", { className: `${phone ? "lg:col-span-7" : "lg:col-span-5"}`, children: [
18
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
19
+ /* @__PURE__ */ jsx("span", { className: "font-display text-4xl font-extralight text-accent-soft", children: String(index + 1).padStart(2, "0") }),
20
+ /* @__PURE__ */ jsx("span", { className: "rounded-full bg-fill px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-on-ink", children: chapter.when })
21
+ ] }),
22
+ /* @__PURE__ */ jsx("h2", { className: "mt-3 !font-display text-3xl !font-light tracking-tight !text-ink sm:text-4xl", children: chapter.title }),
23
+ /* @__PURE__ */ jsx("div", { className: "mt-3 leading-relaxed text-soft", children: chapter.intro }),
24
+ /* @__PURE__ */ jsx("ol", { className: "mt-6 divide-y divide-tint rounded-3xl border border-line bg-surface", children: chapter.steps.map((step, i) => /* @__PURE__ */ jsxs("li", { className: "flex gap-4 p-5", children: [
25
+ /* @__PURE__ */ jsx("span", { className: "grid h-7 w-7 shrink-0 place-items-center rounded-full bg-tint text-xs font-semibold text-accent", children: i + 1 }),
26
+ /* @__PURE__ */ jsxs("div", { children: [
27
+ /* @__PURE__ */ jsx("h3", { className: "!font-sans text-base !font-semibold !text-ink", children: step.title }),
28
+ /* @__PURE__ */ jsx("div", { className: "mt-1 text-sm leading-relaxed text-soft", children: step.text })
29
+ ] })
30
+ ] }, step.title)) }),
31
+ chapter.details.length > 0 && /* @__PURE__ */ jsxs("div", { className: "mt-4 rounded-3xl bg-tint p-5", children: [
32
+ /* @__PURE__ */ jsx(Eyebrow, { children: detailsLabel }),
33
+ /* @__PURE__ */ jsx("ul", { className: "mt-3 space-y-2 text-sm text-body", children: chapter.details.map((detail) => /* @__PURE__ */ jsxs("li", { className: "flex gap-3", children: [
34
+ /* @__PURE__ */ jsx(CheckCircle2, { className: "mt-0.5 h-4 w-4 shrink-0 text-accent", "aria-hidden": "true" }),
35
+ detail
36
+ ] }, detail)) })
37
+ ] }),
38
+ chapter.tip && /* @__PURE__ */ jsxs("div", { className: "mt-4 flex gap-4 rounded-3xl bg-fill p-5 text-on-ink", children: [
39
+ /* @__PURE__ */ jsx(Lightbulb, { className: "h-5 w-5 shrink-0 text-accent-soft", "aria-hidden": "true" }),
40
+ /* @__PURE__ */ jsxs("div", { children: [
41
+ /* @__PURE__ */ jsx("div", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-accent-soft", children: tipLabel }),
42
+ /* @__PURE__ */ jsx("div", { className: "mt-2 text-sm leading-relaxed text-on-ink/85", children: chapter.tip })
43
+ ] })
44
+ ] })
45
+ ] })
46
+ ] });
47
+ }
48
+ export {
49
+ JourneyChapter,
50
+ JourneyGlance
51
+ };
@@ -0,0 +1,94 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
3
+ import { Link, NavLink } from "react-router-dom";
4
+ import { Mail } from "lucide-react";
5
+ import SiteLayout from "./SiteLayout.js";
6
+ import { Container, Eyebrow } from "./ui.js";
7
+ import useScrollSpy from "./useScrollSpy.js";
8
+ import SectionNav, { SECTION_OFFSET } from "./SectionNav.js";
9
+ import { useSite } from "./SiteProvider.js";
10
+ function Sidebar({ sections, active }) {
11
+ const { legal } = useSite();
12
+ return /* @__PURE__ */ jsxs("nav", { "aria-label": "Legal", className: "space-y-8 lg:sticky lg:top-28", children: [
13
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 lg:flex-col", children: legal.documents.map((doc) => {
14
+ const Icon = doc.icon;
15
+ return /* @__PURE__ */ jsxs(
16
+ NavLink,
17
+ {
18
+ to: doc.to,
19
+ className: ({ isActive }) => `flex min-w-[10rem] flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors ${isActive ? "bg-fill text-on-ink" : "bg-surface text-body hover:bg-tint"}`,
20
+ children: [
21
+ /* @__PURE__ */ jsx(Icon, { className: "h-4 w-4 shrink-0", "aria-hidden": "true" }),
22
+ doc.label
23
+ ]
24
+ },
25
+ doc.to
26
+ );
27
+ }) }),
28
+ /* @__PURE__ */ jsxs("div", { className: "hidden lg:block", children: [
29
+ /* @__PURE__ */ jsx(Eyebrow, { children: "Contents" }),
30
+ /* @__PURE__ */ jsx("ol", { className: "mt-4 space-y-0.5 border-l border-line-strong", children: sections.map((s, i) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
31
+ "a",
32
+ {
33
+ href: `#${s.id}`,
34
+ "aria-current": active === s.id ? "location" : void 0,
35
+ className: `-ml-px flex gap-3 border-l-2 py-1.5 pl-4 text-sm transition-colors ${active === s.id ? "border-ink font-medium text-ink" : "border-transparent text-muted hover:text-body"}`,
36
+ children: [
37
+ /* @__PURE__ */ jsxs("span", { className: "w-5 shrink-0 tabular-nums text-accent-soft", children: [
38
+ i + 1,
39
+ "."
40
+ ] }),
41
+ s.title
42
+ ]
43
+ }
44
+ ) }, s.id)) })
45
+ ] }),
46
+ /* @__PURE__ */ jsxs("div", { className: "hidden rounded-2xl bg-tint p-5 text-sm text-body lg:block", children: [
47
+ /* @__PURE__ */ jsx(Mail, { className: "h-4 w-4 text-accent", "aria-hidden": "true" }),
48
+ /* @__PURE__ */ jsx("div", { className: "mt-2 font-medium text-ink", children: "Questions?" }),
49
+ /* @__PURE__ */ jsx("div", { className: "mt-1 text-soft", children: legal.contact })
50
+ ] })
51
+ ] });
52
+ }
53
+ function LegalPage({ title, intro, sections }) {
54
+ const { legal } = useSite();
55
+ const updated = (/* @__PURE__ */ new Date(`${legal.lastUpdated}T00:00:00`)).toLocaleDateString(void 0, { dateStyle: "long" });
56
+ const active = useScrollSpy(sections.map((s) => s.id));
57
+ return /* @__PURE__ */ jsxs(SiteLayout, { cta: false, children: [
58
+ /* @__PURE__ */ jsx(Container, { className: "pt-10 lg:pt-16", children: /* @__PURE__ */ jsxs("header", { className: "max-w-3xl", children: [
59
+ /* @__PURE__ */ jsx(Eyebrow, { children: "Legal" }),
60
+ /* @__PURE__ */ jsx("h1", { className: "mt-3 !font-display text-4xl !font-light tracking-tight !text-ink sm:text-6xl", children: title }),
61
+ /* @__PURE__ */ jsxs("div", { className: "mt-3 text-sm text-muted", children: [
62
+ "Last updated ",
63
+ updated
64
+ ] })
65
+ ] }) }),
66
+ /* @__PURE__ */ jsx(SectionNav, { label: "Contents", numbered: true, className: "lg:hidden", sections: sections.map((s) => ({ id: s.id, label: s.title })) }),
67
+ /* @__PURE__ */ jsx(Container, { className: "pb-16 pt-8 lg:pt-0", children: /* @__PURE__ */ jsxs("div", { className: "mt-4 grid gap-10 lg:mt-12 lg:grid-cols-12", children: [
68
+ /* @__PURE__ */ jsx("aside", { className: "lg:col-span-4 xl:col-span-3", children: /* @__PURE__ */ jsx(Sidebar, { sections, active }) }),
69
+ /* @__PURE__ */ jsxs("article", { className: "okjt-card rounded-[2rem] bg-surface p-6 sm:p-10 lg:col-span-8 xl:col-span-9 xl:p-14", children: [
70
+ /* @__PURE__ */ jsx("div", { className: "text-lg leading-relaxed text-body", children: intro }),
71
+ sections.map((s, i) => /* @__PURE__ */ jsxs("section", { id: s.id, className: `${SECTION_OFFSET} lg:scroll-mt-28 border-t border-tint pt-10 mt-10 first-of-type:mt-10`, children: [
72
+ /* @__PURE__ */ jsxs("h2", { className: "!font-display text-2xl !font-normal tracking-tight !text-ink", children: [
73
+ /* @__PURE__ */ jsxs("span", { className: "mr-2 text-accent-soft", children: [
74
+ i + 1,
75
+ "."
76
+ ] }),
77
+ s.title
78
+ ] }),
79
+ /* @__PURE__ */ jsx("div", { className: "mt-4 max-w-3xl space-y-3 leading-relaxed text-body [&_a]:text-accent-strong [&_li]:ml-5 [&_li]:list-disc [&_ul]:space-y-1.5", children: s.body })
80
+ ] }, s.id)),
81
+ /* @__PURE__ */ jsxs("div", { className: "mt-12 border-t border-tint pt-6 text-sm text-muted", children: [
82
+ "See also: ",
83
+ legal.seeAlso.map((link, i) => /* @__PURE__ */ jsxs(Fragment, { children: [
84
+ i > 0 && " \xB7 ",
85
+ /* @__PURE__ */ jsx(Link, { to: link.to, className: "hover:text-body", children: link.label })
86
+ ] }, link.to))
87
+ ] })
88
+ ] })
89
+ ] }) })
90
+ ] });
91
+ }
92
+ export {
93
+ LegalPage as default
94
+ };
@@ -0,0 +1,55 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useMedia } from "./SiteProvider.js";
4
+ function useInViewPlayback() {
5
+ const ref = useRef(null);
6
+ useEffect(() => {
7
+ const video = ref.current;
8
+ if (!video || typeof IntersectionObserver === "undefined") return void 0;
9
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return void 0;
10
+ const observer = new IntersectionObserver(([entry]) => {
11
+ if (entry.isIntersecting) video.play?.()?.catch?.(() => {
12
+ });
13
+ else video.pause?.();
14
+ }, { threshold: 0.25 });
15
+ observer.observe(video);
16
+ return () => observer.disconnect();
17
+ }, []);
18
+ return ref;
19
+ }
20
+ function MediaFill({ media, className = "", position = "top" }) {
21
+ const item = useMedia(media);
22
+ const videoRef = useInViewPlayback();
23
+ const [failed, setFailed] = useState(false);
24
+ if (!item) return null;
25
+ const fit = `h-full w-full object-cover ${position === "top" ? "object-top" : "object-center"} ${className}`;
26
+ if (failed) return /* @__PURE__ */ jsx("div", { className: `grid place-items-center bg-tint p-6 text-center text-sm text-muted ${fit}`, children: item.alt });
27
+ if (item.video) {
28
+ return /* @__PURE__ */ jsx("video", { ref: videoRef, className: fit, poster: item.src, muted: true, loop: true, playsInline: true, preload: "none", "aria-label": item.alt, onError: () => setFailed(true), children: /* @__PURE__ */ jsx("source", { src: item.video, type: "video/mp4" }) });
29
+ }
30
+ return /* @__PURE__ */ jsx("img", { src: item.src, alt: item.alt, loading: "lazy", decoding: "async", className: fit, onError: () => setFailed(true) });
31
+ }
32
+ function MediaFrame({ media, className = "", variant }) {
33
+ const item = useMedia(media);
34
+ if (!item) return null;
35
+ const kind = variant || (item.phone ? "phone" : "browser");
36
+ if (kind === "phone") {
37
+ return /* @__PURE__ */ jsxs("div", { className: `relative mx-auto aspect-[9/19] w-full max-w-[17rem] overflow-hidden rounded-[2.4rem] border-[7px] border-fill bg-fill shadow-[0_40px_80px_-30px_color-mix(in_srgb,var(--okjt-shadow,var(--color-ink))_70%,transparent)] ${className}`, children: [
38
+ /* @__PURE__ */ jsx("div", { className: "absolute left-1/2 top-2 z-10 h-5 w-20 -translate-x-1/2 rounded-full bg-fill", "aria-hidden": "true" }),
39
+ /* @__PURE__ */ jsx("div", { className: "h-full overflow-hidden rounded-[1.9rem] bg-surface", children: /* @__PURE__ */ jsx(MediaFill, { media: item }) })
40
+ ] });
41
+ }
42
+ return /* @__PURE__ */ jsxs("div", { className: `overflow-hidden rounded-2xl border border-surface/60 bg-surface shadow-[0_40px_80px_-35px_color-mix(in_srgb,var(--okjt-shadow,var(--color-ink))_55%,transparent)] ${className}`, children: [
43
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-line bg-paper px-4 py-2.5", "aria-hidden": "true", children: [
44
+ /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 rounded-full bg-[#E8C4B8]" }),
45
+ /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 rounded-full bg-line" }),
46
+ /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 rounded-full bg-[#C5D4B9]" }),
47
+ /* @__PURE__ */ jsx("span", { className: "ml-3 truncate rounded-full bg-surface px-3 py-0.5 text-[11px] text-muted", children: item.url })
48
+ ] }),
49
+ /* @__PURE__ */ jsx("div", { className: "aspect-[16/10] bg-paper", children: /* @__PURE__ */ jsx(MediaFill, { media: item }) })
50
+ ] });
51
+ }
52
+ export {
53
+ MediaFill,
54
+ MediaFrame as default
55
+ };
package/dist/Photo.js ADDED
@@ -0,0 +1,56 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
4
+ import { usePhotos } from "./SiteProvider.js";
5
+ const ROTATE_EVERY = 7e3;
6
+ const asList = (image) => Array.isArray(image) ? image : [image];
7
+ function Photo({ image, phoneImage, width = 1600, alt, position = "center", className = "", eager = false }) {
8
+ const images = asList(image);
9
+ const phones = phoneImage ? asList(phoneImage) : null;
10
+ const [index, setIndex] = useState(0);
11
+ const [failedSrc, setFailedSrc] = useState(null);
12
+ const reduceMotion = useReducedMotion();
13
+ const photos = usePhotos();
14
+ const url = (key, w) => photos.url(key, w);
15
+ const current = images[index % images.length];
16
+ const src = url(current, width);
17
+ const failed = failedSrc === src;
18
+ const label = alt ?? photos.alt(current) ?? "";
19
+ const rotating = images.length > 1 && !reduceMotion;
20
+ const nextSrc = rotating ? url(images[(index + 1) % images.length], width) : null;
21
+ useEffect(() => {
22
+ if (!rotating) return void 0;
23
+ new Image().src = nextSrc;
24
+ const id = setTimeout(() => setIndex((i) => i + 1), ROTATE_EVERY);
25
+ return () => clearTimeout(id);
26
+ }, [rotating, nextSrc, index]);
27
+ return /* @__PURE__ */ jsx("div", { className: `okjt-photo absolute inset-0 overflow-hidden bg-gradient-to-br from-accent-soft via-accent to-body ${className}`, children: /* @__PURE__ */ jsx(AnimatePresence, { initial: false, children: !failed && /* @__PURE__ */ jsxs(
28
+ motion.picture,
29
+ {
30
+ initial: { opacity: 0 },
31
+ animate: { opacity: 1 },
32
+ exit: { opacity: 0 },
33
+ transition: { duration: 1.4, ease: "easeInOut" },
34
+ className: "absolute inset-0",
35
+ children: [
36
+ phones && /* @__PURE__ */ jsx("source", { media: "(max-width: 640px) and (orientation: portrait)", srcSet: url(phones[index % phones.length], 900) }),
37
+ /* @__PURE__ */ jsx(
38
+ "img",
39
+ {
40
+ src,
41
+ alt: label,
42
+ loading: eager ? "eager" : "lazy",
43
+ decoding: "async",
44
+ onError: () => setFailedSrc(src),
45
+ style: { objectPosition: position },
46
+ className: "h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
47
+ }
48
+ )
49
+ ]
50
+ },
51
+ index
52
+ ) }) });
53
+ }
54
+ export {
55
+ Photo as default
56
+ };
@@ -0,0 +1,37 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { motion } from "framer-motion";
3
+ import Photo from "./Photo.js";
4
+ import { Container, Eyebrow } from "./ui.js";
5
+ function HeroOrnament({ kind = "pill", className = "" }) {
6
+ if (kind === "hatch") {
7
+ return /* @__PURE__ */ jsx(
8
+ "span",
9
+ {
10
+ "aria-hidden": "true",
11
+ className: `inline-block h-[0.55em] w-[2.2em] rounded-sm align-middle opacity-70 ${className}`,
12
+ style: { backgroundImage: "repeating-linear-gradient(-55deg, currentColor 0 1.5px, transparent 1.5px 9px)" }
13
+ }
14
+ );
15
+ }
16
+ return /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: `relative inline-block h-[0.55em] w-[2.6em] align-middle ${className}`, children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsx("span", { className: "absolute rounded-full border border-current opacity-60", style: { inset: `${i * 0.06}em ${i * 0.12}em` } }, i)) });
17
+ }
18
+ function PhotoHero({ image, phoneImage, eyebrow, title, text, children, feature, after, position = "center", tall = false }) {
19
+ return /* @__PURE__ */ jsxs("section", { className: "relative", children: [
20
+ /* @__PURE__ */ jsxs("div", { className: `relative isolate overflow-hidden ${tall ? "min-h-[100svh] pb-56 sm:pb-64" : "min-h-[78svh] pb-24"} flex flex-col justify-center pt-32 text-white`, children: [
21
+ /* @__PURE__ */ jsx(Photo, { image, phoneImage, width: 2400, position, eager: true, className: "-z-20" }),
22
+ /* @__PURE__ */ jsx("div", { className: "absolute inset-0 -z-10 bg-gradient-to-b from-ink-deep/70 via-ink-deep/35 to-ink-deep/60" }),
23
+ /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsxs(motion.div, { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.7 }, className: "mx-auto max-w-5xl text-center", children: [
24
+ eyebrow && /* @__PURE__ */ jsx(Eyebrow, { light: true, children: eyebrow }),
25
+ /* @__PURE__ */ jsx("h1", { className: "mt-5 !font-display text-[2.75rem] !font-extralight leading-[1.02] tracking-tight !text-white sm:text-7xl lg:text-[6.25rem]", children: title }),
26
+ text && /* @__PURE__ */ jsx("div", { className: "mx-auto mt-6 max-w-2xl text-base leading-relaxed text-white/85 sm:text-lg", children: text }),
27
+ feature,
28
+ children && /* @__PURE__ */ jsx("div", { className: "mt-9 flex flex-col justify-center gap-3 sm:flex-row", children })
29
+ ] }) })
30
+ ] }),
31
+ after && /* @__PURE__ */ jsx(Container, { className: "relative z-10 -mt-44 sm:-mt-52", children: after })
32
+ ] });
33
+ }
34
+ export {
35
+ HeroOrnament,
36
+ PhotoHero as default
37
+ };