@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
@@ -0,0 +1,234 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Form — configurable contact/inquiry/lead capture. Fields come from a `list`
5
+ * field (`label | name | type` per line; type ∈ text/email/tel/textarea/select/
6
+ * date/checkbox, default text). Submits JSON to `actionUrl` (the app's Convex
7
+ * action — pixbox's submitForm verifies Turnstile + stores + emails).
8
+ *
9
+ * Optional spam protection: pass `turnstileSiteKey` and the block renders a
10
+ * Cloudflare Turnstile widget inline + includes the token in the submit, so the
11
+ * block is turnkey-protected without the app adding any widget code.
12
+ *
13
+ * Portable: reads only `--pb-*` tokens. In the editor (`edit` injected) the
14
+ * heading + button label are inline-editable and submit is a no-op preview.
15
+ */
16
+ import { useEffect, useRef, useState, type FormEvent } from "react";
17
+ import { renderText, type EditPrimitives } from "../editable";
18
+ import { BODY, HEADING, MUTED, RADIUS, TEXT, ACCENT, BG } from "./primitives";
19
+
20
+ export interface FormProps {
21
+ heading?: string;
22
+ /** `label | name | type` per line (name defaults from label; type defaults text). */
23
+ fields?: string;
24
+ buttonLabel?: string;
25
+ successMessage?: string;
26
+ /** POST target (app Convex action). Empty = editor preview (no-op submit). */
27
+ actionUrl?: string;
28
+ /** Optional Cloudflare Turnstile site key — renders the widget + sends the token. */
29
+ turnstileSiteKey?: string;
30
+ edit?: EditPrimitives;
31
+ }
32
+
33
+ type FieldType = "text" | "email" | "tel" | "textarea" | "select" | "date" | "checkbox";
34
+ interface FieldDef {
35
+ label: string;
36
+ name: string;
37
+ type: FieldType;
38
+ options?: string[];
39
+ required: boolean;
40
+ }
41
+
42
+ function parseFields(raw?: string): FieldDef[] {
43
+ if (!raw) return [];
44
+ return raw
45
+ .split("\n")
46
+ .map((l) => l.trim())
47
+ .filter(Boolean)
48
+ .map((l) => {
49
+ const parts = l.split("|").map((s) => (s ?? "").trim());
50
+ const label = parts[0] || "Field";
51
+ const name = parts[1] || label.toLowerCase().replace(/[^a-z0-9]+/g, "_");
52
+ const type = (parts[2] as FieldType) || "text";
53
+ const selectOpts = type === "select" && parts[3] ? parts[3].split(";").map((o) => o.trim()).filter(Boolean) : undefined;
54
+ return { label, name, type, options: selectOpts, required: !label.endsWith("?") };
55
+ });
56
+ }
57
+
58
+ declare global {
59
+ interface Window {
60
+ turnstile?: { render: (el: HTMLElement, opts: Record<string, unknown>) => string };
61
+ }
62
+ }
63
+
64
+ export function Form(props: FormProps) {
65
+ const { heading, fields, buttonLabel = "Send", successMessage, actionUrl, turnstileSiteKey, edit } = props;
66
+ const defs = parseFields(fields);
67
+ const [values, setValues] = useState<Record<string, string | boolean>>({});
68
+ const [token, setToken] = useState("");
69
+ const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
70
+ const [err, setErr] = useState<string | null>(null);
71
+
72
+ async function onSubmit(e: FormEvent<HTMLFormElement>) {
73
+ e.preventDefault();
74
+ if (edit || !actionUrl) return; // editor preview — no real submit
75
+ if (turnstileSiteKey && !token) {
76
+ setErr("Please complete the spam check.");
77
+ return;
78
+ }
79
+ setState("loading");
80
+ setErr(null);
81
+ try {
82
+ const res = await fetch(actionUrl, {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ ...values, "cf-turnstile-response": token }),
86
+ });
87
+ if (!res.ok) {
88
+ const body = await res.json().catch(() => ({}));
89
+ throw new Error(body.message ?? "Submission failed.");
90
+ }
91
+ setState("done");
92
+ } catch (e2) {
93
+ setState("error");
94
+ setErr(e2 instanceof Error ? e2.message : "Something went wrong.");
95
+ }
96
+ }
97
+
98
+ if (state === "done") {
99
+ return (
100
+ <section className="px-6 py-16 text-center sm:py-20">
101
+ <p className="text-[15px]" style={{ fontFamily: BODY, color: ACCENT }}>
102
+ {successMessage || "Thanks — your message has been sent."}
103
+ </p>
104
+ </section>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <section className="mx-auto max-w-2xl px-6 py-16 sm:py-20">
110
+ {heading &&
111
+ renderText(edit, {
112
+ field: "heading",
113
+ value: heading,
114
+ as: "h2",
115
+ placeholder: "Get in touch",
116
+ className: "mb-8 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
117
+ style: { fontFamily: HEADING, color: TEXT },
118
+ })}
119
+ <form onSubmit={onSubmit} className="space-y-3">
120
+ {defs.map((f) => {
121
+ const inputClass =
122
+ "w-full rounded-md border px-3.5 py-2.5 text-[15px] outline-none focus:border-current/40";
123
+ const borderStyle = {
124
+ fontFamily: BODY,
125
+ borderColor: "color-mix(in srgb, currentColor 18%, transparent)",
126
+ background: "transparent",
127
+ color: TEXT,
128
+ };
129
+ if (f.type === "checkbox") {
130
+ return (
131
+ <label key={f.name} className="flex items-center gap-2.5 text-[14px]" style={{ fontFamily: BODY, color: TEXT }}>
132
+ <input
133
+ type="checkbox"
134
+ name={f.name}
135
+ required={f.required}
136
+ checked={!!values[f.name]}
137
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.checked }))}
138
+ className="size-4"
139
+ />
140
+ {f.label}
141
+ </label>
142
+ );
143
+ }
144
+ return (
145
+ <div key={f.name}>
146
+ <label className="mb-1 block text-xs font-medium" style={{ fontFamily: BODY, color: MUTED }}>
147
+ {f.label}
148
+ </label>
149
+ {f.type === "textarea" ? (
150
+ <textarea
151
+ name={f.name}
152
+ required={f.required}
153
+ rows={4}
154
+ value={(values[f.name] as string) ?? ""}
155
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
156
+ className={inputClass}
157
+ style={borderStyle}
158
+ />
159
+ ) : f.type === "select" ? (
160
+ <select
161
+ name={f.name}
162
+ required={f.required}
163
+ value={(values[f.name] as string) ?? ""}
164
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
165
+ className={inputClass}
166
+ style={borderStyle}
167
+ >
168
+ <option value="">Choose…</option>
169
+ {(f.options ?? []).map((o) => (
170
+ <option key={o} value={o}>
171
+ {o}
172
+ </option>
173
+ ))}
174
+ </select>
175
+ ) : (
176
+ <input
177
+ type={f.type}
178
+ name={f.name}
179
+ required={f.required}
180
+ value={(values[f.name] as string) ?? ""}
181
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
182
+ className={inputClass}
183
+ style={borderStyle}
184
+ />
185
+ )}
186
+ </div>
187
+ );
188
+ })}
189
+ {turnstileSiteKey && !edit && <TurnstileWidget siteKey={turnstileSiteKey} onToken={setToken} />}
190
+ <button
191
+ type="submit"
192
+ disabled={state === "loading"}
193
+ className="mt-2 inline-flex w-full items-center justify-center px-6 py-3 text-[13px] font-semibold uppercase tracking-[0.2em] transition-opacity hover:opacity-90 disabled:opacity-60 sm:w-auto"
194
+ style={{ backgroundColor: ACCENT, color: BG, borderRadius: RADIUS, fontFamily: BODY }}
195
+ >
196
+ {state === "loading" ? "…" : buttonLabel}
197
+ </button>
198
+ {state === "error" && err && (
199
+ <p className="text-sm" style={{ fontFamily: BODY, color: "#c0392b" }}>
200
+ {err}
201
+ </p>
202
+ )}
203
+ </form>
204
+ </section>
205
+ );
206
+ }
207
+
208
+ /** Loads the Turnstile script once + renders the widget into a container. */
209
+ function TurnstileWidget({ siteKey, onToken }: { siteKey: string; onToken: (t: string) => void }) {
210
+ const elRef = useRef<HTMLDivElement>(null);
211
+ const tokenCb = useRef(onToken);
212
+ tokenCb.current = onToken;
213
+ useEffect(() => {
214
+ if (typeof window === "undefined" || !elRef.current) return;
215
+ const render = () => {
216
+ if (elRef.current && window.turnstile) {
217
+ window.turnstile.render(elRef.current, { sitekey: siteKey, callback: (t: string) => tokenCb.current(t) });
218
+ }
219
+ };
220
+ const existing = document.querySelector('script[src^="https://challenges.cloudflare.com/turnstile"]');
221
+ if (window.turnstile) render();
222
+ else if (!existing) {
223
+ const s = document.createElement("script");
224
+ s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
225
+ s.async = true;
226
+ s.defer = true;
227
+ s.onload = render;
228
+ document.head.appendChild(s);
229
+ } else {
230
+ existing.addEventListener("load", render);
231
+ }
232
+ }, [siteKey]);
233
+ return <div ref={elRef} className="min-h-[65px]" aria-label="Spam check" />;
234
+ }
@@ -0,0 +1,230 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Gallery — the core portfolio surface. Variants: grid (n-up, fixed aspect) or
5
+ * masonry (CSS columns, natural aspect). Lightbox is a keyboard-navigable,
6
+ * focus-trapped client island. Portable: reads only `--pb-*` tokens.
7
+ *
8
+ * Images come from a single `list` field (`url | alt` per line) so it works with
9
+ * the engine's current inspector (no structured repeater yet — upgrade path in
10
+ * Phase 7). In the editor (`edit` injected) images render inline-editable and
11
+ * the lightbox is suppressed (a preview, not a picker).
12
+ */
13
+ import { useEffect, useRef, useState } from "react";
14
+ import { renderImage, type EditPrimitives } from "../editable";
15
+ import { BODY, HEADING, MUTED, RADIUS, Section, TEXT } from "./primitives";
16
+
17
+ export interface GalleryProps {
18
+ heading?: string;
19
+ /** `url | alt` per line (alt optional). */
20
+ images?: string;
21
+ columns?: "2" | "3" | "4";
22
+ layout?: "grid" | "masonry";
23
+ aspect?: "auto" | "square" | "portrait" | "landscape";
24
+ edit?: EditPrimitives;
25
+ }
26
+
27
+ interface Shot {
28
+ src: string;
29
+ alt: string;
30
+ }
31
+
32
+ function parseImages(raw?: string): Shot[] {
33
+ if (!raw) return [];
34
+ return raw
35
+ .split("\n")
36
+ .map((l) => l.trim())
37
+ .filter(Boolean)
38
+ .map((l) => {
39
+ const [src, alt = ""] = l.split("|").map((s) => (s ?? "").trim());
40
+ return { src, alt };
41
+ })
42
+ .filter((s) => s.src);
43
+ }
44
+
45
+ const ASPECT: Record<NonNullable<GalleryProps["aspect"]>, string> = {
46
+ auto: "auto",
47
+ square: "1 / 1",
48
+ portrait: "3 / 4",
49
+ landscape: "4 / 3",
50
+ };
51
+
52
+ export function Gallery(props: GalleryProps) {
53
+ const { heading, images, columns = "3", layout = "grid", aspect = "auto", edit } = props;
54
+ const shots = parseImages(images);
55
+ const cols = Number(columns);
56
+
57
+ return (
58
+ <Section>
59
+ {heading && (
60
+ <h2
61
+ className="mb-8 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light"
62
+ style={{ fontFamily: HEADING, color: TEXT }}
63
+ >
64
+ {heading}
65
+ </h2>
66
+ )}
67
+ {shots.length === 0 ? (
68
+ <p className="py-10 text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
69
+ Add images (one per line: <code>url | alt</code>).
70
+ </p>
71
+ ) : layout === "masonry" ? (
72
+ <Masonry shots={shots} cols={cols} edit={edit} />
73
+ ) : (
74
+ <Grid shots={shots} cols={cols} aspect={aspect} edit={edit} />
75
+ )}
76
+ </Section>
77
+ );
78
+ }
79
+
80
+ function Grid({
81
+ shots,
82
+ cols,
83
+ aspect,
84
+ edit,
85
+ }: {
86
+ shots: Shot[];
87
+ cols: number;
88
+ aspect: NonNullable<GalleryProps["aspect"]>;
89
+ edit?: EditPrimitives;
90
+ }) {
91
+ const [open, setOpen] = useState<number | null>(null);
92
+ return (
93
+ <>
94
+ <div
95
+ className="grid gap-3 sm:gap-4"
96
+ style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
97
+ >
98
+ {shots.map((s, i) => (
99
+ <button
100
+ key={i}
101
+ type="button"
102
+ onClick={() => !edit && setOpen(i)}
103
+ className="group relative overflow-hidden bg-black/5"
104
+ style={{ aspectRatio: ASPECT[aspect], borderRadius: RADIUS, cursor: edit ? "default" : "zoom-in" }}
105
+ >
106
+ {renderImage(edit, {
107
+ field: `image-${i}`,
108
+ src: s.src,
109
+ alt: s.alt,
110
+ className: "h-full w-full object-cover transition group-hover:scale-[1.02]",
111
+ })}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ {open !== null && !edit && (
116
+ <Lightbox shots={shots} index={open} onClose={() => setOpen(null)} onIndex={setOpen} />
117
+ )}
118
+ </>
119
+ );
120
+ }
121
+
122
+ function Masonry({ shots, cols, edit }: { shots: Shot[]; cols: number; edit?: EditPrimitives }) {
123
+ const [open, setOpen] = useState<number | null>(null);
124
+ return (
125
+ <>
126
+ <div style={{ columnCount: cols, columnGap: "1rem" }}>
127
+ {shots.map((s, i) => (
128
+ <button
129
+ key={i}
130
+ type="button"
131
+ onClick={() => !edit && setOpen(i)}
132
+ className="mb-3 block w-full overflow-hidden bg-black/5 sm:mb-4"
133
+ style={{ borderRadius: RADIUS, cursor: edit ? "default" : "zoom-in" }}
134
+ >
135
+ {renderImage(edit, {
136
+ field: `image-${i}`,
137
+ src: s.src,
138
+ alt: s.alt,
139
+ className: "w-full object-cover",
140
+ })}
141
+ </button>
142
+ ))}
143
+ </div>
144
+ {open !== null && !edit && (
145
+ <Lightbox shots={shots} index={open} onClose={() => setOpen(null)} onIndex={setOpen} />
146
+ )}
147
+ </>
148
+ );
149
+ }
150
+
151
+ function Lightbox({
152
+ shots,
153
+ index,
154
+ onClose,
155
+ onIndex,
156
+ }: {
157
+ shots: Shot[];
158
+ index: number;
159
+ onClose: () => void;
160
+ onIndex: (i: number) => void;
161
+ }) {
162
+ const ref = useRef<HTMLDivElement>(null);
163
+ useEffect(() => {
164
+ const onKey = (e: KeyboardEvent) => {
165
+ if (e.key === "Escape") onClose();
166
+ if (e.key === "ArrowRight") onIndex((index + 1) % shots.length);
167
+ if (e.key === "ArrowLeft") onIndex((index - 1 + shots.length) % shots.length);
168
+ };
169
+ window.addEventListener("keydown", onKey);
170
+ document.body.style.overflow = "hidden";
171
+ ref.current?.focus();
172
+ return () => {
173
+ window.removeEventListener("keydown", onKey);
174
+ document.body.style.overflow = "";
175
+ };
176
+ }, [index, shots.length, onClose, onIndex]);
177
+
178
+ const s = shots[index];
179
+ return (
180
+ <div
181
+ ref={ref}
182
+ tabIndex={-1}
183
+ onClick={onClose}
184
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 outline-none"
185
+ style={{ fontFamily: BODY }}
186
+ >
187
+ <button
188
+ onClick={(e) => {
189
+ e.stopPropagation();
190
+ onClose();
191
+ }}
192
+ className="absolute right-4 top-4 rounded-full bg-white/10 px-3 py-1.5 text-sm text-white hover:bg-white/20"
193
+ >
194
+ Close ✕
195
+ </button>
196
+ {shots.length > 1 && (
197
+ <>
198
+ <button
199
+ onClick={(e) => {
200
+ e.stopPropagation();
201
+ onIndex((index - 1 + shots.length) % shots.length);
202
+ }}
203
+ className="absolute left-3 rounded-full bg-white/10 px-3 py-2 text-white hover:bg-white/20"
204
+ >
205
+
206
+ </button>
207
+ <button
208
+ onClick={(e) => {
209
+ e.stopPropagation();
210
+ onIndex((index + 1) % shots.length);
211
+ }}
212
+ className="absolute right-3 rounded-full bg-white/10 px-3 py-2 text-white hover:bg-white/20"
213
+ >
214
+
215
+ </button>
216
+ </>
217
+ )}
218
+ <figure onClick={(e) => e.stopPropagation()} className="max-h-[88vh] max-w-5xl">
219
+ {/* eslint-disable-next-line @next/next/no-img-element */}
220
+ <img src={s.src} alt={s.alt} className="max-h-[82vh] w-auto object-contain" loading="eager" />
221
+ {s.alt && (
222
+ <figcaption className="mt-3 text-center text-sm" style={{ color: "rgba(255,255,255,0.7)" }}>
223
+ {s.alt}
224
+ </figcaption>
225
+ )}
226
+ </figure>
227
+ </div>
228
+ );
229
+ }
230
+
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@jimmy_harika/websitekit/blocks` — the shared, design-system-agnostic block
3
+ * library. Every block reads only `--pb-*` theme tokens + Tailwind utilities
4
+ * (no app-UI imports) and is edit-aware, so it renders identically in any
5
+ * consuming app's editor canvas and production site. Per-industry catalogs
6
+ * (`../catalogs/*`) compose these into `SectionDefinition`s.
7
+ */
8
+ export { type BookItem } from "./primitives";
9
+ export {
10
+ ACCENT,
11
+ BG,
12
+ BODY,
13
+ HEADING,
14
+ MUTED,
15
+ RADIUS,
16
+ TEXT,
17
+ Section,
18
+ Eyebrow,
19
+ AccentLink,
20
+ } from "./primitives";
21
+
22
+ export { AuthorHero, type AuthorHeroProps } from "./author-hero";
23
+ export { AuthorBio, type AuthorBioProps } from "./author-bio";
24
+ export { BookSpotlight, type BookSpotlightProps } from "./book-spotlight";
25
+ export { BookGrid, type BookGridProps } from "./book-grid";
26
+ export { Newsletter, type NewsletterProps } from "./newsletter";
27
+ export { Contact, type ContactProps } from "./contact";
28
+ export { Gallery, type GalleryProps } from "./gallery";
29
+ export { Form, type FormProps } from "./form";
30
+ export { Pricing, type PricingProps } from "./pricing";
31
+ export { Testimonials, type TestimonialsProps } from "./testimonials";
32
+ export { Faq, type FaqProps } from "./faq";
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Newsletter capture — a client island. Presentational + self-contained: if
5
+ * `actionUrl` is set it POSTs `{ email }` there (the app's subscribe endpoint);
6
+ * otherwise it shows a client-side thank-you. Real delivery is wired by the app
7
+ * (Phase 1) or the shared Convex component (Phase 2) via `actionUrl`.
8
+ */
9
+ import { useState, type FormEvent } from "react";
10
+ import { ACCENT, BG, BODY, Eyebrow, HAIRLINE, HEADING, MUTED, Section, TEXT } from "./primitives";
11
+
12
+ export interface NewsletterProps {
13
+ eyebrow?: string;
14
+ heading?: string;
15
+ body?: string;
16
+ placeholder?: string;
17
+ cta?: string;
18
+ actionUrl?: string;
19
+ }
20
+
21
+ export function Newsletter({
22
+ eyebrow,
23
+ heading = "Stay in the loop",
24
+ body = "New releases, reading notes, the occasional behind-the-scenes. No spam.",
25
+ placeholder = "you@email.com",
26
+ cta = "Subscribe",
27
+ actionUrl,
28
+ }: NewsletterProps) {
29
+ const [email, setEmail] = useState("");
30
+ const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
31
+
32
+ async function onSubmit(e: FormEvent) {
33
+ e.preventDefault();
34
+ if (!email || state === "loading") return;
35
+ setState("loading");
36
+ try {
37
+ if (actionUrl) {
38
+ const res = await fetch(actionUrl, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify({ email }),
42
+ });
43
+ if (!res.ok) throw new Error("failed");
44
+ }
45
+ setState("done");
46
+ } catch {
47
+ setState("error");
48
+ }
49
+ }
50
+
51
+ return (
52
+ <Section narrow tint>
53
+ <div className="text-center">
54
+ <Eyebrow>{eyebrow}</Eyebrow>
55
+ <h2 className="text-[clamp(1.5rem,3.5vw,2.25rem)] font-light" style={{ fontFamily: HEADING, color: TEXT }}>
56
+ {heading}
57
+ </h2>
58
+ <p className="mx-auto mt-4 max-w-md text-[1.0625rem] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
59
+ {body}
60
+ </p>
61
+ {state === "done" ? (
62
+ <p className="mt-8 text-base" style={{ fontFamily: BODY, color: ACCENT }}>
63
+ Thanks — you’re on the list.
64
+ </p>
65
+ ) : (
66
+ <form onSubmit={onSubmit} className="mx-auto mt-8 flex max-w-md flex-col gap-3 sm:flex-row">
67
+ <input
68
+ type="email"
69
+ required
70
+ value={email}
71
+ onChange={(e) => setEmail(e.target.value)}
72
+ placeholder={placeholder}
73
+ className="min-w-0 flex-1 px-4 py-3 text-[15px] outline-none"
74
+ style={{
75
+ fontFamily: BODY,
76
+ color: TEXT,
77
+ background: BG,
78
+ border: `1px solid ${HAIRLINE}`,
79
+ borderRadius: "var(--pb-radius, 4px)",
80
+ }}
81
+ />
82
+ <button
83
+ type="submit"
84
+ disabled={state === "loading"}
85
+ className="px-6 py-3 text-[14px] font-medium tracking-wide transition-opacity hover:opacity-80 disabled:opacity-50"
86
+ style={{ fontFamily: BODY, background: ACCENT, color: BG, borderRadius: "var(--pb-radius, 4px)" }}
87
+ >
88
+ {state === "loading" ? "…" : cta}
89
+ </button>
90
+ </form>
91
+ )}
92
+ {state === "error" ? (
93
+ <p className="mt-3 text-sm" style={{ fontFamily: BODY, color: "#c0392b" }}>
94
+ Something went wrong — try again.
95
+ </p>
96
+ ) : null}
97
+ </div>
98
+ </Section>
99
+ );
100
+ }