@jimmy_harika/websitekit 0.3.1

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.
Files changed (45) hide show
  1. package/README.md +86 -0
  2. package/package.json +54 -0
  3. package/src/blocks/author-bio.tsx +52 -0
  4. package/src/blocks/author-hero.tsx +99 -0
  5. package/src/blocks/book-grid.tsx +97 -0
  6. package/src/blocks/book-spotlight.tsx +109 -0
  7. package/src/blocks/contact.tsx +64 -0
  8. package/src/blocks/faq.tsx +65 -0
  9. package/src/blocks/form.tsx +234 -0
  10. package/src/blocks/gallery.tsx +230 -0
  11. package/src/blocks/index.ts +32 -0
  12. package/src/blocks/newsletter.tsx +100 -0
  13. package/src/blocks/pricing.tsx +120 -0
  14. package/src/blocks/primitives.tsx +109 -0
  15. package/src/blocks/testimonials.tsx +89 -0
  16. package/src/catalogs/author/catalog.tsx +496 -0
  17. package/src/catalogs/author/index.ts +9 -0
  18. package/src/catalogs/author/templates.ts +133 -0
  19. package/src/catalogs/publisher/catalog.tsx +308 -0
  20. package/src/catalogs/publisher/index.ts +10 -0
  21. package/src/catalogs/publisher/templates.ts +158 -0
  22. package/src/editable.tsx +76 -0
  23. package/src/editor/BlockLibrary.tsx +110 -0
  24. package/src/editor/Canvas.tsx +128 -0
  25. package/src/editor/ErrorBoundary.tsx +54 -0
  26. package/src/editor/InsertPoint.tsx +31 -0
  27. package/src/editor/Inspector.tsx +341 -0
  28. package/src/editor/PageBuilder.tsx +217 -0
  29. package/src/editor/SectionFrame.tsx +127 -0
  30. package/src/editor/SectionsPanel.tsx +149 -0
  31. package/src/editor/TemplateGallery.tsx +98 -0
  32. package/src/editor/ThemePanel.tsx +131 -0
  33. package/src/editor/Toolbar.tsx +162 -0
  34. package/src/editor/dnd.tsx +61 -0
  35. package/src/editor/edit-primitives.tsx +164 -0
  36. package/src/editor/index.tsx +8 -0
  37. package/src/editor/shortcuts.ts +106 -0
  38. package/src/editor/ui-context.tsx +32 -0
  39. package/src/index.ts +11 -0
  40. package/src/lib/cn.ts +8 -0
  41. package/src/model.ts +116 -0
  42. package/src/registry.ts +138 -0
  43. package/src/renderer/index.tsx +55 -0
  44. package/src/store.ts +216 -0
  45. package/src/theme.ts +85 -0
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @jimmy_harika/websitekit
2
+
3
+ The Snow Labs website builder — **one shared, block-agnostic, curated-section
4
+ engine** used by every Snow Labs app. Build it once here, and every app
5
+ (photographer sites, author sites, and more) inherits it. New blocks and
6
+ verticals land here and upgrade all consumers at once.
7
+
8
+ This is the **single home** for the builder: the engine, a design-system-agnostic
9
+ block library, and per-industry catalogs all live in this repo and ship as one
10
+ private package.
11
+
12
+ ## Why custom (not Puck / GrapesJS)
13
+
14
+ The engine is the product. Curated sections (not freeform grids) keep sites
15
+ beautiful-by-default for non-designers, and the **same component renders in the
16
+ editor canvas and in production** (an injected `edit` prop makes fields inline-
17
+ editable; production omits it for a byte-identical static render) — an RSC + CDN
18
+ perf moat heavy runtimes can't match.
19
+
20
+ ## Layers
21
+
22
+ | Export | What |
23
+ |---|---|
24
+ | `@jimmy_harika/websitekit` | Core model · registry · theme (RSC-safe) |
25
+ | `@jimmy_harika/websitekit/editor` | `<PageBuilder>` client editor |
26
+ | `@jimmy_harika/websitekit/renderer` | `PageRenderer` / `PageFonts` (RSC-safe production render) |
27
+ | `@jimmy_harika/websitekit/store` | zustand + zundo store |
28
+ | `@jimmy_harika/websitekit/blocks` | Design-system-agnostic blocks — render off `--pb-*` theme tokens, no app-UI imports (this is what makes a block portable to every app) |
29
+ | `@jimmy_harika/websitekit/catalogs/author` | Author-website catalog + templates |
30
+
31
+ ## The app contract
32
+
33
+ A consuming app supplies only:
34
+
35
+ 1. **A catalog** (or use a shipped one from `./catalogs/*`).
36
+ 2. **A persistence adapter** — load → `PageDocument`, save, publish.
37
+ 3. **`uploadImage(file) => Promise<url>`**.
38
+ 4. **A data-source resolver** — pre-resolve "live" block props (e.g. an author's
39
+ real books) from the app's own backend before render. App-level transform; no
40
+ engine change. Keeps blocks presentational and portable.
41
+
42
+ ```tsx
43
+ import { PageBuilder } from "@jimmy_harika/websitekit/editor";
44
+ import { AUTHOR_CATALOG, AUTHOR_TEMPLATES } from "@jimmy_harika/websitekit/catalogs/author";
45
+
46
+ <PageBuilder
47
+ catalog={AUTHOR_CATALOG}
48
+ templates={AUTHOR_TEMPLATES}
49
+ initialDocument={doc}
50
+ onChange={autosave}
51
+ onPublish={publish}
52
+ uploadImage={upload}
53
+ />;
54
+ ```
55
+
56
+ ```tsx
57
+ // production (RSC)
58
+ import { PageRenderer, PageFonts } from "@jimmy_harika/websitekit/renderer";
59
+ import { AUTHOR_CATALOG } from "@jimmy_harika/websitekit/catalogs/author";
60
+
61
+ <>
62
+ <PageFonts doc={doc} />
63
+ <PageRenderer doc={doc} catalog={AUTHOR_CATALOG} />
64
+ </>;
65
+ ```
66
+
67
+ ## Consuming app notes
68
+
69
+ - The package ships **raw TS/TSX** — add `@jimmy_harika/websitekit` to your Next
70
+ `transpilePackages`, and ensure a **single React copy** (dedupe via your
71
+ bundler's resolve alias).
72
+ - `zod` is a peer (v3 or v4). Catalogs authored here use the repo's zod; apps on
73
+ a different major only need the `obj()` cast bridge if they author their own
74
+ catalog.
75
+ - The **editor chrome** (`@jimmy_harika/websitekit/editor`) is styled with shadcn/ui
76
+ semantic utilities and reads the host's shadcn theme. If your app doesn't use
77
+ shadcn/ui, see **[PREREQUISITES.md](./PREREQUISITES.md)** for the required CSS
78
+ variables. Rendered sites and blocks are portable and do not depend on shadcn.
79
+
80
+ ## Roadmap
81
+
82
+ - **Catalogs:** author (shipped) → photographer (migrate from pixbox) → driving
83
+ schools, etc. Each vertical is additive.
84
+ - **Backend:** a companion Convex component (isolated tables + app-facing API) so
85
+ persistence is shared infra too.
86
+ - **Edge:** Cloudflare-for-SaaS custom domains + wildcard subdomain serving.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@jimmy_harika/websitekit",
3
+ "version": "0.3.1",
4
+ "description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app \u2014 engine + design-system-agnostic blocks + per-industry catalogs (author, photographer, \u2026). Each app plugs in a catalog + theme + persistence adapter.",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/jimmyharika/websitekit.git"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./store": "./src/store.ts",
22
+ "./renderer": "./src/renderer/index.tsx",
23
+ "./editor": "./src/editor/index.tsx",
24
+ "./theme": "./src/theme.ts",
25
+ "./blocks": "./src/blocks/index.ts",
26
+ "./catalogs/author": "./src/catalogs/author/index.ts",
27
+ "./catalogs/publisher": "./src/catalogs/publisher/index.ts",
28
+ "./lib/cn": "./src/lib/cn.ts"
29
+ },
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
35
+ "clsx": "^2.1.1",
36
+ "lucide-react": "^0.525.0",
37
+ "tailwind-merge": "^3.4.0",
38
+ "zundo": "^2.3.0",
39
+ "zustand": "^5.0.9"
40
+ },
41
+ "peerDependencies": {
42
+ "react": "^19.0.0",
43
+ "react-dom": "^19.0.0",
44
+ "zod": "^3.25.0 || ^4.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/react": "^19.2.7",
48
+ "@types/react-dom": "^19.2.3",
49
+ "react": "^19.2.0",
50
+ "react-dom": "^19.2.0",
51
+ "typescript": "^5.9.3",
52
+ "zod": "^3.25.76"
53
+ }
54
+ }
@@ -0,0 +1,52 @@
1
+ /** About-the-author — a portrait beside a story column. `flip` swaps sides. */
2
+ import { renderImage, renderText, type EditPrimitives } from "../editable";
3
+ import { BODY, Eyebrow, HEADING, MUTED, RADIUS, Section, TEXT } from "./primitives";
4
+
5
+ export interface AuthorBioProps {
6
+ eyebrow?: string;
7
+ heading: string;
8
+ body: string;
9
+ portrait?: string;
10
+ flip?: boolean;
11
+ edit?: EditPrimitives;
12
+ }
13
+
14
+ export function AuthorBio({ eyebrow, heading, body, portrait, flip = false, edit }: AuthorBioProps) {
15
+ return (
16
+ <Section tint>
17
+ <div className="grid items-center gap-10 md:grid-cols-2 md:gap-16">
18
+ <div
19
+ className={`relative aspect-[4/5] w-full overflow-hidden ${flip ? "md:order-2" : ""}`}
20
+ style={{ borderRadius: RADIUS, background: MUTED }}
21
+ >
22
+ {renderImage(edit, {
23
+ field: "portrait",
24
+ src: portrait,
25
+ alt: heading,
26
+ className: "h-full w-full object-cover",
27
+ })}
28
+ </div>
29
+ <div className={flip ? "md:order-1" : ""}>
30
+ <Eyebrow>{eyebrow}</Eyebrow>
31
+ {renderText(edit, {
32
+ field: "heading",
33
+ value: heading,
34
+ as: "h2",
35
+ className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light leading-tight",
36
+ style: { fontFamily: HEADING, color: TEXT },
37
+ placeholder: "About the author",
38
+ })}
39
+ {renderText(edit, {
40
+ field: "body",
41
+ value: body,
42
+ as: "div",
43
+ className: "mt-6 space-y-4 text-[1.0625rem] leading-relaxed whitespace-pre-wrap",
44
+ style: { fontFamily: BODY, color: MUTED },
45
+ placeholder: "Tell readers who you are, what you write, and why it matters.",
46
+ multiline: true,
47
+ })}
48
+ </div>
49
+ </div>
50
+ </Section>
51
+ );
52
+ }
@@ -0,0 +1,99 @@
1
+ /** Author hero — name + tagline, over a portrait or as a tall type-only banner. */
2
+ import { renderImage, renderText, type EditPrimitives } from "../editable";
3
+ import { BODY, Eyebrow, HEADING, MUTED, Section, TEXT } from "./primitives";
4
+
5
+ export interface AuthorHeroProps {
6
+ eyebrow?: string;
7
+ name: string;
8
+ tagline?: string;
9
+ portrait?: string;
10
+ align?: "center" | "left";
11
+ edit?: EditPrimitives;
12
+ }
13
+
14
+ export function AuthorHero({ eyebrow, name, tagline, portrait, align = "center", edit }: AuthorHeroProps) {
15
+ // Left layout: split — type beside a tall portrait.
16
+ if (align === "left") {
17
+ return (
18
+ <Section style={{ paddingBlock: "calc(var(--pb-spacing,1) * 5rem)" }}>
19
+ <div className="grid items-center gap-10 md:grid-cols-2 md:gap-16">
20
+ <div>
21
+ <Eyebrow>{eyebrow}</Eyebrow>
22
+ {renderText(edit, {
23
+ field: "name",
24
+ value: name,
25
+ as: "h1",
26
+ className: "text-[clamp(2.5rem,7vw,5rem)] font-light leading-[0.95]",
27
+ style: { fontFamily: HEADING, color: TEXT },
28
+ placeholder: "Author Name",
29
+ })}
30
+ {renderText(edit, {
31
+ field: "tagline",
32
+ value: tagline ?? "",
33
+ as: "p",
34
+ className: "mt-6 max-w-md text-lg leading-relaxed",
35
+ style: { fontFamily: BODY, color: MUTED },
36
+ placeholder: "A one-line tagline about your work.",
37
+ multiline: true,
38
+ })}
39
+ </div>
40
+ <div
41
+ className="relative aspect-[4/5] w-full overflow-hidden"
42
+ style={{ borderRadius: "var(--pb-radius, 4px)", background: MUTED }}
43
+ >
44
+ {renderImage(edit, {
45
+ field: "portrait",
46
+ src: portrait,
47
+ alt: name,
48
+ priority: true,
49
+ className: "h-full w-full object-cover",
50
+ })}
51
+ </div>
52
+ </div>
53
+ </Section>
54
+ );
55
+ }
56
+
57
+ // Center layout: full-bleed portrait with overlay, or tall type-only banner.
58
+ return (
59
+ <section className="relative flex min-h-[78vh] w-full items-center justify-center overflow-hidden px-6 text-center">
60
+ {portrait ? (
61
+ <>
62
+ <div className="absolute inset-0">
63
+ {renderImage(edit, {
64
+ field: "portrait",
65
+ src: portrait,
66
+ alt: name,
67
+ priority: true,
68
+ className: "h-full w-full object-cover",
69
+ })}
70
+ </div>
71
+ <div
72
+ className="absolute inset-0"
73
+ style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.35), rgba(0,0,0,0.6))" }}
74
+ />
75
+ </>
76
+ ) : null}
77
+ <div className="relative z-10 mx-auto max-w-3xl">
78
+ <Eyebrow>{eyebrow}</Eyebrow>
79
+ {renderText(edit, {
80
+ field: "name",
81
+ value: name,
82
+ as: "h1",
83
+ className: "text-[clamp(2.75rem,9vw,6.5rem)] font-light leading-[0.95]",
84
+ style: { fontFamily: HEADING, color: portrait ? "#ffffff" : TEXT },
85
+ placeholder: "Author Name",
86
+ })}
87
+ {renderText(edit, {
88
+ field: "tagline",
89
+ value: tagline ?? "",
90
+ as: "p",
91
+ className: "mx-auto mt-6 max-w-xl text-lg leading-relaxed",
92
+ style: { fontFamily: BODY, color: portrait ? "rgba(255,255,255,0.85)" : MUTED },
93
+ placeholder: "A one-line tagline about your work.",
94
+ multiline: true,
95
+ })}
96
+ </div>
97
+ </section>
98
+ );
99
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Book grid — the author's shelf. `items` is presentational; the app resolves
3
+ * the live list (e.g. published books) before render. Columns are responsive.
4
+ */
5
+ import { renderText, type EditPrimitives } from "../editable";
6
+ import {
7
+ BODY,
8
+ type BookItem,
9
+ Eyebrow,
10
+ HAIRLINE,
11
+ HEADING,
12
+ MUTED,
13
+ RADIUS,
14
+ Section,
15
+ TEXT,
16
+ } from "./primitives";
17
+
18
+ export interface BookGridProps {
19
+ eyebrow?: string;
20
+ heading?: string;
21
+ items?: BookItem[];
22
+ /** May arrive as a number or a string from the inspector select. */
23
+ columns?: 2 | 3 | 4 | string;
24
+ edit?: EditPrimitives;
25
+ }
26
+
27
+ const PLACEHOLDER: BookItem[] = [
28
+ { title: "First Title", subtitle: "Novel" },
29
+ { title: "Second Title", subtitle: "Novel" },
30
+ { title: "Third Title", subtitle: "Short Stories" },
31
+ ];
32
+
33
+ const COLS: Record<number, string> = {
34
+ 2: "grid-cols-2 sm:grid-cols-2",
35
+ 3: "grid-cols-2 sm:grid-cols-3",
36
+ 4: "grid-cols-2 sm:grid-cols-4",
37
+ };
38
+
39
+ export function BookGrid({ eyebrow, heading, items, columns = 3, edit }: BookGridProps) {
40
+ const books = items && items.length > 0 ? items : PLACEHOLDER;
41
+ return (
42
+ <Section>
43
+ <div className="mb-10 text-center">
44
+ <Eyebrow>{eyebrow}</Eyebrow>
45
+ {renderText(edit, {
46
+ field: "heading",
47
+ value: heading ?? "",
48
+ as: "h2",
49
+ className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
50
+ style: { fontFamily: HEADING, color: TEXT },
51
+ placeholder: "Books",
52
+ })}
53
+ </div>
54
+ <div className={`grid gap-6 sm:gap-8 ${COLS[Number(columns)] ?? COLS[3]}`}>
55
+ {books.map((book, i) => {
56
+ const href = book.links?.[0]?.url;
57
+ const card = (
58
+ <>
59
+ <div
60
+ className="aspect-[2/3] w-full overflow-hidden shadow-md transition-transform duration-300 group-hover:-translate-y-1"
61
+ style={{ borderRadius: RADIUS, background: HAIRLINE }}
62
+ >
63
+ {book.coverUrl ? (
64
+ <img src={book.coverUrl} alt={book.title} loading="lazy" className="h-full w-full object-cover" />
65
+ ) : (
66
+ <div
67
+ className="flex h-full w-full items-center justify-center p-4 text-center text-[13px]"
68
+ style={{ color: MUTED, fontFamily: BODY }}
69
+ >
70
+ {book.title}
71
+ </div>
72
+ )}
73
+ </div>
74
+ <p className="mt-3 text-[15px] leading-snug" style={{ fontFamily: HEADING, color: TEXT }}>
75
+ {book.title}
76
+ </p>
77
+ {book.subtitle ? (
78
+ <p className="text-[11px] uppercase tracking-[0.18em]" style={{ fontFamily: BODY, color: MUTED }}>
79
+ {book.subtitle}
80
+ </p>
81
+ ) : null}
82
+ </>
83
+ );
84
+ return href ? (
85
+ <a key={i} href={href} target="_blank" rel="noopener noreferrer" className="group block">
86
+ {card}
87
+ </a>
88
+ ) : (
89
+ <div key={i} className="group block">
90
+ {card}
91
+ </div>
92
+ );
93
+ })}
94
+ </div>
95
+ </Section>
96
+ );
97
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Featured-book spotlight — large cover beside title, blurb, and buy links.
3
+ * `item` is presentational; the app resolves a real book into it (live data),
4
+ * so this same block works for any app with a "books" concept.
5
+ */
6
+ import { renderText, type EditPrimitives } from "../editable";
7
+ import {
8
+ AccentLink,
9
+ BODY,
10
+ type BookItem,
11
+ Eyebrow,
12
+ HAIRLINE,
13
+ HEADING,
14
+ MUTED,
15
+ RADIUS,
16
+ Section,
17
+ TEXT,
18
+ } from "./primitives";
19
+
20
+ export interface BookSpotlightProps {
21
+ eyebrow?: string;
22
+ heading?: string;
23
+ item?: BookItem;
24
+ layout?: "cover-left" | "cover-right";
25
+ edit?: EditPrimitives;
26
+ }
27
+
28
+ const PLACEHOLDER: BookItem = {
29
+ title: "Your Latest Book",
30
+ subtitle: "A Novel",
31
+ description:
32
+ "Drop in the blurb that makes a reader stop scrolling. One or two sentences of the hook, the stakes, the promise.",
33
+ links: [{ label: "Amazon", url: "#" }, { label: "Bookshop", url: "#" }],
34
+ };
35
+
36
+ export function BookSpotlight({
37
+ eyebrow,
38
+ heading,
39
+ item,
40
+ layout = "cover-left",
41
+ edit,
42
+ }: BookSpotlightProps) {
43
+ const book = item && item.title ? item : PLACEHOLDER;
44
+ const coverRight = layout === "cover-right";
45
+ return (
46
+ <Section>
47
+ {(eyebrow || heading) && (
48
+ <div className="mb-10 text-center">
49
+ <Eyebrow>{eyebrow}</Eyebrow>
50
+ {heading
51
+ ? renderText(edit, {
52
+ field: "heading",
53
+ value: heading,
54
+ as: "h2",
55
+ className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
56
+ style: { fontFamily: HEADING, color: TEXT },
57
+ placeholder: "Featured book",
58
+ })
59
+ : null}
60
+ </div>
61
+ )}
62
+ <div className="grid items-center gap-10 md:grid-cols-[0.8fr_1fr] md:gap-16">
63
+ <div
64
+ className={`mx-auto w-full max-w-[320px] ${coverRight ? "md:order-2" : ""}`}
65
+ >
66
+ <div
67
+ className="aspect-[2/3] w-full overflow-hidden shadow-xl"
68
+ style={{ borderRadius: RADIUS, background: MUTED }}
69
+ >
70
+ {book.coverUrl ? (
71
+ <img src={book.coverUrl} alt={book.title} loading="lazy" className="h-full w-full object-cover" />
72
+ ) : (
73
+ <div
74
+ className="flex h-full w-full items-center justify-center p-6 text-center text-sm"
75
+ style={{ color: MUTED, background: HAIRLINE, fontFamily: BODY }}
76
+ >
77
+ {book.title}
78
+ </div>
79
+ )}
80
+ </div>
81
+ </div>
82
+ <div className={coverRight ? "md:order-1" : ""}>
83
+ <h3 className="text-[clamp(1.5rem,3.5vw,2.25rem)] font-light leading-tight" style={{ fontFamily: HEADING, color: TEXT }}>
84
+ {book.title}
85
+ </h3>
86
+ {book.subtitle ? (
87
+ <p className="mt-1 text-sm uppercase tracking-[0.2em]" style={{ fontFamily: BODY, color: MUTED }}>
88
+ {book.subtitle}
89
+ </p>
90
+ ) : null}
91
+ {book.description ? (
92
+ <p className="mt-5 max-w-prose text-[1.0625rem] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
93
+ {book.description}
94
+ </p>
95
+ ) : null}
96
+ {book.links && book.links.length > 0 ? (
97
+ <div className="mt-7 flex flex-wrap gap-3">
98
+ {book.links.map((l, i) => (
99
+ <AccentLink key={i} href={l.url} solid={i === 0}>
100
+ {l.label}
101
+ </AccentLink>
102
+ ))}
103
+ </div>
104
+ ) : null}
105
+ </div>
106
+ </div>
107
+ </Section>
108
+ );
109
+ }
@@ -0,0 +1,64 @@
1
+ /** Contact / footer — heading, a line, an email, and social links. */
2
+ import { renderText, type EditPrimitives } from "../editable";
3
+ import { ACCENT, BODY, Eyebrow, HEADING, MUTED, Section, TEXT } from "./primitives";
4
+
5
+ export interface ContactProps {
6
+ eyebrow?: string;
7
+ heading?: string;
8
+ body?: string;
9
+ email?: string;
10
+ links?: { label: string; url: string }[];
11
+ edit?: EditPrimitives;
12
+ }
13
+
14
+ export function Contact({ eyebrow, heading = "Get in touch", body, email, links, edit }: ContactProps) {
15
+ return (
16
+ <Section narrow>
17
+ <div className="text-center">
18
+ <Eyebrow>{eyebrow}</Eyebrow>
19
+ {renderText(edit, {
20
+ field: "heading",
21
+ value: heading,
22
+ as: "h2",
23
+ className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
24
+ style: { fontFamily: HEADING, color: TEXT },
25
+ placeholder: "Get in touch",
26
+ })}
27
+ {renderText(edit, {
28
+ field: "body",
29
+ value: body ?? "",
30
+ as: "p",
31
+ className: "mx-auto mt-4 max-w-md text-[1.0625rem] leading-relaxed",
32
+ style: { fontFamily: BODY, color: MUTED },
33
+ placeholder: "For rights, events, or just to say hello.",
34
+ multiline: true,
35
+ })}
36
+ {email ? (
37
+ <a
38
+ href={`mailto:${email}`}
39
+ className="mt-6 inline-block text-lg underline-offset-4 hover:underline"
40
+ style={{ fontFamily: HEADING, color: ACCENT }}
41
+ >
42
+ {email}
43
+ </a>
44
+ ) : null}
45
+ {links && links.length > 0 ? (
46
+ <div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
47
+ {links.map((l, i) => (
48
+ <a
49
+ key={i}
50
+ href={l.url || "#"}
51
+ target={l.url?.startsWith("http") ? "_blank" : undefined}
52
+ rel={l.url?.startsWith("http") ? "noopener noreferrer" : undefined}
53
+ className="text-[13px] uppercase tracking-[0.2em] transition-opacity hover:opacity-70"
54
+ style={{ fontFamily: BODY, color: TEXT }}
55
+ >
56
+ {l.label}
57
+ </a>
58
+ ))}
59
+ </div>
60
+ ) : null}
61
+ </div>
62
+ </Section>
63
+ );
64
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * FAQ — accordion. Items from a `list` field: `question | answer` per line.
3
+ * Uses native <details> so it's RSC-safe AND works without JS (progressive
4
+ * enhancement). Portable, edit-aware.
5
+ */
6
+ import { renderText, type EditPrimitives } from "../editable";
7
+ import { BODY, HEADING, MUTED, Section, TEXT } from "./primitives";
8
+
9
+ export interface FaqProps {
10
+ heading?: string;
11
+ /** `question | answer` per line. */
12
+ items?: string;
13
+ edit?: EditPrimitives;
14
+ }
15
+
16
+ interface Qa {
17
+ q: string;
18
+ a: string;
19
+ }
20
+
21
+ function parseItems(raw?: string): Qa[] {
22
+ if (!raw) return [];
23
+ return raw
24
+ .split("\n")
25
+ .map((l) => l.trim())
26
+ .filter(Boolean)
27
+ .map((l) => {
28
+ const [q = "", a = ""] = l.split("|").map((s) => (s ?? "").trim());
29
+ return { q: q || "Question", a: a || "" };
30
+ });
31
+ }
32
+
33
+ export function Faq(props: FaqProps) {
34
+ const { heading, items, edit } = props;
35
+ const qa = parseItems(items);
36
+ return (
37
+ <Section narrow>
38
+ {heading &&
39
+ renderText(edit, {
40
+ field: "heading",
41
+ value: heading,
42
+ as: "h2",
43
+ placeholder: "FAQ",
44
+ className: "mb-8 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
45
+ style: { fontFamily: HEADING, color: TEXT },
46
+ })}
47
+ <div className="divide-y" style={{ borderColor: "color-mix(in srgb, currentColor 12%, transparent)" }}>
48
+ {qa.map((item, i) => (
49
+ <details key={i} className="group py-4">
50
+ <summary
51
+ className="flex cursor-pointer list-none items-center justify-between gap-4 text-[16px] font-medium"
52
+ style={{ fontFamily: BODY, color: TEXT }}
53
+ >
54
+ <span>{item.q}</span>
55
+ <span className="text-xl opacity-50 transition group-open:rotate-45">+</span>
56
+ </summary>
57
+ <p className="mt-3 text-[15px] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
58
+ {item.a}
59
+ </p>
60
+ </details>
61
+ ))}
62
+ </div>
63
+ </Section>
64
+ );
65
+ }