@bygghammar/cms-platform 0.41.0 → 0.43.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
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.42.0
|
|
4
|
+
|
|
5
|
+
- Backoffice host registry gains email-settings, locale-domain, shared-content,
|
|
6
|
+
editor-component and backup adapters plus site placeholders.
|
|
7
|
+
- New package-owned routes: `AdminLayoutRoute`, `AdminOverviewRoute` (cards
|
|
8
|
+
driven by `SiteConfig.features` and superadmin role), `EmailSettingsRoute`,
|
|
9
|
+
`NavigationRoute`, `DomainsRoute`, `ReviewsRoute` and `BackupRoute`.
|
|
10
|
+
- `backofficePaths()` now derives every admin path from `backofficePath`.
|
|
11
|
+
|
|
3
12
|
## 0.41.0
|
|
4
13
|
|
|
5
14
|
- New `backoffice/routes`: a host registry (`registerBackofficeHost`) plus package-owned
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -157,7 +157,8 @@
|
|
|
157
157
|
"./server/backup-core": "./src/server/backup-core.ts",
|
|
158
158
|
"./backoffice/shell": "./src/backoffice/shell/index.ts",
|
|
159
159
|
"./backoffice/routes": "./src/backoffice/routes/index.tsx",
|
|
160
|
-
"./backoffice/routes/host": "./src/backoffice/routes/host.ts"
|
|
160
|
+
"./backoffice/routes/host": "./src/backoffice/routes/host.ts",
|
|
161
|
+
"./backoffice/inspectors": "./src/backoffice/inspectors/index.tsx"
|
|
161
162
|
},
|
|
162
163
|
"dependencies": {
|
|
163
164
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-agnostic block inspector router.
|
|
3
|
+
*
|
|
4
|
+
* The per-block inspectors live in ./blocks. This module owns the inspectors
|
|
5
|
+
* that need host plumbing (shared content sections, trips/cruises, forms) plus
|
|
6
|
+
* the router that maps a block type to its inspector. Everything site-specific
|
|
7
|
+
* — server functions, section defaults, editor components, wording — comes from
|
|
8
|
+
* the backoffice host registry.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { Link } from "@tanstack/react-router";
|
|
12
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
import { Plus, Trash2, ArrowUp, ArrowDown, Save, RotateCcw, Crosshair } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
import type { BlockInstance, BlockProps, TripListProps, TripItem } from "../../blocks/types";
|
|
18
|
+
import { tripNumber } from "../../trips";
|
|
19
|
+
import { FocalImage, FocusPicker } from "../../host";
|
|
20
|
+
import {
|
|
21
|
+
getSharedDrafts,
|
|
22
|
+
setSharedDraft,
|
|
23
|
+
clearSharedDraft,
|
|
24
|
+
subscribeSharedDrafts,
|
|
25
|
+
} from "../../cms/shared-draft-store";
|
|
26
|
+
import { backofficeHost, backofficePaths } from "../routes/host";
|
|
27
|
+
import { InspectorSection, LocalizedField, TextField } from "./fields";
|
|
28
|
+
import {
|
|
29
|
+
HeroInspector,
|
|
30
|
+
IntroInspector,
|
|
31
|
+
FaqInspector,
|
|
32
|
+
InstagramInspector,
|
|
33
|
+
ProseInspector,
|
|
34
|
+
RichTextInspector,
|
|
35
|
+
HeroFullInspector,
|
|
36
|
+
ImageMarqueeInspector,
|
|
37
|
+
LinkTilesInspector,
|
|
38
|
+
CabinTabsInspector,
|
|
39
|
+
PriceTableInspector,
|
|
40
|
+
IconGridInspector,
|
|
41
|
+
ChecklistInspector,
|
|
42
|
+
CtaBannerInspector,
|
|
43
|
+
MediaSplitInspector,
|
|
44
|
+
type BlockPatchProps,
|
|
45
|
+
} from "./blocks";
|
|
46
|
+
|
|
47
|
+
/** Localized string map as the form block stores its copy. */
|
|
48
|
+
type FormBlockCopy = {
|
|
49
|
+
formKey?: string;
|
|
50
|
+
eyebrow?: Record<string, string>;
|
|
51
|
+
title?: Record<string, string>;
|
|
52
|
+
lead?: Record<string, string>;
|
|
53
|
+
tone?: string;
|
|
54
|
+
anchor?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function uid(prefix = "f") {
|
|
58
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ---------------- GlobalRef ---------------- */
|
|
62
|
+
function GlobalRefInspector({
|
|
63
|
+
block,
|
|
64
|
+
patch,
|
|
65
|
+
globalSections,
|
|
66
|
+
}: BlockPatchProps & {
|
|
67
|
+
globalSections: { key: string; name: string }[];
|
|
68
|
+
}) {
|
|
69
|
+
const p = (block.props ?? {}) as { sectionKey?: string };
|
|
70
|
+
return (
|
|
71
|
+
<InspectorSection title="Global block">
|
|
72
|
+
<label className="block space-y-1.5">
|
|
73
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Section</span>
|
|
74
|
+
<select
|
|
75
|
+
value={p.sectionKey ?? ""}
|
|
76
|
+
onChange={(e) => patch({ sectionKey: e.target.value } as unknown as BlockProps)}
|
|
77
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
78
|
+
>
|
|
79
|
+
<option value="">— pick one —</option>
|
|
80
|
+
{globalSections.map((g) => (
|
|
81
|
+
<option key={g.key} value={g.key}>
|
|
82
|
+
{g.name} ({g.key})
|
|
83
|
+
</option>
|
|
84
|
+
))}
|
|
85
|
+
</select>
|
|
86
|
+
</label>
|
|
87
|
+
</InspectorSection>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------- Shared section editor (inline) ---------------- */
|
|
92
|
+
function SharedSectionEditor({ sectionKey }: { sectionKey: string }) {
|
|
93
|
+
const host = backofficeHost();
|
|
94
|
+
const overrides = host.content.useOverrides();
|
|
95
|
+
const site = host.content.useSite();
|
|
96
|
+
const qc = useQueryClient();
|
|
97
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
98
|
+
|
|
99
|
+
const saved = useMemo(() => {
|
|
100
|
+
const o = overrides?.[sectionKey];
|
|
101
|
+
const base = (site as unknown as Record<string, unknown>)[sectionKey];
|
|
102
|
+
const def = host.content.defaultForSection(sectionKey) ?? base;
|
|
103
|
+
// Older overrides predate newer fields (e.g. contact.formTitle/formKey) —
|
|
104
|
+
// merge the defaults in so those inputs still render.
|
|
105
|
+
return o ? host.content.withSectionDefaults(o, def) : base;
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [sectionKey, JSON.stringify(overrides?.[sectionKey] ?? null)]);
|
|
108
|
+
|
|
109
|
+
const current = saved;
|
|
110
|
+
|
|
111
|
+
const [draft, setDraft] = useState<unknown>(() => getSharedDrafts()[sectionKey] ?? current);
|
|
112
|
+
const [dirty, setDirty] = useState(() => getSharedDrafts()[sectionKey] !== undefined);
|
|
113
|
+
const [busy, setBusy] = useState(false);
|
|
114
|
+
|
|
115
|
+
// Re-seed only when the persisted value actually changes (identity-stable).
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const pending = getSharedDrafts()[sectionKey];
|
|
118
|
+
setDraft(pending ?? current);
|
|
119
|
+
setDirty(pending !== undefined);
|
|
120
|
+
}, [current, sectionKey]);
|
|
121
|
+
|
|
122
|
+
if (current === undefined) {
|
|
123
|
+
const known = host.content.editableSectionKeys.includes(sectionKey);
|
|
124
|
+
return (
|
|
125
|
+
<InspectorSection title="Shared content">
|
|
126
|
+
{known ? (
|
|
127
|
+
<Link
|
|
128
|
+
to={`${backofficePaths().sections}/${sectionKey}`}
|
|
129
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
|
|
130
|
+
>
|
|
131
|
+
Open “{sectionKey}” section
|
|
132
|
+
</Link>
|
|
133
|
+
) : (
|
|
134
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
135
|
+
This block has no shared content section. Use the overrides above.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</InspectorSection>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onSave = async () => {
|
|
143
|
+
setBusy(true);
|
|
144
|
+
try {
|
|
145
|
+
await save({ data: { key: sectionKey, data: draft } });
|
|
146
|
+
clearSharedDraft(sectionKey);
|
|
147
|
+
await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
148
|
+
toast.success("Shared content saved");
|
|
149
|
+
setDirty(false);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
toast.error(e instanceof Error ? e.message : "Save failed");
|
|
152
|
+
} finally {
|
|
153
|
+
setBusy(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const onReset = () => {
|
|
158
|
+
const def = host.content.defaultForSection(sectionKey);
|
|
159
|
+
setDraft(def);
|
|
160
|
+
setSharedDraft(sectionKey, def);
|
|
161
|
+
setDirty(true);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<InspectorSection title={`Shared content — "${sectionKey}" (all pages)`}>
|
|
166
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
167
|
+
Edits here affect every page that uses this block and show live in the canvas. They are
|
|
168
|
+
stored when you press Save shared, Save draft or Publish.
|
|
169
|
+
</p>
|
|
170
|
+
<host.components.SectionForm
|
|
171
|
+
value={draft}
|
|
172
|
+
onChange={(next) => {
|
|
173
|
+
setDraft(next);
|
|
174
|
+
setSharedDraft(sectionKey, next);
|
|
175
|
+
setDirty(true);
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={onSave}
|
|
183
|
+
disabled={!dirty || busy}
|
|
184
|
+
className="inline-flex items-center gap-1.5 bg-ink text-paper px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:bg-ink/85 disabled:opacity-40"
|
|
185
|
+
>
|
|
186
|
+
<Save className="size-3.5" /> {busy ? "Saving…" : "Save shared"}
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={onReset}
|
|
191
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-ink"
|
|
192
|
+
>
|
|
193
|
+
<RotateCcw className="size-3.5" /> Reset to default
|
|
194
|
+
</button>
|
|
195
|
+
<Link
|
|
196
|
+
to={`${backofficePaths().sections}/${sectionKey}`}
|
|
197
|
+
className="ml-auto text-[0.7rem] underline text-ink/60 hover:text-ink"
|
|
198
|
+
>
|
|
199
|
+
Open full editor
|
|
200
|
+
</Link>
|
|
201
|
+
</div>
|
|
202
|
+
</InspectorSection>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ---------------- Gallery responsive preview ---------------- */
|
|
207
|
+
function GalleryResponsivePreview() {
|
|
208
|
+
const host = backofficeHost();
|
|
209
|
+
const overrides = host.content.useOverrides();
|
|
210
|
+
const site = host.content.useSite();
|
|
211
|
+
const qc = useQueryClient();
|
|
212
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
213
|
+
const [pending, setPending] = useState<Record<string, unknown>>(() => getSharedDrafts());
|
|
214
|
+
|
|
215
|
+
useEffect(() => subscribeSharedDrafts(setPending), []);
|
|
216
|
+
|
|
217
|
+
const shared = useMemo(() => {
|
|
218
|
+
const p = pending["gallery"] as
|
|
219
|
+
{ images?: Array<{ src: string; alt: Record<string, string> }> } | undefined;
|
|
220
|
+
if (p) return p;
|
|
221
|
+
const o =
|
|
222
|
+
(overrides?.gallery as { images?: Array<{ src: string; alt: Record<string, string> }> }) ??
|
|
223
|
+
undefined;
|
|
224
|
+
return (
|
|
225
|
+
o ??
|
|
226
|
+
(
|
|
227
|
+
site as unknown as {
|
|
228
|
+
gallery: { images: Array<{ src: string; alt: Record<string, string> }> };
|
|
229
|
+
}
|
|
230
|
+
).gallery
|
|
231
|
+
);
|
|
232
|
+
}, [pending, overrides, site]);
|
|
233
|
+
|
|
234
|
+
const images = (shared?.images ?? []) as Array<{ src: string; alt: Record<string, string> }>;
|
|
235
|
+
const [device, setDevice] = useState<"desktop" | "tablet" | "mobile">("desktop");
|
|
236
|
+
const [busy, setBusy] = useState(false);
|
|
237
|
+
const [focusSrc, setFocusSrc] = useState<string | null>(null);
|
|
238
|
+
|
|
239
|
+
const persist = async (nextImages: typeof images) => {
|
|
240
|
+
const next = { ...(shared as object), images: nextImages };
|
|
241
|
+
setBusy(true);
|
|
242
|
+
try {
|
|
243
|
+
await save({ data: { key: "gallery", data: next } });
|
|
244
|
+
clearSharedDraft("gallery");
|
|
245
|
+
await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
toast.error(e instanceof Error ? e.message : "Save failed");
|
|
248
|
+
} finally {
|
|
249
|
+
setBusy(false);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
254
|
+
const j = i + dir;
|
|
255
|
+
if (j < 0 || j >= images.length) return;
|
|
256
|
+
const next = images.slice();
|
|
257
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
258
|
+
void persist(next);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Bento visual: desktop = 3 cols, first spans 2 rows; tablet = 2 cols; mobile = 1 col.
|
|
262
|
+
const gridClass =
|
|
263
|
+
device === "desktop"
|
|
264
|
+
? "grid-cols-3 grid-rows-2 h-56"
|
|
265
|
+
: device === "tablet"
|
|
266
|
+
? "grid-cols-2 auto-rows-[80px]"
|
|
267
|
+
: "grid-cols-1 auto-rows-[80px]";
|
|
268
|
+
const frameWidth = device === "desktop" ? "100%" : device === "tablet" ? 320 : 200;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<InspectorSection title="Responsive preview & order">
|
|
272
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
273
|
+
One ordering drives all breakpoints. Reorder here to update every device.
|
|
274
|
+
</p>
|
|
275
|
+
<div className="flex items-center gap-1 pb-2">
|
|
276
|
+
{(["desktop", "tablet", "mobile"] as const).map((d) => (
|
|
277
|
+
<button
|
|
278
|
+
key={d}
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={() => setDevice(d)}
|
|
281
|
+
className={`px-2 py-1 text-[0.65rem] uppercase tracking-widest border ${
|
|
282
|
+
device === d
|
|
283
|
+
? "border-sea bg-ice/50 text-ink"
|
|
284
|
+
: "border-ink/15 text-ink/55 hover:text-ink"
|
|
285
|
+
}`}
|
|
286
|
+
>
|
|
287
|
+
{d}
|
|
288
|
+
</button>
|
|
289
|
+
))}
|
|
290
|
+
{busy ? <span className="text-[0.65rem] text-ink/50 ml-auto">Saving…</span> : null}
|
|
291
|
+
</div>
|
|
292
|
+
<div className="bg-ink/5 p-3 flex justify-center">
|
|
293
|
+
<div style={{ width: frameWidth }} className="max-w-full">
|
|
294
|
+
<div className={`grid gap-1.5 ${gridClass}`}>
|
|
295
|
+
{images.map((img, i) => (
|
|
296
|
+
<div
|
|
297
|
+
key={i}
|
|
298
|
+
className={`relative overflow-hidden border border-ink/10 bg-white ${
|
|
299
|
+
device === "desktop" && i === 0 ? "row-span-2" : ""
|
|
300
|
+
}`}
|
|
301
|
+
>
|
|
302
|
+
{img.src ? (
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={() => setFocusSrc(img.src)}
|
|
306
|
+
className="block h-full w-full"
|
|
307
|
+
title="Set focus point"
|
|
308
|
+
>
|
|
309
|
+
<FocalImage
|
|
310
|
+
src={img.src}
|
|
311
|
+
responsive={false}
|
|
312
|
+
alt=""
|
|
313
|
+
className="h-full w-full object-cover"
|
|
314
|
+
/>
|
|
315
|
+
</button>
|
|
316
|
+
) : (
|
|
317
|
+
<div className="h-full w-full grid place-items-center text-[0.6rem] text-ink/40">
|
|
318
|
+
empty
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
<span className="absolute top-1 left-1 bg-ink/70 text-paper text-[0.6rem] px-1">
|
|
322
|
+
{i + 1}
|
|
323
|
+
</span>
|
|
324
|
+
</div>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div className="pt-2 space-y-1">
|
|
330
|
+
{images.map((img, i) => (
|
|
331
|
+
<div key={i} className="flex items-center gap-2 border border-ink/10 px-2 py-1">
|
|
332
|
+
<span className="text-[0.65rem] uppercase tracking-widest text-ink/55 w-6">
|
|
333
|
+
{i + 1}
|
|
334
|
+
</span>
|
|
335
|
+
<div className="h-8 w-10 shrink-0 bg-ink/5 overflow-hidden">
|
|
336
|
+
{img.src ? (
|
|
337
|
+
<FocalImage
|
|
338
|
+
src={img.src}
|
|
339
|
+
responsive={false}
|
|
340
|
+
alt=""
|
|
341
|
+
className="h-full w-full object-cover"
|
|
342
|
+
/>
|
|
343
|
+
) : null}
|
|
344
|
+
</div>
|
|
345
|
+
<span className="flex-1 truncate text-[0.7rem] text-ink/70">
|
|
346
|
+
{img.alt?.sv || img.alt?.en || img.src.split("/").pop()}
|
|
347
|
+
</span>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => setFocusSrc(img.src)}
|
|
351
|
+
disabled={!img.src}
|
|
352
|
+
className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
|
|
353
|
+
title="Set focus point"
|
|
354
|
+
>
|
|
355
|
+
<Crosshair className="size-3.5" />
|
|
356
|
+
</button>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
onClick={() => move(i, -1)}
|
|
360
|
+
disabled={i === 0 || busy}
|
|
361
|
+
className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
|
|
362
|
+
title="Move up"
|
|
363
|
+
>
|
|
364
|
+
<ArrowUp className="size-3.5" />
|
|
365
|
+
</button>
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={() => move(i, 1)}
|
|
369
|
+
disabled={i === images.length - 1 || busy}
|
|
370
|
+
className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
|
|
371
|
+
title="Move down"
|
|
372
|
+
>
|
|
373
|
+
<ArrowDown className="size-3.5" />
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
{focusSrc ? (
|
|
379
|
+
<FocusPicker
|
|
380
|
+
src={focusSrc}
|
|
381
|
+
modal
|
|
382
|
+
title={focusSrc.split("/").pop()}
|
|
383
|
+
onClose={() => setFocusSrc(null)}
|
|
384
|
+
/>
|
|
385
|
+
) : null}
|
|
386
|
+
</InspectorSection>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function GlobalContentNotice({ blockType }: { blockType: string }) {
|
|
391
|
+
return (
|
|
392
|
+
<div className="space-y-3">
|
|
393
|
+
<p className="text-sm text-ink/70">
|
|
394
|
+
This block renders shared site content. Edit its text, images and links in the Sections
|
|
395
|
+
editor for consistent styling across every page.
|
|
396
|
+
</p>
|
|
397
|
+
<Link
|
|
398
|
+
to={`${backofficePaths().sections}/${blockType}`}
|
|
399
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
|
|
400
|
+
>
|
|
401
|
+
Open “{blockType}” section
|
|
402
|
+
</Link>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ---------------- Trips list ---------------- */
|
|
408
|
+
function TripListInspector({ block, patch }: BlockPatchProps) {
|
|
409
|
+
const labels = backofficeHost().labels?.trips;
|
|
410
|
+
const one = labels?.singular ?? "Trip";
|
|
411
|
+
const many = labels?.plural ?? "Trips";
|
|
412
|
+
const p = (block.props ?? {}) as TripListProps;
|
|
413
|
+
const items = p.items ?? [];
|
|
414
|
+
const update = (next: TripListProps) => patch(next as unknown as BlockProps);
|
|
415
|
+
const setItem = (i: number, item: TripItem) => {
|
|
416
|
+
const next = items.slice();
|
|
417
|
+
next[i] = item;
|
|
418
|
+
update({ ...p, items: next });
|
|
419
|
+
};
|
|
420
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
421
|
+
const j = i + dir;
|
|
422
|
+
if (j < 0 || j >= items.length) return;
|
|
423
|
+
const next = items.slice();
|
|
424
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
425
|
+
update({ ...p, items: next });
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<>
|
|
430
|
+
<InspectorSection title="Heading">
|
|
431
|
+
<LocalizedField
|
|
432
|
+
label="Eyebrow"
|
|
433
|
+
value={p.eyebrow}
|
|
434
|
+
onChange={(v) => update({ ...p, eyebrow: v })}
|
|
435
|
+
/>
|
|
436
|
+
<LocalizedField
|
|
437
|
+
label="Title"
|
|
438
|
+
value={p.title}
|
|
439
|
+
onChange={(v) => update({ ...p, title: v })}
|
|
440
|
+
/>
|
|
441
|
+
<LocalizedField
|
|
442
|
+
label="Lead"
|
|
443
|
+
multiline
|
|
444
|
+
value={p.lead}
|
|
445
|
+
onChange={(v) => update({ ...p, lead: v })}
|
|
446
|
+
/>
|
|
447
|
+
<LocalizedField
|
|
448
|
+
label="Note"
|
|
449
|
+
multiline
|
|
450
|
+
value={p.note}
|
|
451
|
+
onChange={(v) => update({ ...p, note: v })}
|
|
452
|
+
/>
|
|
453
|
+
<TextField
|
|
454
|
+
label="Anchor id (optional)"
|
|
455
|
+
value={p.anchor ?? ""}
|
|
456
|
+
onChange={(v) => update({ ...p, anchor: v })}
|
|
457
|
+
/>
|
|
458
|
+
<label className="block space-y-1.5">
|
|
459
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
460
|
+
Background
|
|
461
|
+
</span>
|
|
462
|
+
<select
|
|
463
|
+
value={p.tone ?? "white"}
|
|
464
|
+
onChange={(e) => update({ ...p, tone: e.target.value as TripListProps["tone"] })}
|
|
465
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
466
|
+
>
|
|
467
|
+
<option value="white">White</option>
|
|
468
|
+
<option value="paper">Paper</option>
|
|
469
|
+
</select>
|
|
470
|
+
</label>
|
|
471
|
+
<label className="block space-y-1.5">
|
|
472
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
473
|
+
Columns
|
|
474
|
+
</span>
|
|
475
|
+
<select
|
|
476
|
+
value={String(p.columns ?? 3)}
|
|
477
|
+
onChange={(e) =>
|
|
478
|
+
update({ ...p, columns: Number(e.target.value) as TripListProps["columns"] })
|
|
479
|
+
}
|
|
480
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
481
|
+
>
|
|
482
|
+
<option value="2">2</option>
|
|
483
|
+
<option value="3">3</option>
|
|
484
|
+
<option value="4">4</option>
|
|
485
|
+
</select>
|
|
486
|
+
</label>
|
|
487
|
+
<label className="block space-y-1.5">
|
|
488
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
489
|
+
{`${one} source`}
|
|
490
|
+
</span>
|
|
491
|
+
<select
|
|
492
|
+
value={p.source ?? "global"}
|
|
493
|
+
onChange={(e) => update({ ...p, source: e.target.value as TripListProps["source"] })}
|
|
494
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
495
|
+
>
|
|
496
|
+
<option value="global">{`${many} (single source of truth)`}</option>
|
|
497
|
+
<option value="custom">Custom list (this block only)</option>
|
|
498
|
+
</select>
|
|
499
|
+
</label>
|
|
500
|
+
{(p.source ?? "global") === "global" ? (
|
|
501
|
+
<>
|
|
502
|
+
<TextField
|
|
503
|
+
label="Filter by year (empty = all)"
|
|
504
|
+
value={p.year ?? ""}
|
|
505
|
+
onChange={(v) => update({ ...p, year: v })}
|
|
506
|
+
/>
|
|
507
|
+
<p className="text-xs text-ink/55">
|
|
508
|
+
{`${many} are managed centrally in Backoffice → ${many}. Edit them there and every page updates.`}
|
|
509
|
+
</p>
|
|
510
|
+
</>
|
|
511
|
+
) : null}
|
|
512
|
+
</InspectorSection>
|
|
513
|
+
{(p.source ?? "global") === "global" ? null : (
|
|
514
|
+
<InspectorSection title={`${many} (${items.length})`}>
|
|
515
|
+
<div className="space-y-4">
|
|
516
|
+
{items.map((item, i) => (
|
|
517
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
|
|
518
|
+
<div className="flex items-center justify-between">
|
|
519
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
520
|
+
#{i + 1}
|
|
521
|
+
</span>
|
|
522
|
+
<div className="flex items-center gap-1">
|
|
523
|
+
<button
|
|
524
|
+
onClick={() => move(i, -1)}
|
|
525
|
+
className="p-1 text-ink/50 hover:text-ink"
|
|
526
|
+
title="Move up"
|
|
527
|
+
>
|
|
528
|
+
<ArrowUp className="size-3.5" />
|
|
529
|
+
</button>
|
|
530
|
+
<button
|
|
531
|
+
onClick={() => move(i, 1)}
|
|
532
|
+
className="p-1 text-ink/50 hover:text-ink"
|
|
533
|
+
title="Move down"
|
|
534
|
+
>
|
|
535
|
+
<ArrowDown className="size-3.5" />
|
|
536
|
+
</button>
|
|
537
|
+
<button
|
|
538
|
+
onClick={() => {
|
|
539
|
+
const next = items.slice();
|
|
540
|
+
next.splice(i, 1);
|
|
541
|
+
update({ ...p, items: next });
|
|
542
|
+
}}
|
|
543
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
544
|
+
title="Delete"
|
|
545
|
+
>
|
|
546
|
+
<Trash2 className="size-3.5" />
|
|
547
|
+
</button>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
<LocalizedField
|
|
551
|
+
label={`${one} no`}
|
|
552
|
+
value={tripNumber(item)}
|
|
553
|
+
onChange={(v) => setItem(i, { ...item, tripNo: v })}
|
|
554
|
+
/>
|
|
555
|
+
<LocalizedField
|
|
556
|
+
label="Location"
|
|
557
|
+
value={item.location}
|
|
558
|
+
onChange={(v) => setItem(i, { ...item, location: v })}
|
|
559
|
+
/>
|
|
560
|
+
<LocalizedField
|
|
561
|
+
label="Dates"
|
|
562
|
+
value={item.dates}
|
|
563
|
+
onChange={(v) => setItem(i, { ...item, dates: v })}
|
|
564
|
+
/>
|
|
565
|
+
<LocalizedField
|
|
566
|
+
label="Accommodation"
|
|
567
|
+
value={item.accommodation}
|
|
568
|
+
onChange={(v) => setItem(i, { ...item, accommodation: v })}
|
|
569
|
+
/>
|
|
570
|
+
<LocalizedField
|
|
571
|
+
label="Price"
|
|
572
|
+
value={item.price}
|
|
573
|
+
onChange={(v) => setItem(i, { ...item, price: v })}
|
|
574
|
+
/>
|
|
575
|
+
<LocalizedField
|
|
576
|
+
label="Original price"
|
|
577
|
+
value={item.oldPrice}
|
|
578
|
+
onChange={(v) => setItem(i, { ...item, oldPrice: v })}
|
|
579
|
+
/>
|
|
580
|
+
<LocalizedField
|
|
581
|
+
label="Badge"
|
|
582
|
+
value={item.badge}
|
|
583
|
+
onChange={(v) => setItem(i, { ...item, badge: v })}
|
|
584
|
+
/>
|
|
585
|
+
<LocalizedField
|
|
586
|
+
label="Button label"
|
|
587
|
+
value={item.bookLabel}
|
|
588
|
+
onChange={(v) => setItem(i, { ...item, bookLabel: v })}
|
|
589
|
+
/>
|
|
590
|
+
<TextField
|
|
591
|
+
label="Booking URL"
|
|
592
|
+
value={item.bookUrl ?? ""}
|
|
593
|
+
onChange={(v) => setItem(i, { ...item, bookUrl: v })}
|
|
594
|
+
/>
|
|
595
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
596
|
+
<input
|
|
597
|
+
type="checkbox"
|
|
598
|
+
checked={!!item.featured}
|
|
599
|
+
onChange={(e) => setItem(i, { ...item, featured: e.target.checked })}
|
|
600
|
+
/>
|
|
601
|
+
{`Highlight this ${one.toLowerCase()}`}
|
|
602
|
+
</label>
|
|
603
|
+
</div>
|
|
604
|
+
))}
|
|
605
|
+
</div>
|
|
606
|
+
<button
|
|
607
|
+
onClick={() =>
|
|
608
|
+
update({
|
|
609
|
+
...p,
|
|
610
|
+
items: [
|
|
611
|
+
...items,
|
|
612
|
+
{
|
|
613
|
+
id: uid("t"),
|
|
614
|
+
tripNo: {},
|
|
615
|
+
location: {},
|
|
616
|
+
dates: {},
|
|
617
|
+
accommodation: {},
|
|
618
|
+
price: {},
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
className="mt-1 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
624
|
+
>
|
|
625
|
+
<Plus className="size-4" /> {`Add ${one.toLowerCase()}`}
|
|
626
|
+
</button>
|
|
627
|
+
</InspectorSection>
|
|
628
|
+
)}
|
|
629
|
+
</>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* ---------------- Form ---------------- */
|
|
634
|
+
function FormInspector({ block, patch }: BlockPatchProps) {
|
|
635
|
+
const p = (block.props ?? {}) as FormBlockCopy;
|
|
636
|
+
const listAll = useServerFn(backofficeHost().fns.listForms);
|
|
637
|
+
const { data: forms } = useQuery<Array<{ key: string; name: string; is_active: boolean }>>({
|
|
638
|
+
queryKey: ["backoffice-forms"],
|
|
639
|
+
queryFn: () => listAll() as Promise<Array<{ key: string; name: string; is_active: boolean }>>,
|
|
640
|
+
staleTime: 30_000,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return (
|
|
644
|
+
<>
|
|
645
|
+
<InspectorSection title="Form">
|
|
646
|
+
<label className="block">
|
|
647
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Which form</span>
|
|
648
|
+
<select
|
|
649
|
+
value={p.formKey ?? ""}
|
|
650
|
+
onChange={(e) => patch({ ...p, formKey: e.target.value } as unknown as BlockProps)}
|
|
651
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
652
|
+
>
|
|
653
|
+
<option value="">Select a form…</option>
|
|
654
|
+
{(forms ?? []).map((f) => (
|
|
655
|
+
<option key={f.key} value={f.key}>
|
|
656
|
+
{f.name} ({f.key}){f.is_active ? "" : " — inactive"}
|
|
657
|
+
</option>
|
|
658
|
+
))}
|
|
659
|
+
</select>
|
|
660
|
+
</label>
|
|
661
|
+
<p className="text-xs text-ink/50">
|
|
662
|
+
Fields, notifications, spam protection and the submissions inbox are managed in{" "}
|
|
663
|
+
<Link to={backofficePaths().forms} className="underline">
|
|
664
|
+
Forms
|
|
665
|
+
</Link>
|
|
666
|
+
.
|
|
667
|
+
</p>
|
|
668
|
+
</InspectorSection>
|
|
669
|
+
<InspectorSection title="Content">
|
|
670
|
+
<LocalizedField
|
|
671
|
+
label="Eyebrow"
|
|
672
|
+
value={p.eyebrow}
|
|
673
|
+
onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
|
|
674
|
+
/>
|
|
675
|
+
<LocalizedField
|
|
676
|
+
label="Title"
|
|
677
|
+
value={p.title}
|
|
678
|
+
onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
|
|
679
|
+
/>
|
|
680
|
+
<LocalizedField
|
|
681
|
+
label="Lead"
|
|
682
|
+
value={p.lead}
|
|
683
|
+
multiline
|
|
684
|
+
onChange={(v) => patch({ ...p, lead: v } as unknown as BlockProps)}
|
|
685
|
+
/>
|
|
686
|
+
</InspectorSection>
|
|
687
|
+
<InspectorSection title="Layout">
|
|
688
|
+
<label className="block">
|
|
689
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Tone</span>
|
|
690
|
+
<select
|
|
691
|
+
value={p.tone ?? "paper"}
|
|
692
|
+
onChange={(e) => patch({ ...p, tone: e.target.value } as unknown as BlockProps)}
|
|
693
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
694
|
+
>
|
|
695
|
+
<option value="white">White</option>
|
|
696
|
+
<option value="paper">Paper</option>
|
|
697
|
+
<option value="ink">Ink (dark)</option>
|
|
698
|
+
</select>
|
|
699
|
+
</label>
|
|
700
|
+
<TextField
|
|
701
|
+
label="Anchor id"
|
|
702
|
+
value={p.anchor ?? ""}
|
|
703
|
+
onChange={(v) => patch({ ...p, anchor: v } as unknown as BlockProps)}
|
|
704
|
+
/>
|
|
705
|
+
</InspectorSection>
|
|
706
|
+
</>
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/* ---------------- Router ---------------- */
|
|
711
|
+
export function BlockInspector({
|
|
712
|
+
block,
|
|
713
|
+
patch,
|
|
714
|
+
globalSections,
|
|
715
|
+
}: BlockPatchProps & {
|
|
716
|
+
globalSections: { key: string; name: string }[];
|
|
717
|
+
}) {
|
|
718
|
+
switch (block.type) {
|
|
719
|
+
case "hero":
|
|
720
|
+
return <HeroInspector block={block} patch={patch} />;
|
|
721
|
+
case "intro":
|
|
722
|
+
return <IntroInspector block={block} patch={patch} />;
|
|
723
|
+
case "faq":
|
|
724
|
+
return <FaqInspector block={block} patch={patch} />;
|
|
725
|
+
case "instagram":
|
|
726
|
+
return <InstagramInspector block={block} patch={patch} />;
|
|
727
|
+
case "prose":
|
|
728
|
+
return <ProseInspector block={block} patch={patch} />;
|
|
729
|
+
case "richText":
|
|
730
|
+
return <RichTextInspector block={block} patch={patch} />;
|
|
731
|
+
case "checklist":
|
|
732
|
+
return <ChecklistInspector block={block} patch={patch} />;
|
|
733
|
+
case "iconGrid":
|
|
734
|
+
return <IconGridInspector block={block} patch={patch} />;
|
|
735
|
+
case "imageMarquee":
|
|
736
|
+
return <ImageMarqueeInspector block={block} patch={patch} />;
|
|
737
|
+
case "linkTiles":
|
|
738
|
+
return <LinkTilesInspector block={block} patch={patch} />;
|
|
739
|
+
case "cabinTabs":
|
|
740
|
+
return <CabinTabsInspector block={block} patch={patch} />;
|
|
741
|
+
case "priceTable":
|
|
742
|
+
return <PriceTableInspector block={block} patch={patch} />;
|
|
743
|
+
case "tripList":
|
|
744
|
+
case "courseList":
|
|
745
|
+
return <TripListInspector block={block} patch={patch} />;
|
|
746
|
+
case "ctaBanner":
|
|
747
|
+
return <CtaBannerInspector block={block} patch={patch} />;
|
|
748
|
+
case "mediaSplit":
|
|
749
|
+
return <MediaSplitInspector block={block} patch={patch} />;
|
|
750
|
+
case "heroFull":
|
|
751
|
+
return <HeroFullInspector block={block} patch={patch} />;
|
|
752
|
+
case "form":
|
|
753
|
+
return <FormInspector block={block} patch={patch} />;
|
|
754
|
+
case "globalRef":
|
|
755
|
+
return <GlobalRefInspector block={block} patch={patch} globalSections={globalSections} />;
|
|
756
|
+
case "gallery":
|
|
757
|
+
return (
|
|
758
|
+
<>
|
|
759
|
+
<SharedSectionEditor sectionKey="gallery" />
|
|
760
|
+
<GalleryResponsivePreview />
|
|
761
|
+
</>
|
|
762
|
+
);
|
|
763
|
+
case "contactCta":
|
|
764
|
+
// The contact CTA block renders content from BOTH shared sections:
|
|
765
|
+
// "cta" (title, body, CTA, form) and "contact" (eyebrow/form title,
|
|
766
|
+
// labels, contact copy) — expose both so every string is editable.
|
|
767
|
+
return (
|
|
768
|
+
<>
|
|
769
|
+
<SharedSectionEditor sectionKey="cta" />
|
|
770
|
+
<SharedSectionEditor sectionKey="contact" />
|
|
771
|
+
</>
|
|
772
|
+
);
|
|
773
|
+
case "stats":
|
|
774
|
+
case "features":
|
|
775
|
+
case "destinations":
|
|
776
|
+
case "testimonials":
|
|
777
|
+
return <SharedSectionEditor sectionKey={block.type} />;
|
|
778
|
+
case "trips":
|
|
779
|
+
case "packages":
|
|
780
|
+
return <SharedSectionEditor sectionKey="trips" />;
|
|
781
|
+
default:
|
|
782
|
+
return <GlobalContentNotice blockType={block.type} />;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
@@ -6,8 +6,17 @@
|
|
|
6
6
|
* is a two-line `createFileRoute(...)({ component: XRoute })`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
|
|
9
11
|
import type { SiteConfig } from "../../config/site-config";
|
|
10
12
|
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
|
+
/** Media field component as the reviews editor consumes it. */
|
|
15
|
+
export type MediaFieldComponent = (props: any) => ReactNode;
|
|
16
|
+
/** Review image uploader component as the reviews editor consumes it. */
|
|
17
|
+
export type ReviewImageUploadComponent = (props: any) => ReactNode;
|
|
18
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
19
|
+
|
|
11
20
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
12
21
|
/** A TanStack server function as seen from the host (called via `useServerFn`). */
|
|
13
22
|
export type HostServerFn = (...args: any[]) => Promise<any>;
|
|
@@ -55,9 +64,63 @@ export type BackofficeHost = {
|
|
|
55
64
|
getContentOverrides: HostServerFn;
|
|
56
65
|
saveContentSection: HostServerFn;
|
|
57
66
|
// forms
|
|
67
|
+
listForms: HostServerFn;
|
|
58
68
|
getFormDefaults: HostServerFn;
|
|
59
69
|
saveFormDefaults: HostServerFn;
|
|
60
70
|
purgeOldSubmissions: HostServerFn;
|
|
71
|
+
// email settings
|
|
72
|
+
getEmailSettings: HostServerFn;
|
|
73
|
+
saveEmailSettings: HostServerFn;
|
|
74
|
+
listEmailProviders: HostServerFn;
|
|
75
|
+
upsertEmailProvider: HostServerFn;
|
|
76
|
+
deleteEmailProvider: HostServerFn;
|
|
77
|
+
sendTestEmail: HostServerFn;
|
|
78
|
+
// locale domains
|
|
79
|
+
saveLocaleDomain: HostServerFn;
|
|
80
|
+
deleteLocaleDomain: HostServerFn;
|
|
81
|
+
};
|
|
82
|
+
/** Site-specific example values shown as input placeholders. */
|
|
83
|
+
placeholders?: {
|
|
84
|
+
fromEmail?: string;
|
|
85
|
+
notifyTo?: string;
|
|
86
|
+
domain?: string;
|
|
87
|
+
};
|
|
88
|
+
/** Shared-content adapters: resolved site content plus its cache key. */
|
|
89
|
+
content: {
|
|
90
|
+
/** React hook returning the resolved (defaults + overrides) site content. */
|
|
91
|
+
useSite: () => Record<string, unknown>;
|
|
92
|
+
/** React hook returning the stored overrides, or undefined while loading. */
|
|
93
|
+
useOverrides: () => Record<string, unknown> | undefined;
|
|
94
|
+
/** Query key of the overrides query, so routes can invalidate it. */
|
|
95
|
+
overridesQueryKey: readonly unknown[];
|
|
96
|
+
/** Code defaults for the whole site content tree. */
|
|
97
|
+
defaults: Record<string, unknown>;
|
|
98
|
+
/** Code default for a single shared section. */
|
|
99
|
+
defaultForSection: (key: string) => unknown;
|
|
100
|
+
/** Merges code defaults into a stored override so newer fields still render. */
|
|
101
|
+
withSectionDefaults: (stored: unknown, defaults: unknown) => unknown;
|
|
102
|
+
/** Keys of the shared sections a site exposes in its sections editor. */
|
|
103
|
+
editableSectionKeys: string[];
|
|
104
|
+
};
|
|
105
|
+
/** Editor components a site wires with its own locales, media and forms. */
|
|
106
|
+
components: {
|
|
107
|
+
/** Recursive shared-content editor. */
|
|
108
|
+
SectionForm: (props: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
|
|
109
|
+
/** Single media field used by the reviews editor. */
|
|
110
|
+
MediaField: MediaFieldComponent;
|
|
111
|
+
/** Review portrait uploader. */
|
|
112
|
+
ReviewImageUpload: ReviewImageUploadComponent;
|
|
113
|
+
};
|
|
114
|
+
/** Site wording for domain concepts the shared UI names. */
|
|
115
|
+
labels?: {
|
|
116
|
+
/** Singular/plural noun for the trips module (e.g. Cruise/Cruises). */
|
|
117
|
+
trips?: { singular: string; plural: string };
|
|
118
|
+
};
|
|
119
|
+
/** Backup screen adapters — auth headers plus browser media archiving. */
|
|
120
|
+
backup?: {
|
|
121
|
+
authHeaders: () => Promise<Record<string, string>>;
|
|
122
|
+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
123
|
+
buildBrowserMediaArchive: (args: any) => Promise<any>;
|
|
61
124
|
};
|
|
62
125
|
};
|
|
63
126
|
|
|
@@ -89,5 +152,17 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
|
|
|
89
152
|
emailLogs: `${base}/admin/email-logs`,
|
|
90
153
|
users: `${base}/admin/users`,
|
|
91
154
|
forms: `${base}/forms`,
|
|
155
|
+
settings: `${base}/admin/settings`,
|
|
156
|
+
roles: `${base}/admin/roles`,
|
|
157
|
+
logs: `${base}/admin/logs`,
|
|
158
|
+
email: `${base}/admin/email`,
|
|
159
|
+
contact: `${base}/admin/contact`,
|
|
160
|
+
tracking: `${base}/admin/tracking`,
|
|
161
|
+
redirects: `${base}/admin/redirects`,
|
|
162
|
+
navigation: `${base}/admin/navigation`,
|
|
163
|
+
version: `${base}/admin/version`,
|
|
164
|
+
backup: `${base}/admin/backup`,
|
|
165
|
+
sections: `${base}/sections`,
|
|
166
|
+
globalSections: `${base}/global-sections`,
|
|
92
167
|
};
|
|
93
168
|
}
|
|
@@ -6,12 +6,33 @@
|
|
|
6
6
|
* file only maps a URL to one of these.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { Link, useNavigate } from "@tanstack/react-router";
|
|
9
|
+
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
+
import {
|
|
11
|
+
AlertCircle,
|
|
12
|
+
ArrowLeft,
|
|
13
|
+
Building2,
|
|
14
|
+
Code2,
|
|
15
|
+
DatabaseBackup,
|
|
16
|
+
FileText,
|
|
17
|
+
Mail,
|
|
18
|
+
Menu,
|
|
19
|
+
Package,
|
|
20
|
+
ScrollText,
|
|
21
|
+
Shield,
|
|
22
|
+
Signpost,
|
|
23
|
+
SlidersHorizontal,
|
|
24
|
+
Users,
|
|
25
|
+
} from "lucide-react";
|
|
10
26
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
11
27
|
import { useServerFn } from "@tanstack/react-start";
|
|
12
28
|
|
|
13
29
|
import type { SiteSettingOverrides } from "../../config/site-config";
|
|
14
30
|
import { AuditView } from "../AuditView";
|
|
31
|
+
import { BackupView } from "../BackupView";
|
|
32
|
+
import { DomainsView } from "../DomainsView";
|
|
33
|
+
import { EmailSettingsView } from "../EmailSettingsView";
|
|
34
|
+
import { NavigationView } from "../NavigationView";
|
|
35
|
+
import { ReviewsView, type ReviewsSection } from "../ReviewsView";
|
|
15
36
|
import { ContactDetailsView } from "../ContactDetailsView";
|
|
16
37
|
import { EmailLogsView } from "../EmailLogsView";
|
|
17
38
|
import { FormSettingsView } from "../FormSettingsView";
|
|
@@ -229,3 +250,307 @@ export function useSeoListNavigate() {
|
|
|
229
250
|
const paths = backofficePaths();
|
|
230
251
|
return () => navigate({ to: `${paths.base}/seo` });
|
|
231
252
|
}
|
|
253
|
+
|
|
254
|
+
/* ------------------------------------------------------------------ *
|
|
255
|
+
* Admin section: layout, overview and the remaining admin screens.
|
|
256
|
+
* ------------------------------------------------------------------ */
|
|
257
|
+
|
|
258
|
+
/** Chrome around every `/admin/*` screen: a back link plus the section title. */
|
|
259
|
+
export function AdminLayoutRoute() {
|
|
260
|
+
const paths = backofficePaths();
|
|
261
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
262
|
+
const isOverview = pathname.replace(/\/$/, "") === paths.admin;
|
|
263
|
+
const backTo = isOverview ? paths.base : paths.admin;
|
|
264
|
+
const backLabel = isOverview ? "Back to backoffice" : "Back to admin";
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="min-h-screen">
|
|
268
|
+
<div className="border-b border-ink/10 bg-white">
|
|
269
|
+
<div className="px-8 md:px-12 py-6">
|
|
270
|
+
<Link
|
|
271
|
+
to={backTo}
|
|
272
|
+
className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea transition-colors"
|
|
273
|
+
>
|
|
274
|
+
<ArrowLeft className="size-3.5" />
|
|
275
|
+
{backLabel}
|
|
276
|
+
</Link>
|
|
277
|
+
<h1 className="mt-3 font-serif text-2xl text-ink">System administration</h1>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<Outlet />
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Card grid linking to every admin screen the signed-in user may open. */
|
|
286
|
+
export function AdminOverviewRoute() {
|
|
287
|
+
const host = backofficeHost();
|
|
288
|
+
const paths = backofficePaths(host);
|
|
289
|
+
const fetchRoles = useServerFn(host.fns.getMyRoles);
|
|
290
|
+
const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
|
|
291
|
+
const isSuper = ((rolesQ.data ?? []) as string[]).includes("superadmin");
|
|
292
|
+
|
|
293
|
+
const cards = [
|
|
294
|
+
{
|
|
295
|
+
to: paths.settings,
|
|
296
|
+
title: "Site settings",
|
|
297
|
+
body: "Site name, company details, active modules and brand tokens — with reset to code defaults.",
|
|
298
|
+
icon: SlidersHorizontal,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
to: paths.users,
|
|
302
|
+
title: "Users",
|
|
303
|
+
body: "Create, edit, delete users and reset passwords.",
|
|
304
|
+
icon: Users,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
to: paths.roles,
|
|
308
|
+
title: "User roles",
|
|
309
|
+
body: "Assign superadmin, admin, seo or user roles.",
|
|
310
|
+
icon: Shield,
|
|
311
|
+
},
|
|
312
|
+
{ to: paths.logs, title: "Logs", body: "System logs (admin+).", icon: ScrollText },
|
|
313
|
+
{
|
|
314
|
+
to: paths.emailLogs,
|
|
315
|
+
title: "Email logs",
|
|
316
|
+
body: "Outgoing email history.",
|
|
317
|
+
icon: FileText,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
to: paths.audit,
|
|
321
|
+
title: "Audit logs",
|
|
322
|
+
body: "Every backoffice action.",
|
|
323
|
+
icon: AlertCircle,
|
|
324
|
+
},
|
|
325
|
+
...(host.siteConfig.features.email
|
|
326
|
+
? [
|
|
327
|
+
{
|
|
328
|
+
to: paths.email,
|
|
329
|
+
title: "Email settings",
|
|
330
|
+
body: "SMTP / Amazon SES, from/reply-to.",
|
|
331
|
+
icon: Mail,
|
|
332
|
+
},
|
|
333
|
+
]
|
|
334
|
+
: []),
|
|
335
|
+
{
|
|
336
|
+
to: paths.contact,
|
|
337
|
+
title: "Contact details",
|
|
338
|
+
body: "Company address, org. number, email and phone.",
|
|
339
|
+
icon: Building2,
|
|
340
|
+
},
|
|
341
|
+
...(host.siteConfig.features.tracking
|
|
342
|
+
? [
|
|
343
|
+
{
|
|
344
|
+
to: paths.tracking,
|
|
345
|
+
title: "Code & tracking",
|
|
346
|
+
body: "Head/body code injection, GTM, GA4, Meta and TikTok pixels.",
|
|
347
|
+
icon: Code2,
|
|
348
|
+
},
|
|
349
|
+
]
|
|
350
|
+
: []),
|
|
351
|
+
{
|
|
352
|
+
to: paths.redirects,
|
|
353
|
+
title: "Redirects",
|
|
354
|
+
body: "Old URLs pointing to new ones, created automatically on slug changes.",
|
|
355
|
+
icon: Signpost,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
to: paths.navigation,
|
|
359
|
+
title: "Navigation & footer",
|
|
360
|
+
body: "Site-wide top menu and footer content.",
|
|
361
|
+
icon: Menu,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
to: paths.version,
|
|
365
|
+
title: "CMS & platform version",
|
|
366
|
+
body: "Current CMS build, shared platform package version and release history with dates.",
|
|
367
|
+
icon: Package,
|
|
368
|
+
},
|
|
369
|
+
...(isSuper && host.siteConfig.features.backup
|
|
370
|
+
? [
|
|
371
|
+
{
|
|
372
|
+
to: paths.backup,
|
|
373
|
+
title: "Backup & restore",
|
|
374
|
+
body: "Export everything — content, forms, submissions, media and roles — and restore it again.",
|
|
375
|
+
icon: DatabaseBackup,
|
|
376
|
+
},
|
|
377
|
+
]
|
|
378
|
+
: []),
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div className="p-8 md:p-12 w-full">
|
|
383
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
384
|
+
{cards.map((c) => (
|
|
385
|
+
<Link
|
|
386
|
+
key={c.to}
|
|
387
|
+
to={c.to}
|
|
388
|
+
className="group border border-ink/10 bg-white p-6 hover:border-sea transition-colors"
|
|
389
|
+
>
|
|
390
|
+
<c.icon className="size-6 text-ink/70 group-hover:text-sea" />
|
|
391
|
+
<div className="mt-4 font-serif text-lg text-ink">{c.title}</div>
|
|
392
|
+
<p className="mt-2 text-sm text-ink/60">{c.body}</p>
|
|
393
|
+
</Link>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Email sending settings plus provider credentials. */
|
|
401
|
+
export function EmailSettingsRoute() {
|
|
402
|
+
const host = backofficeHost();
|
|
403
|
+
const { fns } = host;
|
|
404
|
+
const fetchSettings = useServerFn(fns.getEmailSettings);
|
|
405
|
+
const fetchRoles = useServerFn(fns.getMyRoles);
|
|
406
|
+
const fetchProviders = useServerFn(fns.listEmailProviders);
|
|
407
|
+
const save = useServerFn(fns.saveEmailSettings);
|
|
408
|
+
const saveProvider = useServerFn(fns.upsertEmailProvider);
|
|
409
|
+
const removeProvider = useServerFn(fns.deleteEmailProvider);
|
|
410
|
+
const sendTest = useServerFn(fns.sendTestEmail);
|
|
411
|
+
|
|
412
|
+
const settingsQ = useQuery({
|
|
413
|
+
queryKey: ["admin", "email-settings"],
|
|
414
|
+
queryFn: () => fetchSettings(),
|
|
415
|
+
});
|
|
416
|
+
const providersQ = useQuery({
|
|
417
|
+
queryKey: ["admin", "email-providers"],
|
|
418
|
+
queryFn: () => fetchProviders(),
|
|
419
|
+
});
|
|
420
|
+
const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<EmailSettingsView
|
|
424
|
+
deps={{
|
|
425
|
+
settings: (settingsQ.data ?? null) as never,
|
|
426
|
+
providers: (providersQ.data ?? []) as never,
|
|
427
|
+
isSuper: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
|
|
428
|
+
loadingProviders: providersQ.isLoading,
|
|
429
|
+
saveSettings: (input) => save(input as never),
|
|
430
|
+
saveProvider: (input) => saveProvider(input as never),
|
|
431
|
+
deleteProvider: (input) => removeProvider(input),
|
|
432
|
+
sendTestEmail: (input) => sendTest(input),
|
|
433
|
+
refreshSettings: () => settingsQ.refetch(),
|
|
434
|
+
refreshProviders: () => providersQ.refetch(),
|
|
435
|
+
placeholders: {
|
|
436
|
+
fromEmail: host.placeholders?.fromEmail ?? "",
|
|
437
|
+
notifyTo: host.placeholders?.notifyTo ?? "",
|
|
438
|
+
},
|
|
439
|
+
}}
|
|
440
|
+
/>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Site-wide navigation and footer editor. */
|
|
445
|
+
export function NavigationRoute() {
|
|
446
|
+
const host = backofficeHost();
|
|
447
|
+
const qc = useQueryClient();
|
|
448
|
+
const site = host.content.useSite();
|
|
449
|
+
const overrides = host.content.useOverrides();
|
|
450
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<NavigationView
|
|
454
|
+
deps={{
|
|
455
|
+
overrides,
|
|
456
|
+
site,
|
|
457
|
+
defaults: host.content.defaults,
|
|
458
|
+
SectionForm: host.components.SectionForm,
|
|
459
|
+
saveSection: (input) => save(input as never),
|
|
460
|
+
refresh: () => qc.invalidateQueries({ queryKey: host.content.overridesQueryKey }),
|
|
461
|
+
}}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Per-locale domain mapping. */
|
|
467
|
+
export function DomainsRoute() {
|
|
468
|
+
const host = backofficeHost();
|
|
469
|
+
const paths = backofficePaths(host);
|
|
470
|
+
const qc = useQueryClient();
|
|
471
|
+
const list = useServerFn(host.fns.getLocaleDomains);
|
|
472
|
+
const save = useServerFn(host.fns.saveLocaleDomain);
|
|
473
|
+
const del = useServerFn(host.fns.deleteLocaleDomain);
|
|
474
|
+
const q = useQuery({ queryKey: ["locale-domains"], queryFn: () => list() });
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<DomainsView
|
|
478
|
+
deps={{
|
|
479
|
+
domains: q.data ?? [],
|
|
480
|
+
locales: host.siteConfig.locales,
|
|
481
|
+
defaultLocale: host.siteConfig.defaultLocale,
|
|
482
|
+
backPath: paths.base,
|
|
483
|
+
domainPlaceholder: host.placeholders?.domain ?? "",
|
|
484
|
+
saveDomain: (input) => save(input as never),
|
|
485
|
+
deleteDomain: (input) => del(input as never),
|
|
486
|
+
refresh: () => qc.invalidateQueries({ queryKey: ["locale-domains"] }),
|
|
487
|
+
}}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Testimonials / reviews editor backed by the shared content section. */
|
|
493
|
+
export function ReviewsRoute() {
|
|
494
|
+
const host = backofficeHost();
|
|
495
|
+
const paths = backofficePaths(host);
|
|
496
|
+
const qc = useQueryClient();
|
|
497
|
+
const site = host.content.useSite();
|
|
498
|
+
const overrides = host.content.useOverrides();
|
|
499
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
500
|
+
|
|
501
|
+
const source = (overrides?.["testimonials"] ??
|
|
502
|
+
site["testimonials"] ??
|
|
503
|
+
host.content.defaultForSection("testimonials")) as ReviewsSection;
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<ReviewsView
|
|
507
|
+
deps={{
|
|
508
|
+
source,
|
|
509
|
+
locale: host.previewLocale ?? host.siteConfig.defaultLocale,
|
|
510
|
+
save: async (section) => {
|
|
511
|
+
await save({
|
|
512
|
+
data: { key: "testimonials", data: section as unknown as Record<string, unknown> },
|
|
513
|
+
});
|
|
514
|
+
await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
515
|
+
},
|
|
516
|
+
MediaField: host.components.MediaField as never,
|
|
517
|
+
ReviewImageUpload: host.components.ReviewImageUpload as never,
|
|
518
|
+
BackLink: ({ className, children }) => (
|
|
519
|
+
<Link to={paths.base} className={className}>
|
|
520
|
+
{children}
|
|
521
|
+
</Link>
|
|
522
|
+
),
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Backup and restore, superadmin only. */
|
|
529
|
+
export function BackupRoute() {
|
|
530
|
+
const host = backofficeHost();
|
|
531
|
+
const backup = host.backup;
|
|
532
|
+
if (!backup) {
|
|
533
|
+
throw new Error("Backup host adapters missing — register `backup` to use BackupRoute.");
|
|
534
|
+
}
|
|
535
|
+
const fetchRoles = useServerFn(host.fns.getMyRoles);
|
|
536
|
+
const rolesQ = useQuery({
|
|
537
|
+
queryKey: ["admin", "my-roles"],
|
|
538
|
+
queryFn: async () => (await fetchRoles()) as string[],
|
|
539
|
+
retry: 3,
|
|
540
|
+
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000),
|
|
541
|
+
staleTime: 60_000,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<BackupView
|
|
546
|
+
deps={{
|
|
547
|
+
isSuper: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
|
|
548
|
+
loading: rolesQ.isLoading,
|
|
549
|
+
error: rolesQ.isError,
|
|
550
|
+
retry: () => void rolesQ.refetch(),
|
|
551
|
+
authHeaders: backup.authHeaders,
|
|
552
|
+
buildBrowserMediaArchive: backup.buildBrowserMediaArchive as never,
|
|
553
|
+
}}
|
|
554
|
+
/>
|
|
555
|
+
);
|
|
556
|
+
}
|