@bygghammar/cms-platform 0.2.0 → 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 +11 -0
- package/package.json +5 -2
- package/src/backup/browser-media.ts +18 -1
- package/src/blocks/types.ts +3 -48
- package/src/index.ts +2 -0
- package/src/navigation/index.ts +41 -0
- package/src/trips/TripSectionEditor.tsx +279 -0
- package/src/trips/index.ts +90 -0
package/README.md
CHANGED
|
@@ -11,6 +11,11 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
|
|
|
11
11
|
- `src/i18n/*` — the localization core (locales, types, resolver, context)
|
|
12
12
|
- `src/backup/*` — archive/media safety, browser-side large-media export and
|
|
13
13
|
provider-neutral verification helpers
|
|
14
|
+
- `src/trips/*` — reusable trips data contract, editor and legacy course-data
|
|
15
|
+
compatibility. Each host supplies its own terminology (Courses, Cruises or
|
|
16
|
+
Trips).
|
|
17
|
+
- `src/navigation/*` — public and backoffice navigation contracts plus shared
|
|
18
|
+
active-state helpers. Menu content and visual headers remain per-site.
|
|
14
19
|
- `src/theme.css` — the design-token contract + neutral starter theme
|
|
15
20
|
|
|
16
21
|
## What must never live here
|
|
@@ -19,6 +24,7 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
|
|
|
19
24
|
- page content, SEO copy, media, CMS page JSON
|
|
20
25
|
- Supabase generated types, auth users, secrets, API keys
|
|
21
26
|
- route files and per-site deployment config
|
|
27
|
+
- actual navigation links, public slugs, brand-specific headers or menu styling
|
|
22
28
|
- site table lists, import order, database clients, authentication and RLS
|
|
23
29
|
policies; each host supplies those adapters to the shared backup core
|
|
24
30
|
|
|
@@ -37,6 +43,11 @@ versions. Keep the dynamic import behind an `import.meta.env.SSR` guard in SSR
|
|
|
37
43
|
applications so the browser-only ZIP code cannot enter the optimized server
|
|
38
44
|
chunk graph.
|
|
39
45
|
|
|
46
|
+
Individual media downloads are retried before an export is stopped. Public
|
|
47
|
+
media-bucket adapters should also fall back to the object's public URL when an
|
|
48
|
+
authenticated Storage download fails, so one transient large-file request
|
|
49
|
+
cannot discard an otherwise valid archive.
|
|
50
|
+
|
|
40
51
|
The consuming site remains responsible for its dataset groups, table import
|
|
41
52
|
order, Supabase clients, superadmin authorization, HTTP routes, audit records
|
|
42
53
|
and database migrations. The package never contains site data or credentials.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
"./backoffice/inspectors/fields": "./src/backoffice/inspectors/fields.tsx",
|
|
42
42
|
"./theme.css": "./src/theme.css",
|
|
43
43
|
"./backoffice/inspectors/blocks": "./src/backoffice/inspectors/blocks.tsx",
|
|
44
|
-
"./blocks/registry": "./src/blocks/registry.tsx"
|
|
44
|
+
"./blocks/registry": "./src/blocks/registry.tsx",
|
|
45
|
+
"./trips": "./src/trips/index.ts",
|
|
46
|
+
"./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
|
|
47
|
+
"./navigation": "./src/navigation/index.ts"
|
|
45
48
|
},
|
|
46
49
|
"dependencies": {
|
|
47
50
|
"fflate": "^0.8.3"
|
|
@@ -41,6 +41,23 @@ export type BrowserMediaExportOptions = {
|
|
|
41
41
|
onProgress?: (progress: MediaExportProgress) => void;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
const MEDIA_DOWNLOAD_ATTEMPTS = 3;
|
|
45
|
+
|
|
46
|
+
async function downloadWithRetry(storage: BrowserBackupStorage, path: string): Promise<Uint8Array> {
|
|
47
|
+
let lastError: unknown;
|
|
48
|
+
for (let attempt = 1; attempt <= MEDIA_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
|
49
|
+
try {
|
|
50
|
+
return await storage.download(path);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
lastError = error;
|
|
53
|
+
if (attempt < MEDIA_DOWNLOAD_ATTEMPTS) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 250));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
function readManifest<M extends BackupArchiveManifest>(
|
|
45
62
|
files: Record<string, Uint8Array>,
|
|
46
63
|
expectedFormat: number | string,
|
|
@@ -149,7 +166,7 @@ export async function buildBrowserMediaArchive<M extends BackupArchiveManifest>(
|
|
|
149
166
|
for (const file of candidates) {
|
|
150
167
|
let data: Uint8Array;
|
|
151
168
|
try {
|
|
152
|
-
data = await storage
|
|
169
|
+
data = await downloadWithRetry(storage, file.name);
|
|
153
170
|
} catch (error) {
|
|
154
171
|
throw new Error(
|
|
155
172
|
`Could not download media file “${file.name}”: ${
|
package/src/blocks/types.ts
CHANGED
|
@@ -113,7 +113,6 @@ export type ImageMarqueeProps = {
|
|
|
113
113
|
items?: ImageMarqueeItem[];
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
|
|
117
116
|
export type GlobalRefProps = {
|
|
118
117
|
sectionKey: string;
|
|
119
118
|
};
|
|
@@ -182,7 +181,6 @@ export type HeroFullProps = {
|
|
|
182
181
|
anchor?: string;
|
|
183
182
|
};
|
|
184
183
|
|
|
185
|
-
|
|
186
184
|
export type IntroProps = {
|
|
187
185
|
eyebrow?: Partial<LocalizedString>;
|
|
188
186
|
title?: Partial<LocalizedString>;
|
|
@@ -196,18 +194,10 @@ export type IntroProps = {
|
|
|
196
194
|
imageWidth?: number;
|
|
197
195
|
};
|
|
198
196
|
|
|
199
|
-
export const INTRO_IMAGE_ASPECTS = [
|
|
200
|
-
"4/5",
|
|
201
|
-
"1/1",
|
|
202
|
-
"3/4",
|
|
203
|
-
"3/2",
|
|
204
|
-
"16/9",
|
|
205
|
-
"auto",
|
|
206
|
-
] as const;
|
|
197
|
+
export const INTRO_IMAGE_ASPECTS = ["4/5", "1/1", "3/4", "3/2", "16/9", "auto"] as const;
|
|
207
198
|
|
|
208
199
|
export type IntroImageAspect = (typeof INTRO_IMAGE_ASPECTS)[number];
|
|
209
200
|
|
|
210
|
-
|
|
211
201
|
/** ---- Long-form editorial blocks ---- */
|
|
212
202
|
|
|
213
203
|
export type ProseItem = {
|
|
@@ -244,43 +234,9 @@ export type ChecklistProps = {
|
|
|
244
234
|
columns?: ChecklistColumn[];
|
|
245
235
|
};
|
|
246
236
|
|
|
247
|
-
/** ---- Bookable
|
|
237
|
+
/** ---- Bookable trips (inline or sourced from the shared trips section) ---- */
|
|
248
238
|
|
|
249
|
-
export type CourseListItem
|
|
250
|
-
id: string;
|
|
251
|
-
/** Season/year used to group and filter courses (e.g. "2026"). */
|
|
252
|
-
year?: string;
|
|
253
|
-
courseNo?: Partial<LocalizedString>;
|
|
254
|
-
location?: Partial<LocalizedString>;
|
|
255
|
-
dates?: Partial<LocalizedString>;
|
|
256
|
-
accommodation?: Partial<LocalizedString>;
|
|
257
|
-
price?: Partial<LocalizedString>;
|
|
258
|
-
oldPrice?: Partial<LocalizedString>;
|
|
259
|
-
badge?: Partial<LocalizedString>;
|
|
260
|
-
bookLabel?: Partial<LocalizedString>;
|
|
261
|
-
bookUrl?: string;
|
|
262
|
-
featured?: boolean;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
export type CourseListProps = {
|
|
266
|
-
eyebrow?: Partial<LocalizedString>;
|
|
267
|
-
title?: Partial<LocalizedString>;
|
|
268
|
-
lead?: Partial<LocalizedString>;
|
|
269
|
-
note?: Partial<LocalizedString>;
|
|
270
|
-
tone?: "white" | "paper";
|
|
271
|
-
columns?: 2 | 3 | 4;
|
|
272
|
-
anchor?: string;
|
|
273
|
-
/**
|
|
274
|
-
* Where the courses come from.
|
|
275
|
-
* "global" (default) = the single source of truth in the backoffice
|
|
276
|
-
* (Courses section), optionally filtered by `year`.
|
|
277
|
-
* "custom" = items edited inline on this block only (legacy).
|
|
278
|
-
*/
|
|
279
|
-
source?: "global" | "custom";
|
|
280
|
-
/** Only used with source="global": show courses of this year. */
|
|
281
|
-
year?: string;
|
|
282
|
-
items?: CourseListItem[];
|
|
283
|
-
};
|
|
239
|
+
export type { CourseListItem, CourseListProps, TripItem, TripListProps } from "../trips";
|
|
284
240
|
|
|
285
241
|
/** ---- Simple call-to-action banner ---- */
|
|
286
242
|
|
|
@@ -358,7 +314,6 @@ export type MediaSplitProps = {
|
|
|
358
314
|
videoControls?: boolean;
|
|
359
315
|
};
|
|
360
316
|
|
|
361
|
-
|
|
362
317
|
/** ---- Rich text (WYSIWYG) ---- */
|
|
363
318
|
|
|
364
319
|
export type RichTextProps = {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
import type { LocalizedString } from "../i18n/types";
|
|
4
|
+
|
|
5
|
+
export type NavigationItem = {
|
|
6
|
+
label: LocalizedString;
|
|
7
|
+
href: string;
|
|
8
|
+
children?: NavigationItem[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type BackofficeNavigationItem<TIconProps = { className?: string }> = {
|
|
12
|
+
key: string;
|
|
13
|
+
to: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon: ComponentType<TIconProps>;
|
|
16
|
+
exact?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function defineNavigation<const T extends readonly NavigationItem[]>(items: T): T {
|
|
20
|
+
return items;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function defineBackofficeNavigation<
|
|
24
|
+
TIconProps,
|
|
25
|
+
const T extends readonly BackofficeNavigationItem<TIconProps>[],
|
|
26
|
+
>(items: T): T {
|
|
27
|
+
return items;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isBackofficeNavigationActive(
|
|
31
|
+
pathname: string,
|
|
32
|
+
item: Pick<BackofficeNavigationItem, "to" | "exact">,
|
|
33
|
+
): boolean {
|
|
34
|
+
return item.exact
|
|
35
|
+
? pathname === item.to
|
|
36
|
+
: pathname === item.to || pathname.startsWith(`${item.to}/`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function navigationPanelId(href: string): string {
|
|
40
|
+
return `nav-sub-${href.replace(/\W+/g, "-")}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp, Plus, Save, Star, StarOff, Trash2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "../i18n/locales";
|
|
4
|
+
import type { LocalizedString } from "../i18n/types";
|
|
5
|
+
import type { TripItem, TripSection, TripTerminology } from ".";
|
|
6
|
+
import { tripNumber, withTripNumber } from ".";
|
|
7
|
+
|
|
8
|
+
export type TripSectionEditorProps = {
|
|
9
|
+
value: TripSection;
|
|
10
|
+
terminology: TripTerminology;
|
|
11
|
+
onChange: (value: TripSection) => void;
|
|
12
|
+
onSave: () => void | Promise<void>;
|
|
13
|
+
saving?: boolean;
|
|
14
|
+
locale?: Locale;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function localizedValue(value: Partial<LocalizedString> | undefined, locale: Locale): string {
|
|
18
|
+
return value?.[locale] ?? "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function localizedPatch(
|
|
22
|
+
value: Partial<LocalizedString> | undefined,
|
|
23
|
+
locale: Locale,
|
|
24
|
+
next: string,
|
|
25
|
+
): Partial<LocalizedString> {
|
|
26
|
+
return { ...value, [locale]: next };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TripSectionEditor({
|
|
30
|
+
value,
|
|
31
|
+
terminology,
|
|
32
|
+
onChange,
|
|
33
|
+
onSave,
|
|
34
|
+
saving = false,
|
|
35
|
+
locale = "sv",
|
|
36
|
+
}: TripSectionEditorProps) {
|
|
37
|
+
const update = (patch: Partial<TripSection>) => onChange({ ...value, ...patch });
|
|
38
|
+
const updateItem = (index: number, patch: Partial<TripItem>) => {
|
|
39
|
+
const items = value.items.slice();
|
|
40
|
+
items[index] = { ...items[index], ...patch };
|
|
41
|
+
update({ items });
|
|
42
|
+
};
|
|
43
|
+
const removeItem = (index: number) => {
|
|
44
|
+
update({ items: value.items.filter((_, itemIndex) => itemIndex !== index) });
|
|
45
|
+
};
|
|
46
|
+
const move = (index: number, direction: -1 | 1) => {
|
|
47
|
+
const target = index + direction;
|
|
48
|
+
if (target < 0 || target >= value.items.length) return;
|
|
49
|
+
const items = value.items.slice();
|
|
50
|
+
[items[index], items[target]] = [items[target], items[index]];
|
|
51
|
+
update({ items });
|
|
52
|
+
};
|
|
53
|
+
const addItem = () => {
|
|
54
|
+
const number = value.items.length + 1;
|
|
55
|
+
const year = String(new Date().getFullYear());
|
|
56
|
+
update({
|
|
57
|
+
items: [
|
|
58
|
+
...value.items,
|
|
59
|
+
{
|
|
60
|
+
id: `t${year}-${String(number).padStart(2, "0")}`,
|
|
61
|
+
year,
|
|
62
|
+
tripNo: { [locale]: `${terminology.singular} ${number}` },
|
|
63
|
+
location: { [locale]: terminology.defaultLocation ?? "" },
|
|
64
|
+
dates: { [locale]: "" },
|
|
65
|
+
accommodation: { [locale]: terminology.defaultAccommodation ?? "" },
|
|
66
|
+
price: { [locale]: "" },
|
|
67
|
+
oldPrice: { [locale]: "" },
|
|
68
|
+
bookUrl: "",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="w-full">
|
|
76
|
+
<h1 className="font-serif text-3xl text-ink md:text-4xl">{terminology.plural}</h1>
|
|
77
|
+
<p className="mt-3 max-w-2xl text-ink/65">{terminology.description}</p>
|
|
78
|
+
|
|
79
|
+
<div className="mt-8 grid items-start gap-4 md:grid-cols-2">
|
|
80
|
+
<EditorField
|
|
81
|
+
label="Title"
|
|
82
|
+
value={localizedValue(value.title, locale)}
|
|
83
|
+
onChange={(next) => update({ title: localizedPatch(value.title, locale, next) })}
|
|
84
|
+
/>
|
|
85
|
+
<EditorField
|
|
86
|
+
label="Eyebrow"
|
|
87
|
+
value={localizedValue(value.eyebrow, locale)}
|
|
88
|
+
onChange={(next) => update({ eyebrow: localizedPatch(value.eyebrow, locale, next) })}
|
|
89
|
+
/>
|
|
90
|
+
<label className="block md:col-span-2">
|
|
91
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Intro</span>
|
|
92
|
+
<textarea
|
|
93
|
+
className="mt-2 min-h-[80px] w-full border border-ink/15 bg-white px-4 py-3"
|
|
94
|
+
value={localizedValue(value.body, locale)}
|
|
95
|
+
onChange={(event) =>
|
|
96
|
+
update({ body: localizedPatch(value.body, locale, event.target.value) })
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
</label>
|
|
100
|
+
<EditorField
|
|
101
|
+
label="Autoplay (ms)"
|
|
102
|
+
type="number"
|
|
103
|
+
value={String(value.autoplayMs)}
|
|
104
|
+
onChange={(next) => update({ autoplayMs: Number(next) || 0 })}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="mt-10 space-y-4">
|
|
109
|
+
{value.items.map((item, index) => (
|
|
110
|
+
<div key={item.id ?? index} className="border border-ink/10 bg-white p-5">
|
|
111
|
+
<div className="flex items-center justify-between gap-3">
|
|
112
|
+
<div className="flex items-center gap-2 text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
113
|
+
<span>#{index + 1}</span>
|
|
114
|
+
{item.id ? (
|
|
115
|
+
<span className="border border-ink/15 px-1.5 py-0.5 normal-case tracking-normal text-ink/70">
|
|
116
|
+
{item.id}
|
|
117
|
+
</span>
|
|
118
|
+
) : null}
|
|
119
|
+
</div>
|
|
120
|
+
<div className="flex items-center gap-1">
|
|
121
|
+
<IconButton label="Move up" disabled={index === 0} onClick={() => move(index, -1)}>
|
|
122
|
+
<ArrowUp className="size-4" />
|
|
123
|
+
</IconButton>
|
|
124
|
+
<IconButton
|
|
125
|
+
label="Move down"
|
|
126
|
+
disabled={index === value.items.length - 1}
|
|
127
|
+
onClick={() => move(index, 1)}
|
|
128
|
+
>
|
|
129
|
+
<ArrowDown className="size-4" />
|
|
130
|
+
</IconButton>
|
|
131
|
+
<IconButton
|
|
132
|
+
label={`Mark ${terminology.singular.toLowerCase()} as featured`}
|
|
133
|
+
onClick={() => updateItem(index, { featured: !item.featured })}
|
|
134
|
+
className={item.featured ? "text-sea" : "text-ink/40"}
|
|
135
|
+
>
|
|
136
|
+
{item.featured ? <Star className="size-4" /> : <StarOff className="size-4" />}
|
|
137
|
+
</IconButton>
|
|
138
|
+
<IconButton
|
|
139
|
+
label={`Delete ${terminology.singular.toLowerCase()}`}
|
|
140
|
+
onClick={() => removeItem(index)}
|
|
141
|
+
className="text-red-600 hover:bg-red-50"
|
|
142
|
+
>
|
|
143
|
+
<Trash2 className="size-4" />
|
|
144
|
+
</IconButton>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
149
|
+
<EditorField
|
|
150
|
+
label="ID (stable key)"
|
|
151
|
+
value={item.id ?? ""}
|
|
152
|
+
onChange={(next) => updateItem(index, { id: next })}
|
|
153
|
+
/>
|
|
154
|
+
<EditorField
|
|
155
|
+
label="Year"
|
|
156
|
+
value={item.year ?? ""}
|
|
157
|
+
onChange={(next) => updateItem(index, { year: next })}
|
|
158
|
+
/>
|
|
159
|
+
<EditorField
|
|
160
|
+
label={terminology.number}
|
|
161
|
+
value={localizedValue(tripNumber(item), locale)}
|
|
162
|
+
onChange={(next) =>
|
|
163
|
+
updateItem(
|
|
164
|
+
index,
|
|
165
|
+
withTripNumber(item, localizedPatch(tripNumber(item), locale, next)),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
/>
|
|
169
|
+
<EditorField
|
|
170
|
+
label={terminology.location}
|
|
171
|
+
value={localizedValue(item.location, locale)}
|
|
172
|
+
onChange={(next) =>
|
|
173
|
+
updateItem(index, { location: localizedPatch(item.location, locale, next) })
|
|
174
|
+
}
|
|
175
|
+
/>
|
|
176
|
+
<EditorField
|
|
177
|
+
label={terminology.dates}
|
|
178
|
+
value={localizedValue(item.dates, locale)}
|
|
179
|
+
onChange={(next) =>
|
|
180
|
+
updateItem(index, { dates: localizedPatch(item.dates, locale, next) })
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
<EditorField
|
|
184
|
+
label={terminology.accommodation}
|
|
185
|
+
value={localizedValue(item.accommodation, locale)}
|
|
186
|
+
onChange={(next) =>
|
|
187
|
+
updateItem(index, {
|
|
188
|
+
accommodation: localizedPatch(item.accommodation, locale, next),
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
/>
|
|
192
|
+
<EditorField
|
|
193
|
+
label="Price"
|
|
194
|
+
value={localizedValue(item.price, locale)}
|
|
195
|
+
onChange={(next) =>
|
|
196
|
+
updateItem(index, { price: localizedPatch(item.price, locale, next) })
|
|
197
|
+
}
|
|
198
|
+
/>
|
|
199
|
+
<EditorField
|
|
200
|
+
label="Regular price"
|
|
201
|
+
value={localizedValue(item.oldPrice, locale)}
|
|
202
|
+
onChange={(next) =>
|
|
203
|
+
updateItem(index, { oldPrice: localizedPatch(item.oldPrice, locale, next) })
|
|
204
|
+
}
|
|
205
|
+
/>
|
|
206
|
+
<EditorField
|
|
207
|
+
label="Booking URL"
|
|
208
|
+
value={item.bookUrl ?? ""}
|
|
209
|
+
onChange={(next) => updateItem(index, { bookUrl: next })}
|
|
210
|
+
span={3}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={addItem}
|
|
221
|
+
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"
|
|
222
|
+
>
|
|
223
|
+
<Plus className="size-4" /> Add {terminology.singular.toLowerCase()}
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => void onSave()}
|
|
228
|
+
disabled={saving}
|
|
229
|
+
className="inline-flex items-center gap-2 bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper hover:bg-ink/85 disabled:opacity-50"
|
|
230
|
+
>
|
|
231
|
+
<Save className="size-4" /> {saving ? "Saving…" : "Save all changes"}
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function IconButton({
|
|
239
|
+
label,
|
|
240
|
+
className = "",
|
|
241
|
+
...props
|
|
242
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
|
243
|
+
return (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
aria-label={label}
|
|
247
|
+
className={`p-2 hover:bg-ink/5 disabled:opacity-30 ${className}`}
|
|
248
|
+
{...props}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function EditorField({
|
|
254
|
+
label,
|
|
255
|
+
value,
|
|
256
|
+
onChange,
|
|
257
|
+
span,
|
|
258
|
+
type = "text",
|
|
259
|
+
}: {
|
|
260
|
+
label: string;
|
|
261
|
+
value: string;
|
|
262
|
+
onChange: (value: string) => void;
|
|
263
|
+
span?: 1 | 2 | 3;
|
|
264
|
+
type?: "text" | "number";
|
|
265
|
+
}) {
|
|
266
|
+
const className = span === 3 ? "md:col-span-3" : span === 2 ? "md:col-span-2" : "";
|
|
267
|
+
return (
|
|
268
|
+
<label className={`block ${className}`}>
|
|
269
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">{label}</span>
|
|
270
|
+
<input
|
|
271
|
+
type={type}
|
|
272
|
+
min={type === "number" ? 0 : undefined}
|
|
273
|
+
className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
274
|
+
value={value}
|
|
275
|
+
onChange={(event) => onChange(event.target.value)}
|
|
276
|
+
/>
|
|
277
|
+
</label>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { LocalizedString } from "../i18n/types";
|
|
2
|
+
|
|
3
|
+
export const TRIPS_SECTION_KEY = "trips" as const;
|
|
4
|
+
export const LEGACY_TRIPS_SECTION_KEY = "packages" as const;
|
|
5
|
+
|
|
6
|
+
export type TripItem = {
|
|
7
|
+
id?: string;
|
|
8
|
+
year?: string;
|
|
9
|
+
tripNo?: Partial<LocalizedString>;
|
|
10
|
+
/** @deprecated Read-only compatibility with CMS data created before the trips module. */
|
|
11
|
+
courseNo?: Partial<LocalizedString>;
|
|
12
|
+
location?: Partial<LocalizedString>;
|
|
13
|
+
dates?: Partial<LocalizedString>;
|
|
14
|
+
accommodation?: Partial<LocalizedString>;
|
|
15
|
+
price?: Partial<LocalizedString>;
|
|
16
|
+
oldPrice?: Partial<LocalizedString>;
|
|
17
|
+
badge?: Partial<LocalizedString>;
|
|
18
|
+
bookLabel?: Partial<LocalizedString>;
|
|
19
|
+
bookUrl?: string;
|
|
20
|
+
featured?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TripSection = {
|
|
24
|
+
eyebrow: Partial<LocalizedString>;
|
|
25
|
+
id: string;
|
|
26
|
+
title: Partial<LocalizedString>;
|
|
27
|
+
body: Partial<LocalizedString>;
|
|
28
|
+
autoplayMs: number;
|
|
29
|
+
items: TripItem[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TripListProps = {
|
|
33
|
+
eyebrow?: Partial<LocalizedString>;
|
|
34
|
+
title?: Partial<LocalizedString>;
|
|
35
|
+
lead?: Partial<LocalizedString>;
|
|
36
|
+
note?: Partial<LocalizedString>;
|
|
37
|
+
tone?: "white" | "paper";
|
|
38
|
+
columns?: 2 | 3 | 4;
|
|
39
|
+
anchor?: string;
|
|
40
|
+
source?: "global" | "custom";
|
|
41
|
+
year?: string;
|
|
42
|
+
items?: TripItem[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type TripTerminology = {
|
|
46
|
+
singular: string;
|
|
47
|
+
plural: string;
|
|
48
|
+
number: string;
|
|
49
|
+
dates: string;
|
|
50
|
+
location: string;
|
|
51
|
+
accommodation: string;
|
|
52
|
+
description: string;
|
|
53
|
+
defaultLocation?: string;
|
|
54
|
+
defaultAccommodation?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function defineTripTerminology(terminology: TripTerminology): TripTerminology {
|
|
58
|
+
return terminology;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function tripNumber(item: Pick<TripItem, "tripNo" | "courseNo">): Partial<LocalizedString> {
|
|
62
|
+
return item.tripNo ?? item.courseNo ?? {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function withTripNumber(item: TripItem, value: Partial<LocalizedString>): TripItem {
|
|
66
|
+
return { ...item, tripNo: value };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type SectionSources = Record<string, unknown> | null | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the new trips key first, then the legacy packages key. This keeps
|
|
73
|
+
* existing CMS rows readable while every new save is written as `trips`.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTripSection<T extends TripSection>(
|
|
76
|
+
overrides: SectionSources,
|
|
77
|
+
site: SectionSources,
|
|
78
|
+
fallback: T,
|
|
79
|
+
): T {
|
|
80
|
+
return (overrides?.[TRIPS_SECTION_KEY] ??
|
|
81
|
+
overrides?.[LEGACY_TRIPS_SECTION_KEY] ??
|
|
82
|
+
site?.[TRIPS_SECTION_KEY] ??
|
|
83
|
+
site?.[LEGACY_TRIPS_SECTION_KEY] ??
|
|
84
|
+
fallback) as T;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @deprecated Use TripItem. */
|
|
88
|
+
export type CourseListItem = TripItem;
|
|
89
|
+
/** @deprecated Use TripListProps. */
|
|
90
|
+
export type CourseListProps = TripListProps;
|