@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.
- package/README.md +86 -0
- package/package.json +54 -0
- package/src/blocks/author-bio.tsx +52 -0
- package/src/blocks/author-hero.tsx +99 -0
- package/src/blocks/book-grid.tsx +97 -0
- package/src/blocks/book-spotlight.tsx +109 -0
- package/src/blocks/contact.tsx +64 -0
- package/src/blocks/faq.tsx +65 -0
- package/src/blocks/form.tsx +234 -0
- package/src/blocks/gallery.tsx +230 -0
- package/src/blocks/index.ts +32 -0
- package/src/blocks/newsletter.tsx +100 -0
- package/src/blocks/pricing.tsx +120 -0
- package/src/blocks/primitives.tsx +109 -0
- package/src/blocks/testimonials.tsx +89 -0
- package/src/catalogs/author/catalog.tsx +496 -0
- package/src/catalogs/author/index.ts +9 -0
- package/src/catalogs/author/templates.ts +133 -0
- package/src/catalogs/publisher/catalog.tsx +308 -0
- package/src/catalogs/publisher/index.ts +10 -0
- package/src/catalogs/publisher/templates.ts +158 -0
- package/src/editable.tsx +76 -0
- package/src/editor/BlockLibrary.tsx +110 -0
- package/src/editor/Canvas.tsx +128 -0
- package/src/editor/ErrorBoundary.tsx +54 -0
- package/src/editor/InsertPoint.tsx +31 -0
- package/src/editor/Inspector.tsx +341 -0
- package/src/editor/PageBuilder.tsx +217 -0
- package/src/editor/SectionFrame.tsx +127 -0
- package/src/editor/SectionsPanel.tsx +149 -0
- package/src/editor/TemplateGallery.tsx +98 -0
- package/src/editor/ThemePanel.tsx +131 -0
- package/src/editor/Toolbar.tsx +162 -0
- package/src/editor/dnd.tsx +61 -0
- package/src/editor/edit-primitives.tsx +164 -0
- package/src/editor/index.tsx +8 -0
- package/src/editor/shortcuts.ts +106 -0
- package/src/editor/ui-context.tsx +32 -0
- package/src/index.ts +11 -0
- package/src/lib/cn.ts +8 -0
- package/src/model.ts +116 -0
- package/src/registry.ts +138 -0
- package/src/renderer/index.tsx +55 -0
- package/src/store.ts +216 -0
- 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
|
+
}
|