@bygghammar/cms-platform 0.27.0 → 0.29.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/CHANGELOG.md +22 -0
- package/package.json +9 -3
- package/src/backoffice/FormDeliveriesView.tsx +344 -0
- package/src/backoffice/FormSettingsView.tsx +436 -0
- package/src/backoffice/FormSubmissionsView.tsx +646 -0
- package/src/backoffice/GlobalSectionBuilderView.tsx +225 -0
- package/src/backoffice/GlobalSectionsListView.tsx +193 -0
- package/src/backoffice/ReviewsView.tsx +231 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { ArrowLeft, Save, Pencil, Layers, Globe } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export type GlobalSectionKind = "block" | "section";
|
|
6
|
+
|
|
7
|
+
/** Props the host editor component must accept — matches PageEditorView's contract. */
|
|
8
|
+
export type GlobalSectionEditorProps<TBlock> = {
|
|
9
|
+
pageKey: string;
|
|
10
|
+
initialBlocks: TBlock[];
|
|
11
|
+
blocks: TBlock[];
|
|
12
|
+
globals: Record<string, TBlock[]>;
|
|
13
|
+
globalSections: { key: string; name: string; kind: GlobalSectionKind; blocks: TBlock[] }[];
|
|
14
|
+
onBlocksChange: (blocks: TBlock[]) => void;
|
|
15
|
+
onDirtyChange: (dirty: boolean) => void;
|
|
16
|
+
singleBlock: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type GlobalSectionBuilderDeps<TBlock> = {
|
|
20
|
+
sectionKey: string;
|
|
21
|
+
section: { id: string; key: string; name: string; kind?: string } | undefined;
|
|
22
|
+
blocksFromServer: TBlock[] | undefined;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
error?: Error | null;
|
|
25
|
+
/** Every global for the picker, including this one — it is filtered out here. */
|
|
26
|
+
allGlobals: { key: string; name: string; kind?: string; blocks?: unknown }[];
|
|
27
|
+
/** Places this global is used, shown as a save-impact note. */
|
|
28
|
+
usage: { name: string }[];
|
|
29
|
+
save: (input: { sectionId: string; blocks: TBlock[] }) => Promise<unknown>;
|
|
30
|
+
rename: (input: { sectionId: string; name: string }) => Promise<unknown>;
|
|
31
|
+
setKind: (input: { sectionId: string; kind: GlobalSectionKind }) => Promise<unknown>;
|
|
32
|
+
refetch: () => void;
|
|
33
|
+
/** Navigates back to the blocks or global-sections library. */
|
|
34
|
+
goToLibrary: (kind: GlobalSectionKind) => void;
|
|
35
|
+
Editor: ComponentType<GlobalSectionEditorProps<TBlock>>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Controlled failure screen — an auth rejection must never blank the shell. */
|
|
39
|
+
export function GlobalSectionEditorError({ error }: { error: Error }): ReactNode {
|
|
40
|
+
const message = error?.message ?? "";
|
|
41
|
+
const unauthorized = /unauthor|forbidden|sign in|401/i.test(message);
|
|
42
|
+
return (
|
|
43
|
+
<div className="p-12">
|
|
44
|
+
<h1 className="font-serif text-2xl text-ink">
|
|
45
|
+
{unauthorized ? "Your session expired" : "This editor could not load"}
|
|
46
|
+
</h1>
|
|
47
|
+
<p className="mt-3 text-sm text-ink/60">
|
|
48
|
+
{unauthorized
|
|
49
|
+
? "Sign in again to continue editing this global section."
|
|
50
|
+
: "Something went wrong while loading this global section."}
|
|
51
|
+
</p>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => window.location.reload()}
|
|
54
|
+
className="mt-6 inline-flex items-center bg-ink text-white px-4 py-2 text-sm"
|
|
55
|
+
>
|
|
56
|
+
Reload
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Editor for one global section or global block: rename, convert kind, edit its
|
|
64
|
+
* blocks and save to every place that uses it.
|
|
65
|
+
*/
|
|
66
|
+
export function GlobalSectionBuilderView<TBlock>({
|
|
67
|
+
deps,
|
|
68
|
+
}: {
|
|
69
|
+
deps: GlobalSectionBuilderDeps<TBlock>;
|
|
70
|
+
}): ReactNode {
|
|
71
|
+
const {
|
|
72
|
+
sectionKey,
|
|
73
|
+
section,
|
|
74
|
+
blocksFromServer,
|
|
75
|
+
isLoading,
|
|
76
|
+
error,
|
|
77
|
+
allGlobals,
|
|
78
|
+
usage,
|
|
79
|
+
save,
|
|
80
|
+
rename,
|
|
81
|
+
setKind,
|
|
82
|
+
refetch,
|
|
83
|
+
goToLibrary,
|
|
84
|
+
Editor,
|
|
85
|
+
} = deps;
|
|
86
|
+
|
|
87
|
+
const [blocks, setBlocks] = useState<TBlock[]>([]);
|
|
88
|
+
const [initialBlocks, setInitialBlocks] = useState<TBlock[]>([]);
|
|
89
|
+
const [dirty, setDirty] = useState(false);
|
|
90
|
+
const [busy, setBusy] = useState(false);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (blocksFromServer) {
|
|
94
|
+
setBlocks(blocksFromServer);
|
|
95
|
+
setInitialBlocks(blocksFromServer);
|
|
96
|
+
setDirty(false);
|
|
97
|
+
}
|
|
98
|
+
}, [blocksFromServer]);
|
|
99
|
+
|
|
100
|
+
const onSave = async () => {
|
|
101
|
+
if (!section) return;
|
|
102
|
+
setBusy(true);
|
|
103
|
+
try {
|
|
104
|
+
await save({ sectionId: section.id, blocks });
|
|
105
|
+
toast.success("Saved");
|
|
106
|
+
setDirty(false);
|
|
107
|
+
refetch();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onRename = async () => {
|
|
116
|
+
if (!section) return;
|
|
117
|
+
const next = window.prompt("Name", section.name);
|
|
118
|
+
if (!next || next.trim() === "" || next === section.name) return;
|
|
119
|
+
try {
|
|
120
|
+
await rename({ sectionId: section.id, name: next.trim() });
|
|
121
|
+
toast.success("Renamed");
|
|
122
|
+
refetch();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onConvert = async (kind: GlobalSectionKind) => {
|
|
129
|
+
if (!section) return;
|
|
130
|
+
if (dirty) {
|
|
131
|
+
toast.error("Save your changes first");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await setKind({ sectionId: section.id, kind });
|
|
136
|
+
toast.success(kind === "block" ? "Now a global block" : "Now a global section");
|
|
137
|
+
refetch();
|
|
138
|
+
} catch (e) {
|
|
139
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (isLoading) return <div className="p-12 text-ink/60">Loading…</div>;
|
|
144
|
+
if (error) return <GlobalSectionEditorError error={error} />;
|
|
145
|
+
if (!section) return null;
|
|
146
|
+
|
|
147
|
+
const kind: GlobalSectionKind = section.kind === "block" ? "block" : "section";
|
|
148
|
+
const isBlock = kind === "block";
|
|
149
|
+
|
|
150
|
+
// Prevent self-reference in the picker, and never offer globals inside a global block.
|
|
151
|
+
const globalOptions = allGlobals
|
|
152
|
+
.filter((g) => g.key !== sectionKey)
|
|
153
|
+
.map((g) => ({
|
|
154
|
+
key: g.key,
|
|
155
|
+
name: g.name,
|
|
156
|
+
kind: (g.kind === "block" ? "block" : "section") as GlobalSectionKind,
|
|
157
|
+
blocks: (g.blocks ?? []) as TBlock[],
|
|
158
|
+
}));
|
|
159
|
+
// Resolve nested global blocks so the preview renders them.
|
|
160
|
+
const globalsMap: Record<string, TBlock[]> = {};
|
|
161
|
+
for (const g of allGlobals) globalsMap[g.key] = (g.blocks ?? []) as TBlock[];
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="p-6 md:p-10 w-full">
|
|
165
|
+
<button
|
|
166
|
+
onClick={() => goToLibrary(kind)}
|
|
167
|
+
className="inline-flex items-center gap-2 text-sm text-ink/60 hover:text-ink"
|
|
168
|
+
>
|
|
169
|
+
<ArrowLeft className="size-4" /> {isBlock ? "All global blocks" : "All global sections"}
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<div className="mt-3 flex flex-wrap items-end justify-between gap-4">
|
|
173
|
+
<div>
|
|
174
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 flex items-center gap-1.5">
|
|
175
|
+
{isBlock ? <Globe className="size-3" /> : <Layers className="size-3" />}
|
|
176
|
+
{isBlock ? "Global block — one block" : "Global section — several blocks"}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="flex items-center gap-2 mt-1">
|
|
179
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink">{section.name}</h1>
|
|
180
|
+
<button onClick={onRename} title="Rename" className="text-ink/40 hover:text-ink">
|
|
181
|
+
<Pencil className="size-4" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="text-xs text-ink/50 font-mono mt-1">
|
|
185
|
+
{section.key}
|
|
186
|
+
{dirty && " · unsaved changes"}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="mt-2 text-xs text-ink/60">
|
|
189
|
+
{usage.length === 0
|
|
190
|
+
? "Not used anywhere yet."
|
|
191
|
+
: `Used in ${usage.length} place${usage.length === 1 ? "" : "s"}: ${usage
|
|
192
|
+
.map((u) => u.name)
|
|
193
|
+
.join(", ")} — saving updates them all.`}
|
|
194
|
+
</div>
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => onConvert(isBlock ? "section" : "block")}
|
|
197
|
+
className="mt-2 text-xs text-sea hover:underline"
|
|
198
|
+
>
|
|
199
|
+
{isBlock ? "Convert to global section" : "Convert to global block"}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
disabled={busy || !dirty}
|
|
204
|
+
onClick={onSave}
|
|
205
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
|
|
206
|
+
>
|
|
207
|
+
<Save className="size-4" /> Save
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="mt-6">
|
|
212
|
+
<Editor
|
|
213
|
+
pageKey={`gs:${sectionKey}`}
|
|
214
|
+
initialBlocks={initialBlocks}
|
|
215
|
+
blocks={blocks}
|
|
216
|
+
globals={globalsMap}
|
|
217
|
+
globalSections={globalOptions}
|
|
218
|
+
onBlocksChange={setBlocks}
|
|
219
|
+
onDirtyChange={setDirty}
|
|
220
|
+
singleBlock={isBlock}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Trash2, Pencil, Eye } from "lucide-react";
|
|
4
|
+
import { DataThumb } from "./SectionThumb";
|
|
5
|
+
import { PreviewLightbox } from "./PreviewLightbox";
|
|
6
|
+
|
|
7
|
+
/** One reusable group of blocks as returned by the host's list function. */
|
|
8
|
+
export type GlobalSectionRow = {
|
|
9
|
+
id: string;
|
|
10
|
+
key: string;
|
|
11
|
+
name: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
blocks?: unknown;
|
|
14
|
+
kind?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type GlobalSectionsListDeps = {
|
|
18
|
+
rows: GlobalSectionRow[];
|
|
19
|
+
/** Maps a block instance to the label shown as a chip. */
|
|
20
|
+
blockLabels: (blocks: unknown) => string[];
|
|
21
|
+
create: (input: { key: string; name: string }) => Promise<unknown>;
|
|
22
|
+
remove: (sectionId: string) => Promise<unknown>;
|
|
23
|
+
refetch: () => void;
|
|
24
|
+
/** Navigates to the editor for one section key. */
|
|
25
|
+
openEditor: (key: string) => void;
|
|
26
|
+
/** Preview URL for a section key, e.g. `/preview/gs:${key}`. */
|
|
27
|
+
previewSrc: (key: string) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Library screen listing global sections — reusable groups of blocks that update
|
|
32
|
+
* everywhere at once. All data access and navigation is host-supplied.
|
|
33
|
+
*/
|
|
34
|
+
export function GlobalSectionsListView({ deps }: { deps: GlobalSectionsListDeps }): ReactNode {
|
|
35
|
+
const { rows, blockLabels, create, remove, refetch, openEditor, previewSrc } = deps;
|
|
36
|
+
const [newKey, setNewKey] = useState("");
|
|
37
|
+
const [newName, setNewName] = useState("");
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const [preview, setPreview] = useState<{ key: string; name: string } | null>(null);
|
|
40
|
+
|
|
41
|
+
const onCreate = async () => {
|
|
42
|
+
if (!newKey || !newName) return;
|
|
43
|
+
setBusy(true);
|
|
44
|
+
try {
|
|
45
|
+
await create({ key: newKey, name: newName });
|
|
46
|
+
toast.success("Global section created");
|
|
47
|
+
setNewKey("");
|
|
48
|
+
setNewName("");
|
|
49
|
+
refetch();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
52
|
+
} finally {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const onDelete = async (id: string, name: string) => {
|
|
58
|
+
if (!confirm(`Delete "${name}"? Pages referencing it will render nothing there.`)) return;
|
|
59
|
+
try {
|
|
60
|
+
await remove(id);
|
|
61
|
+
toast.success("Deleted");
|
|
62
|
+
refetch();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="p-8 md:p-12 w-full">
|
|
70
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Library</div>
|
|
71
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Global Sections</h1>
|
|
72
|
+
<p className="mt-4 max-w-2xl text-sm text-ink/60">
|
|
73
|
+
A global section holds several blocks that belong together. Reuse it on any page. Edit it
|
|
74
|
+
here and every place that uses it updates at the same time. For a single reusable block, use
|
|
75
|
+
Global blocks in the Blocks library instead.
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
<div className="mt-8 border border-ink/10 bg-white">
|
|
79
|
+
<table className="w-full text-sm">
|
|
80
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
81
|
+
<tr>
|
|
82
|
+
<th className="px-4 py-3 w-36">Preview</th>
|
|
83
|
+
<th className="px-4 py-3">Name</th>
|
|
84
|
+
<th className="px-4 py-3">Key</th>
|
|
85
|
+
<th className="px-4 py-3">Updated</th>
|
|
86
|
+
<th className="px-4 py-3 w-40"></th>
|
|
87
|
+
</tr>
|
|
88
|
+
</thead>
|
|
89
|
+
<tbody>
|
|
90
|
+
{rows.map((g) => {
|
|
91
|
+
const labels = blockLabels(g.blocks);
|
|
92
|
+
return (
|
|
93
|
+
<tr key={g.id} className="border-t border-ink/10">
|
|
94
|
+
<td className="px-4 py-3 align-top">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
title="Preview section"
|
|
98
|
+
onClick={() => setPreview({ key: g.key, name: g.name })}
|
|
99
|
+
className="group relative block"
|
|
100
|
+
>
|
|
101
|
+
<DataThumb data={g.blocks} />
|
|
102
|
+
<span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
|
|
103
|
+
<Eye className="size-4" />
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
</td>
|
|
107
|
+
<td className="px-4 py-3 align-top">
|
|
108
|
+
<div className="text-ink">{g.name}</div>
|
|
109
|
+
{labels.length > 0 && (
|
|
110
|
+
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
111
|
+
{labels.map((label, i) => (
|
|
112
|
+
<span
|
|
113
|
+
key={i}
|
|
114
|
+
className="inline-block text-[0.65rem] leading-none px-1.5 py-1 bg-ink/[0.06] text-ink/60 rounded"
|
|
115
|
+
>
|
|
116
|
+
{label}
|
|
117
|
+
</span>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</td>
|
|
122
|
+
<td className="px-4 py-3 text-ink/70 font-mono align-top">{g.key}</td>
|
|
123
|
+
<td className="px-4 py-3 text-ink/50 align-top">
|
|
124
|
+
{new Date(g.updated_at).toLocaleString()}
|
|
125
|
+
</td>
|
|
126
|
+
<td className="px-4 py-3 text-right align-top">
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => openEditor(g.key)}
|
|
129
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
130
|
+
>
|
|
131
|
+
<Pencil className="size-3.5" /> Edit
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => onDelete(g.id, g.name)}
|
|
135
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
136
|
+
>
|
|
137
|
+
<Trash2 className="size-3.5" /> Delete
|
|
138
|
+
</button>
|
|
139
|
+
</td>
|
|
140
|
+
</tr>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
{rows.length === 0 && (
|
|
144
|
+
<tr>
|
|
145
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
146
|
+
No global sections yet.
|
|
147
|
+
</td>
|
|
148
|
+
</tr>
|
|
149
|
+
)}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
155
|
+
<div className="font-serif text-lg text-ink">New global section</div>
|
|
156
|
+
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
157
|
+
<label className="block">
|
|
158
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
|
|
159
|
+
<input
|
|
160
|
+
value={newName}
|
|
161
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
162
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
163
|
+
placeholder="Site-wide FAQ"
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
<label className="block">
|
|
167
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Key</span>
|
|
168
|
+
<input
|
|
169
|
+
value={newKey}
|
|
170
|
+
onChange={(e) => setNewKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
|
171
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
172
|
+
placeholder="site-faq"
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
175
|
+
<button
|
|
176
|
+
disabled={busy || !newKey || !newName}
|
|
177
|
+
onClick={onCreate}
|
|
178
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-5 py-2 text-sm disabled:opacity-40 self-end"
|
|
179
|
+
>
|
|
180
|
+
<Plus className="size-4" /> Create
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<PreviewLightbox
|
|
186
|
+
open={preview != null}
|
|
187
|
+
onClose={() => setPreview(null)}
|
|
188
|
+
src={preview ? previewSrc(preview.key) : ""}
|
|
189
|
+
title={preview?.name ?? ""}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Trash2, ArrowUp, ArrowDown, Save, ChevronLeft } from "lucide-react";
|
|
4
|
+
import type { ComponentType } from "react";
|
|
5
|
+
|
|
6
|
+
type LocalizedText = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
/** One quote in the reviews slider. */
|
|
9
|
+
export type ReviewItem = {
|
|
10
|
+
quote: LocalizedText;
|
|
11
|
+
name: LocalizedText;
|
|
12
|
+
role: LocalizedText;
|
|
13
|
+
image?: string;
|
|
14
|
+
imageAlt?: LocalizedText;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ReviewsSection = {
|
|
18
|
+
eyebrow: LocalizedText;
|
|
19
|
+
title: LocalizedText;
|
|
20
|
+
autoplayMs: number;
|
|
21
|
+
items: ReviewItem[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ReviewsDeps = {
|
|
25
|
+
/** Current section content, already merged from overrides and code defaults. */
|
|
26
|
+
source: ReviewsSection | undefined;
|
|
27
|
+
/** Locale the editor writes to; the public site of a single-locale host uses this. */
|
|
28
|
+
locale: string;
|
|
29
|
+
save: (section: ReviewsSection) => Promise<unknown>;
|
|
30
|
+
/** Host media picker — usually the project's wrapper around `inspectors/fields`. */
|
|
31
|
+
MediaField: ComponentType<{
|
|
32
|
+
label: string;
|
|
33
|
+
value: string | undefined;
|
|
34
|
+
onChange: (url: string) => void;
|
|
35
|
+
accept?: "image" | "video" | "any";
|
|
36
|
+
}>;
|
|
37
|
+
/** Host review-image uploader, already wired to its storage. */
|
|
38
|
+
ReviewImageUpload: ComponentType<{ onUploaded: (url: string) => void }>;
|
|
39
|
+
BackLink: (props: { className?: string; children: ReactNode }) => ReactNode;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Editor for the reviews/testimonials slider content section. */
|
|
43
|
+
export function ReviewsView({ deps }: { deps: ReviewsDeps }): ReactNode {
|
|
44
|
+
const { source, locale, save, BackLink, MediaField, ReviewImageUpload } = deps;
|
|
45
|
+
const [section, setSection] = useState<ReviewsSection | null>(null);
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (source) setSection(structuredClone(source));
|
|
50
|
+
}, [source]);
|
|
51
|
+
|
|
52
|
+
if (!section) return <div className="p-8">Loading…</div>;
|
|
53
|
+
|
|
54
|
+
const one = (value: string): LocalizedText => ({ [locale]: value });
|
|
55
|
+
const update = (patch: Partial<ReviewsSection>) => setSection({ ...section, ...patch });
|
|
56
|
+
const updateItem = (i: number, patch: Partial<ReviewItem>) => {
|
|
57
|
+
const items = section.items.slice();
|
|
58
|
+
items[i] = { ...items[i], ...patch } as ReviewItem;
|
|
59
|
+
update({ items });
|
|
60
|
+
};
|
|
61
|
+
const removeItem = (i: number) => update({ items: section.items.filter((_, idx) => idx !== i) });
|
|
62
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
63
|
+
const items = section.items.slice();
|
|
64
|
+
const j = i + dir;
|
|
65
|
+
if (j < 0 || j >= items.length) return;
|
|
66
|
+
[items[i], items[j]] = [items[j]!, items[i]!];
|
|
67
|
+
update({ items });
|
|
68
|
+
};
|
|
69
|
+
const addItem = () =>
|
|
70
|
+
update({ items: [...section.items, { quote: one(""), name: one(""), role: one("") }] });
|
|
71
|
+
|
|
72
|
+
const onSave = async () => {
|
|
73
|
+
setBusy(true);
|
|
74
|
+
try {
|
|
75
|
+
await save(section);
|
|
76
|
+
toast.success("Reviews saved");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
toast.error(err instanceof Error ? err.message : "Could not save");
|
|
79
|
+
} finally {
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="p-8 md:p-12 w-full">
|
|
86
|
+
<BackLink className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink">
|
|
87
|
+
<>
|
|
88
|
+
<ChevronLeft className="size-4" />
|
|
89
|
+
Dashboard
|
|
90
|
+
</>
|
|
91
|
+
</BackLink>
|
|
92
|
+
<h1 className="mt-4 font-serif text-3xl md:text-4xl text-ink">Reviews</h1>
|
|
93
|
+
<p className="mt-3 text-ink/65 max-w-xl">
|
|
94
|
+
Edit the quotes shown in the slider. Changes appear after saving.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
<div className="mt-8 grid gap-3 md:grid-cols-2">
|
|
98
|
+
<label className="block">
|
|
99
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Eyebrow</span>
|
|
100
|
+
<input
|
|
101
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
|
|
102
|
+
value={section.eyebrow[locale] ?? ""}
|
|
103
|
+
onChange={(e) => update({ eyebrow: one(e.target.value) })}
|
|
104
|
+
/>
|
|
105
|
+
</label>
|
|
106
|
+
<label className="block">
|
|
107
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Title</span>
|
|
108
|
+
<input
|
|
109
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
|
|
110
|
+
value={section.title[locale] ?? ""}
|
|
111
|
+
onChange={(e) => update({ title: one(e.target.value) })}
|
|
112
|
+
/>
|
|
113
|
+
</label>
|
|
114
|
+
<label className="block">
|
|
115
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
116
|
+
Autoplay (ms)
|
|
117
|
+
</span>
|
|
118
|
+
<input
|
|
119
|
+
type="number"
|
|
120
|
+
min={0}
|
|
121
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
|
|
122
|
+
value={section.autoplayMs}
|
|
123
|
+
onChange={(e) => update({ autoplayMs: Number(e.target.value) || 0 })}
|
|
124
|
+
/>
|
|
125
|
+
</label>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="mt-8 space-y-4">
|
|
129
|
+
{section.items.map((it, i) => (
|
|
130
|
+
<div key={i} className="border border-ink/10 bg-white p-5">
|
|
131
|
+
<div className="flex items-center justify-between gap-3">
|
|
132
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">#{i + 1}</div>
|
|
133
|
+
<div className="flex items-center gap-1">
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => move(i, -1)}
|
|
136
|
+
disabled={i === 0}
|
|
137
|
+
className="p-2 hover:bg-ink/5 disabled:opacity-30"
|
|
138
|
+
>
|
|
139
|
+
<ArrowUp className="size-4" />
|
|
140
|
+
</button>
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => move(i, 1)}
|
|
143
|
+
disabled={i === section.items.length - 1}
|
|
144
|
+
className="p-2 hover:bg-ink/5 disabled:opacity-30"
|
|
145
|
+
>
|
|
146
|
+
<ArrowDown className="size-4" />
|
|
147
|
+
</button>
|
|
148
|
+
<button onClick={() => removeItem(i)} className="p-2 hover:bg-red-50 text-red-600">
|
|
149
|
+
<Trash2 className="size-4" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="mt-4 grid gap-3">
|
|
154
|
+
<label className="block">
|
|
155
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
156
|
+
Quote
|
|
157
|
+
</span>
|
|
158
|
+
<textarea
|
|
159
|
+
className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm min-h-[90px]"
|
|
160
|
+
value={it.quote[locale] ?? ""}
|
|
161
|
+
onChange={(e) => updateItem(i, { quote: one(e.target.value) })}
|
|
162
|
+
/>
|
|
163
|
+
</label>
|
|
164
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
165
|
+
<label className="block">
|
|
166
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
167
|
+
Name
|
|
168
|
+
</span>
|
|
169
|
+
<input
|
|
170
|
+
className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
171
|
+
value={it.name[locale] ?? ""}
|
|
172
|
+
onChange={(e) => updateItem(i, { name: one(e.target.value) })}
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
175
|
+
<label className="block">
|
|
176
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
177
|
+
Role
|
|
178
|
+
</span>
|
|
179
|
+
<input
|
|
180
|
+
className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
181
|
+
value={it.role[locale] ?? ""}
|
|
182
|
+
onChange={(e) => updateItem(i, { role: one(e.target.value) })}
|
|
183
|
+
/>
|
|
184
|
+
</label>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
187
|
+
<div className="space-y-2">
|
|
188
|
+
<MediaField
|
|
189
|
+
label="Image"
|
|
190
|
+
accept="image"
|
|
191
|
+
value={it.image}
|
|
192
|
+
onChange={(url) => updateItem(i, { image: url || undefined })}
|
|
193
|
+
/>
|
|
194
|
+
<ReviewImageUpload onUploaded={(url) => updateItem(i, { image: url })} />
|
|
195
|
+
</div>
|
|
196
|
+
<label className="block">
|
|
197
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
198
|
+
Image alt text
|
|
199
|
+
</span>
|
|
200
|
+
<input
|
|
201
|
+
className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
202
|
+
value={it.imageAlt?.[locale] ?? ""}
|
|
203
|
+
onChange={(e) => updateItem(i, { imageAlt: one(e.target.value) })}
|
|
204
|
+
/>
|
|
205
|
+
</label>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
213
|
+
<button
|
|
214
|
+
onClick={addItem}
|
|
215
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-xs uppercase tracking-[0.22em] hover:border-ink"
|
|
216
|
+
>
|
|
217
|
+
<Plus className="size-4" />
|
|
218
|
+
Add review
|
|
219
|
+
</button>
|
|
220
|
+
<button
|
|
221
|
+
onClick={onSave}
|
|
222
|
+
disabled={busy}
|
|
223
|
+
className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
|
|
224
|
+
>
|
|
225
|
+
<Save className="size-4" />
|
|
226
|
+
{busy ? "Saving…" : "Save all changes"}
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|