@fullgrainsoftware/portal 0.12.0 → 0.13.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/README.md CHANGED
@@ -49,6 +49,55 @@ export default defineSite({
49
49
 
50
50
  A repeatable section whose items are mostly a picture can take photos in bulk from the panel: set `batchUpload: true` (the section has exactly one image field) or `batchUpload: "<image field key>"`. Each picked image becomes one item with every other field empty, so no other field of that section may be `required`.
51
51
 
52
+ ### Menu pages
53
+
54
+ A restaurant's menu is a page with `kind: "menu"`. The panel then gives the
55
+ client a **Menu** page for it: every repeatable section as an editable table
56
+ (one row per item, one column per field), a dropdown for each field that
57
+ declares `options`, export to a spreadsheet (Excel or CSV) and import from one
58
+ with a reviewed diff the client confirms, and undo of the last import.
59
+ Non-menu pages are edited exactly as before.
60
+
61
+ ```ts
62
+ { slug: "/menu", title: "Menu", kind: "menu", sections: [
63
+ { key: "originals", title: "Lost Originals", kind: "repeatable", fields: {
64
+ name: { label: "Name", type: "text", required: true },
65
+ ingredients: { label: "Ingredients", type: "text" },
66
+ strength: { label: "Strength", type: "text", options: ["None", "Strong", "Really Strong"], default: "None" },
67
+ note: { label: "Note", type: "text" },
68
+ price: { label: "Price", type: "text" },
69
+ } },
70
+ { key: "rum", title: "Rum", kind: "repeatable", fields: {
71
+ name: { label: "Name", type: "text", required: true },
72
+ "price-15": { label: "1.5 oz", type: "text" },
73
+ "price-30": { label: "3 oz", type: "text" },
74
+ } },
75
+ ] }
76
+ ```
77
+
78
+ Rules (checked by `siteManifestSchema`, so `fullgrain sync` refuses a manifest
79
+ that breaks one):
80
+
81
+ - A menu page needs at least one repeatable section. Singleton sections are
82
+ allowed beside them (a section's own price or rules).
83
+ - Each repeatable section on a menu page needs a `text` field. Its **first**
84
+ text field, in the order you declare them, is the item's name: the table's
85
+ row title, the item name in the panel, and the key an imported spreadsheet
86
+ row is matched by. A seed item's `name` must equal its value for that field
87
+ (or the field's `default` when the seed leaves it out).
88
+ - `options` (a dropdown's allowed values) goes on `type: "text"` fields only,
89
+ on any page: 2–50 values, each trimmed, non-empty and at most 60 characters,
90
+ and no two the same ignoring case and spaces ("Strong" and "strong " are
91
+ one value). A `default` and every seed value for that field must be one of
92
+ the options, spelled exactly.
93
+ - A field with `options` still reads as a `string`. The list constrains the
94
+ panel's editing and import (a typed "strong" becomes "Strong"); a stored
95
+ value outside the list is possible — say, after the list changes — and the
96
+ panel shows it as-is with a warning rather than rewriting it. Changing a
97
+ field's `options` never touches stored values.
98
+ - Prices stay text fields (the site parses them), so "MP", "15/28" and
99
+ "Market" are all fine.
100
+
52
101
  ## Read content in your pages
53
102
 
54
103
  ```tsx
@@ -293,6 +342,98 @@ page); a site that wants its own can set a manage URL in the panel and call
293
342
  should pass back the `version` it rendered (`cancel(token, { version: booking.version })`)
294
343
  so a stale page can't act on a booking that changed since it loaded.
295
344
 
345
+ ## Business (hours, details, closures)
346
+
347
+ For sites whose client keeps their hours and closures in the panel. While
348
+ Business is switched on for the site, the content payload carries a
349
+ `website.business` block: name, address, display phone, email, the IANA
350
+ `timezone`, weekly `hours`, an `hoursNote`, and the current and upcoming
351
+ `closures`. It refreshes on the same revalidate ping as any content edit.
352
+
353
+ ```ts
354
+ import { readBusiness, type CMSBusiness } from "@fullgrainsoftware/portal";
355
+
356
+ // lib/cms.ts — through the same cached fetch as the content
357
+ export async function getBusiness(): Promise<CMSBusiness | null> {
358
+ const websiteId = process.env.WEBSITE_ID;
359
+ if (!websiteId) return null;
360
+ try {
361
+ return readBusiness((await getPublicSiteData(websiteId)).website);
362
+ } catch (err) {
363
+ console.error("[cms] business fetch failed:", err);
364
+ return null;
365
+ }
366
+ }
367
+ ```
368
+
369
+ `readBusiness` never throws: seed mode, Business switched off, an older
370
+ panel, or a malformed block all give `null` (keep code-owned hours to fall
371
+ back on); a malformed day, window or closure is dropped on its own. Hours are
372
+ `{ mon: [["17:00", "23:00"]], … }` with `sun … sat` keys; a day absent or
373
+ empty is closed, and an end at or before its start runs into the next day:
374
+ `["17:00", "00:00"]` closes at midnight, `["17:00", "02:00"]` at 2 AM.
375
+
376
+ **The open badge is a client leaf.** `businessStatus(business, now)` is
377
+ pure and works in the business's zone (an unknown zone reads as UTC), but it
378
+ depends on the clock, so render a fixed-size placeholder on the server and
379
+ fill it in after mount — size the placeholder for the longest label it will
380
+ hold:
381
+
382
+ ```tsx
383
+ "use client";
384
+ import { useSyncExternalStore } from "react";
385
+ import { businessStatus, type CMSBusiness } from "@fullgrainsoftware/portal";
386
+
387
+ const subscribe = (tick: () => void) => {
388
+ const id = setInterval(tick, 30_000);
389
+ return () => clearInterval(id);
390
+ };
391
+ const minute = () => Math.floor(Date.now() / 60_000);
392
+ // closesAt is 24-hour "HH:MM" local time: "23:00" → "11 PM", "17:30" → "5:30 PM"
393
+ const formatTime = (hhmm: string) => {
394
+ const [h, m] = hhmm.split(":").map(Number);
395
+ return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h < 12 ? "AM" : "PM"}`;
396
+ };
397
+
398
+ export function OpenBadge({ business }: { business: CMSBusiness }) {
399
+ const now = useSyncExternalStore(subscribe, minute, () => null); // null on the server and the first client render
400
+ const status = now === null ? null : businessStatus(business, new Date(now * 60_000));
401
+ const label = !status ? " " : status.state === "open" ? (status.closesAt ? `Open until ${formatTime(status.closesAt)}` : "Open") : "Closed";
402
+ return <span className="inline-block min-w-[16ch]">{label}</span>;
403
+ }
404
+ ```
405
+
406
+ `BusinessStatus` is `{ state: "open", closesAt }` (`"HH:MM"` local, or
407
+ `null` when open around the clock) or `{ state: "closed", because:
408
+ "hours" | "closure", closure?, reopens }`, where `reopens` is `{ date, time }`
409
+ within the next 14 days or `null`. `because` is `"closure"` only when the
410
+ hours alone would have it open now; an all-day closure closes that date's
411
+ windows, their run past midnight included, but never the previous night's
412
+ late close. `todaysClosures(business, now)` lists the closures touching the
413
+ local date, all-day first, for a "tonight: private event 6–10 PM" notice
414
+ while still open.
415
+
416
+ **Closure copy.** `describeClosure(closure)` gives plain default wording
417
+ per `reason` (`private_event` "Closed for a private event", `holiday`
418
+ "Closed for the holiday", `weather` "Closed due to weather", `maintenance`
419
+ "Closed for maintenance", `staffing` "Closed today", `other` the client's
420
+ `message` or "Closed"). A site with its own voice switches on the reason and
421
+ keeps the default for the rest:
422
+
423
+ ```ts
424
+ const headline = closure.reason === "private_event" ? "Under Private Charter" : describeClosure(closure);
425
+ const when = formatWindow(closure); // "6–10 PM"; "18:00–22:00" with { hour12: false }; null when all day
426
+ ```
427
+
428
+ **Structured data.** `openingHoursSpecification(business)` returns the
429
+ schema.org `OpeningHoursSpecification` entries for your JSON-LD, one per
430
+ distinct window with the days that share it (`dayOfWeek: ["Wednesday",
431
+ "Thursday"], opens: "17:00", closes: "23:00"`); an overnight close stays
432
+ verbatim (`closes: "02:00"`), which is how schema.org reads it. A 24-hour
433
+ day (`["00:00", "00:00"]`) is emitted as `closes: "23:59"`, since Google reads
434
+ `opens` equal to `closes` at midnight as closed all day. Closures are never
435
+ emitted.
436
+
296
437
  ## Reviews
297
438
 
298
439
  For sites with a review form. Submit reviews through `createInquiries` with
@@ -0,0 +1,39 @@
1
+ import type { CMSBusiness, CMSClosure, CMSWebsite } from "./cms-types.js";
2
+ export type BusinessStatus = {
3
+ state: "open";
4
+ closesAt: string | null;
5
+ } | {
6
+ state: "closed";
7
+ because: "hours" | "closure";
8
+ closure?: CMSClosure;
9
+ reopens: {
10
+ date: string;
11
+ time: string;
12
+ } | null;
13
+ };
14
+ export declare function readBusiness(website: CMSWebsite | null | undefined): CMSBusiness | null;
15
+ export declare function businessStatus(business: CMSBusiness, now?: Date): BusinessStatus;
16
+ /** The closures touching the business's local date today, all-day first —
17
+ * for a "tonight: private event 6–10 PM" notice while still open. */
18
+ export declare function todaysClosures(business: CMSBusiness, now?: Date): CMSClosure[];
19
+ /** Plain default copy for a closure. A site with its own voice switches on
20
+ * `closure.reason` and uses this as the fallback. */
21
+ export declare function describeClosure(closure: CMSClosure): string;
22
+ /** A closure window as "6–10 PM" (or "18:00–22:00" with hour12: false);
23
+ * null for an all-day closure. Formatted by hand, so there is no U+202F
24
+ * before the meridiem as Intl would put there. */
25
+ export declare function formatWindow(closure: CMSClosure, opts?: {
26
+ hour12?: boolean;
27
+ }): string | null;
28
+ /** schema.org OpeningHoursSpecification entries for JSON-LD, one per
29
+ * distinct window with the days that share it (Monday first). An overnight
30
+ * window keeps its end verbatim (closes "02:00"), as schema.org reads it. A
31
+ * 24-hour window (end equal to start, e.g. ["00:00", "00:00"]) closes
32
+ * "23:59": Google reads opens "00:00" closes "00:00" as closed all day.
33
+ * Closures are never emitted. */
34
+ export declare function openingHoursSpecification(business: CMSBusiness): Array<{
35
+ "@type": "OpeningHoursSpecification";
36
+ dayOfWeek: string[];
37
+ opens: string;
38
+ closes: string;
39
+ }>;
@@ -0,0 +1,325 @@
1
+ /** Sunday first: the weekday index math below depends on this order. */
2
+ const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
3
+ const CLOSURE_REASONS = ["private_event", "holiday", "weather", "maintenance", "staffing", "other"];
4
+ const DAY = 1440;
5
+ const HORIZON_DAYS = 14;
6
+ const DATE_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
7
+ const TIME_SHAPE = /^(\d{2}):(\d{2})$/;
8
+ /* ---------- zone math (Intl, no library) ---------- */
9
+ const formatters = new Map();
10
+ function formatter(tz) {
11
+ let f = formatters.get(tz);
12
+ if (!f) {
13
+ f = new Intl.DateTimeFormat("en-US", {
14
+ timeZone: tz,
15
+ hourCycle: "h23",
16
+ year: "numeric",
17
+ month: "2-digit",
18
+ day: "2-digit",
19
+ hour: "2-digit",
20
+ minute: "2-digit",
21
+ weekday: "short",
22
+ });
23
+ formatters.set(tz, f);
24
+ }
25
+ return f;
26
+ }
27
+ function isValidTimeZone(tz) {
28
+ if (typeof tz !== "string" || !tz)
29
+ return false;
30
+ try {
31
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function localParts(instant, tz) {
39
+ const parts = {};
40
+ for (const p of formatter(tz).formatToParts(instant))
41
+ parts[p.type] = p.value;
42
+ const hour = parts.hour === "24" ? "00" : parts.hour;
43
+ return {
44
+ date: `${parts.year}-${parts.month}-${parts.day}`,
45
+ time: `${hour}:${parts.minute}`,
46
+ weekday: parts.weekday.toLowerCase().slice(0, 3),
47
+ };
48
+ }
49
+ function addLocalDays(date, n) {
50
+ const dm = DATE_SHAPE.exec(date);
51
+ if (!dm)
52
+ throw new Error(`bad date ${date}`);
53
+ return new Date(Date.UTC(+dm[1], +dm[2] - 1, +dm[3] + n)).toISOString().slice(0, 10);
54
+ }
55
+ /* ---------- minute spans ---------- */
56
+ function toMinutes(hhmm) {
57
+ const m = TIME_SHAPE.exec(hhmm);
58
+ if (!m)
59
+ return null;
60
+ const h = +m[1];
61
+ const min = +m[2];
62
+ if (min > 59)
63
+ return null;
64
+ if (h === 24)
65
+ return min === 0 ? DAY : null;
66
+ if (h > 23)
67
+ return null;
68
+ return h * 60 + min;
69
+ }
70
+ const isTime = (v) => typeof v === "string" && toMinutes(v) !== null;
71
+ /** A window's start: any HH:MM but "24:00", which is only ever an end. */
72
+ const isStartTime = (v) => isTime(v) && toMinutes(v) < DAY;
73
+ function subtractSpans(base, blocked) {
74
+ let out = base.slice();
75
+ for (const [bs, be] of blocked) {
76
+ const next = [];
77
+ for (const [s, e] of out) {
78
+ if (be <= s || bs >= e) {
79
+ next.push([s, e]);
80
+ continue;
81
+ }
82
+ if (bs > s)
83
+ next.push([s, bs]);
84
+ if (be < e)
85
+ next.push([be, e]);
86
+ }
87
+ out = next;
88
+ }
89
+ return out;
90
+ }
91
+ function windowSpans(windows) {
92
+ if (!Array.isArray(windows))
93
+ return [];
94
+ const out = [];
95
+ for (const w of windows) {
96
+ if (!Array.isArray(w) || typeof w[0] !== "string" || typeof w[1] !== "string")
97
+ continue;
98
+ const s = toMinutes(w[0]);
99
+ const e = toMinutes(w[1]);
100
+ if (s === null || e === null || s >= DAY)
101
+ continue;
102
+ out.push([s, e <= s ? e + DAY : e]);
103
+ }
104
+ return out;
105
+ }
106
+ const covers = (c, date) => typeof c.from === "string" && typeof c.to === "string" && c.from <= date && date <= c.to;
107
+ const isAllDay = (c) => c.start == null && c.end == null;
108
+ /** A closure window's span on `date` in that date's minutes, or null when it
109
+ * does not cover the date, is all day, or its times are malformed. */
110
+ function windowClosureSpan(c, date) {
111
+ if (!covers(c, date) || isAllDay(c))
112
+ return null;
113
+ const s = typeof c.start === "string" ? toMinutes(c.start) : null;
114
+ const e = typeof c.end === "string" ? toMinutes(c.end) : null;
115
+ if (s === null || e === null || e <= s)
116
+ return null;
117
+ return [s, e];
118
+ }
119
+ function union(spans) {
120
+ const sorted = spans.slice().sort((a, b) => a[0] - b[0]);
121
+ const out = [];
122
+ for (const [s, e] of sorted) {
123
+ const last = out[out.length - 1];
124
+ if (last && s <= last[1])
125
+ last[1] = Math.max(last[1], e);
126
+ else
127
+ out.push([s, e]);
128
+ }
129
+ return out;
130
+ }
131
+ const clock = (minutes) => {
132
+ const m = ((minutes % DAY) + DAY) % DAY;
133
+ return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
134
+ };
135
+ const inside = (spans, t) => spans.find(([s, e]) => s <= t && t < e);
136
+ /** All-day closures first, then by start time; otherwise input order. An
137
+ * all-day closure's start is null, read as "", which sorts before any HH:MM. */
138
+ const byAllDayThenStart = (a, b) => (a.start ?? "").localeCompare(b.start ?? "");
139
+ function localNow(business, now) {
140
+ return localParts(now, isValidTimeZone(business.timezone) ? business.timezone : "UTC");
141
+ }
142
+ const closuresOf = (business) => (Array.isArray(business.closures) ? business.closures.filter(Boolean) : []);
143
+ /* ---------- reader ---------- */
144
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
145
+ function readAddress(value) {
146
+ if (!isObject(value))
147
+ return null;
148
+ const { street, unit, city, region, postalCode, country } = value;
149
+ if (![street, unit, city, region, postalCode, country].every((v) => typeof v === "string"))
150
+ return null;
151
+ return { street, unit, city, region, postalCode, country };
152
+ }
153
+ function readHours(value) {
154
+ const out = {};
155
+ for (const [day, windows] of Object.entries(value)) {
156
+ if (!WEEKDAYS.includes(day) || !Array.isArray(windows))
157
+ continue;
158
+ out[day] = windows
159
+ .filter((w) => Array.isArray(w) && w.length === 2 && isStartTime(w[0]) && isTime(w[1]))
160
+ .map(([s, e]) => [s, e]);
161
+ }
162
+ return out;
163
+ }
164
+ function readClosure(value) {
165
+ if (!isObject(value))
166
+ return null;
167
+ const { id, from, to, start, end, reason, message } = value;
168
+ if (typeof id !== "string" || typeof message !== "string")
169
+ return null;
170
+ if (typeof from !== "string" || typeof to !== "string" || !DATE_SHAPE.test(from) || !DATE_SHAPE.test(to))
171
+ return null;
172
+ if (!CLOSURE_REASONS.includes(reason))
173
+ return null;
174
+ const allDay = start === null && end === null;
175
+ // A closure window never wraps past midnight: an end at or before its
176
+ // start (or a "24:00" start) is dropped, as the status algorithm ignores it.
177
+ if (!allDay && !(isStartTime(start) && isTime(end) && toMinutes(end) > toMinutes(start)))
178
+ return null;
179
+ return { id, from, to, start: allDay ? null : start, end: allDay ? null : end, reason: reason, message };
180
+ }
181
+ export function readBusiness(website) {
182
+ const raw = website?.business;
183
+ if (!isObject(raw))
184
+ return null;
185
+ const { name, phone, email, timezone, hoursNote, hours, closures } = raw;
186
+ if (![name, phone, email, timezone, hoursNote].every((v) => typeof v === "string"))
187
+ return null;
188
+ const address = readAddress(raw.address);
189
+ if (!address || !isObject(hours) || !Array.isArray(closures))
190
+ return null;
191
+ return {
192
+ name: name,
193
+ address,
194
+ phone: phone,
195
+ email: email,
196
+ timezone: timezone,
197
+ hours: readHours(hours),
198
+ hoursNote: hoursNote,
199
+ closures: closures.map(readClosure).filter((c) => c !== null),
200
+ };
201
+ }
202
+ /* ---------- status ---------- */
203
+ export function businessStatus(business, now = new Date()) {
204
+ const local = localNow(business, now);
205
+ const nowMin = toMinutes(local.time) ?? 0;
206
+ const todayIndex = WEEKDAYS.indexOf(local.weekday);
207
+ const hours = business.hours && typeof business.hours === "object" ? business.hours : {};
208
+ const closures = closuresOf(business);
209
+ const open = [];
210
+ const shut = [];
211
+ const cut = [];
212
+ for (let k = -1; k <= HORIZON_DAYS; k++) {
213
+ const date = addLocalDays(local.date, k);
214
+ const weekday = WEEKDAYS[(todayIndex + k + 7) % 7];
215
+ const base = k * DAY;
216
+ const allDay = closures.filter((c) => isAllDay(c) && covers(c, date));
217
+ for (const [s, e] of windowSpans(hours[weekday])) {
218
+ const span = [base + s, base + e];
219
+ if (allDay.length)
220
+ for (const closure of allDay)
221
+ shut.push({ span, closure });
222
+ else
223
+ open.push(span);
224
+ }
225
+ for (const c of closures) {
226
+ const span = windowClosureSpan(c, date);
227
+ if (span)
228
+ cut.push({ span: [base + span[0], base + span[1]], closure: c });
229
+ }
230
+ }
231
+ const remaining = subtractSpans(union(open), union(cut.map((x) => x.span)));
232
+ const horizonEnd = (HORIZON_DAYS + 1) * DAY;
233
+ const current = inside(remaining, nowMin);
234
+ if (current)
235
+ return { state: "open", closesAt: current[1] >= horizonEnd ? null : clock(current[1]) };
236
+ const next = remaining.find(([s]) => s > nowMin);
237
+ const reopens = next ? { date: addLocalDays(local.date, Math.floor(next[0] / DAY)), time: clock(next[0]) } : null;
238
+ if (inside(union([...open, ...shut.map((x) => x.span)]), nowMin)) {
239
+ const covering = [...shut, ...cut]
240
+ .filter(({ span }) => span[0] <= nowMin && nowMin < span[1])
241
+ .map((x) => x.closure)
242
+ .sort(byAllDayThenStart);
243
+ if (covering.length)
244
+ return { state: "closed", because: "closure", closure: covering[0], reopens };
245
+ }
246
+ return { state: "closed", because: "hours", reopens };
247
+ }
248
+ /** The closures touching the business's local date today, all-day first —
249
+ * for a "tonight: private event 6–10 PM" notice while still open. */
250
+ export function todaysClosures(business, now = new Date()) {
251
+ const { date } = localNow(business, now);
252
+ return closuresOf(business)
253
+ .filter((c) => covers(c, date))
254
+ .sort(byAllDayThenStart);
255
+ }
256
+ /* ---------- copy and structured data ---------- */
257
+ const CLOSURE_COPY = {
258
+ private_event: "Closed for a private event",
259
+ holiday: "Closed for the holiday",
260
+ weather: "Closed due to weather",
261
+ maintenance: "Closed for maintenance",
262
+ staffing: "Closed today",
263
+ };
264
+ /** Plain default copy for a closure. A site with its own voice switches on
265
+ * `closure.reason` and uses this as the fallback. */
266
+ export function describeClosure(closure) {
267
+ if (closure.reason !== "other" && Object.hasOwn(CLOSURE_COPY, closure.reason))
268
+ return CLOSURE_COPY[closure.reason];
269
+ const message = typeof closure.message === "string" ? closure.message.trim() : "";
270
+ return message || "Closed";
271
+ }
272
+ /** A closure window as "6–10 PM" (or "18:00–22:00" with hour12: false);
273
+ * null for an all-day closure. Formatted by hand, so there is no U+202F
274
+ * before the meridiem as Intl would put there. */
275
+ export function formatWindow(closure, opts = {}) {
276
+ const { start, end } = closure;
277
+ if (!isTime(start) || !isTime(end))
278
+ return null;
279
+ if (opts.hour12 === false)
280
+ return `${start}–${end}`;
281
+ const part = (hhmm) => {
282
+ const total = toMinutes(hhmm);
283
+ const h = Math.floor(total / 60) % 24;
284
+ const m = total % 60;
285
+ const h12 = h % 12 || 12;
286
+ return { text: m ? `${h12}:${String(m).padStart(2, "0")}` : String(h12), meridiem: h < 12 ? "AM" : "PM" };
287
+ };
288
+ const a = part(start);
289
+ const b = part(end);
290
+ return a.meridiem === b.meridiem ? `${a.text}–${b.text} ${b.meridiem}` : `${a.text} ${a.meridiem}–${b.text} ${b.meridiem}`;
291
+ }
292
+ const SCHEMA_DAYS = [
293
+ ["mon", "Monday"],
294
+ ["tue", "Tuesday"],
295
+ ["wed", "Wednesday"],
296
+ ["thu", "Thursday"],
297
+ ["fri", "Friday"],
298
+ ["sat", "Saturday"],
299
+ ["sun", "Sunday"],
300
+ ];
301
+ /** schema.org OpeningHoursSpecification entries for JSON-LD, one per
302
+ * distinct window with the days that share it (Monday first). An overnight
303
+ * window keeps its end verbatim (closes "02:00"), as schema.org reads it. A
304
+ * 24-hour window (end equal to start, e.g. ["00:00", "00:00"]) closes
305
+ * "23:59": Google reads opens "00:00" closes "00:00" as closed all day.
306
+ * Closures are never emitted. */
307
+ export function openingHoursSpecification(business) {
308
+ const hours = business.hours && typeof business.hours === "object" ? business.hours : {};
309
+ const groups = new Map();
310
+ for (const [day, dayName] of SCHEMA_DAYS) {
311
+ const windows = Array.isArray(hours[day]) ? hours[day] : [];
312
+ const valid = windows
313
+ .filter((w) => Array.isArray(w) && isTime(w[0]) && isTime(w[1]) && toMinutes(w[0]) < DAY)
314
+ .sort((x, y) => toMinutes(x[0]) - toMinutes(y[0]));
315
+ for (const [opens, end] of valid) {
316
+ const closes = toMinutes(end) === toMinutes(opens) ? "23:59" : end;
317
+ const key = `${opens}|${closes}`;
318
+ const group = groups.get(key) ?? { opens, closes, days: [] };
319
+ if (!group.days.includes(dayName))
320
+ group.days.push(dayName);
321
+ groups.set(key, group);
322
+ }
323
+ }
324
+ return [...groups.values()].map((g) => ({ "@type": "OpeningHoursSpecification", dayOfWeek: g.days, opens: g.opens, closes: g.closes }));
325
+ }
@@ -83,6 +83,49 @@ export type CMSReview = {
83
83
  /** ISO 8601, UTC. */
84
84
  createdAt: string;
85
85
  };
86
+ /** Why a business is closed (panel business spec 2026-10-26 §3.1). */
87
+ export type ClosureReason = "private_event" | "holiday" | "weather" | "maintenance" | "staffing" | "other";
88
+ /** One closure, in the business's local dates and wall times. `start` and
89
+ * `end` are both null for an all-day closure; a window is "HH:MM" on a single
90
+ * date (`end` may be "24:00"). `message` is public copy, possibly empty. */
91
+ export type CMSClosure = {
92
+ id: string;
93
+ /** "YYYY-MM-DD", inclusive. */
94
+ from: string;
95
+ /** "YYYY-MM-DD", inclusive. */
96
+ to: string;
97
+ start: string | null;
98
+ end: string | null;
99
+ reason: ClosureReason;
100
+ message: string;
101
+ };
102
+ export type CMSBusinessAddress = {
103
+ street: string;
104
+ unit: string;
105
+ city: string;
106
+ /** State or province. */
107
+ region: string;
108
+ postalCode: string;
109
+ /** ISO 3166-1 alpha-2. */
110
+ country: string;
111
+ };
112
+ /** Opening windows per weekday as ["HH:MM", "HH:MM"] pairs; a day absent or
113
+ * empty is closed. An end at or before its start runs into the next day
114
+ * (["17:00", "02:00"] closes at 2 AM). */
115
+ export type CMSBusinessHours = Partial<Record<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun", Array<[string, string]>>>;
116
+ /** The business block (panel business spec 2026-10-26 §5.1). `phone` is the
117
+ * display form; `closures` are the current and upcoming ones. */
118
+ export type CMSBusiness = {
119
+ name: string;
120
+ address: CMSBusinessAddress;
121
+ phone: string;
122
+ email: string;
123
+ /** IANA zone the hours and closures are written in. */
124
+ timezone: string;
125
+ hours: CMSBusinessHours;
126
+ hoursNote: string;
127
+ closures: CMSClosure[];
128
+ };
86
129
  export type CMSWebsite = {
87
130
  id: string;
88
131
  user_id: string;
@@ -93,6 +136,8 @@ export type CMSWebsite = {
93
136
  pages: CMSPage[];
94
137
  /** Present only when the site has reviews switched on in the panel; read it with readReviews. */
95
138
  reviews?: CMSReview[];
139
+ /** Present only when the site has Business switched on in the panel; read it with readBusiness. */
140
+ business?: CMSBusiness;
96
141
  };
97
142
  export type PublicSiteData = {
98
143
  website: CMSWebsite;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,9 @@ export { readSiteContent, cmsItemProps } from "./reader.js";
4
4
  export type { SiteContent, CmsItemMeta } from "./reader.js";
5
5
  export { fetchPublicSiteData } from "./cms-client.js";
6
6
  export { readReviews } from "./reviews.js";
7
- export type { CMSComponent, CMSPage, CMSReview, CMSSection, CMSSectionGroup, CMSStaticRendition, CMSVideo, CMSWebsite, PublicSiteData, } from "./cms-types.js";
7
+ export { businessStatus, describeClosure, formatWindow, openingHoursSpecification, readBusiness, todaysClosures, } from "./business.js";
8
+ export type { BusinessStatus } from "./business.js";
9
+ export type { CMSBusiness, CMSBusinessAddress, CMSBusinessHours, CMSClosure, CMSComponent, CMSPage, CMSReview, CMSSection, CMSSectionGroup, CMSStaticRendition, CMSVideo, CMSWebsite, ClosureReason, PublicSiteData, } from "./cms-types.js";
8
10
  export { createCommerce } from "./commerce.js";
9
11
  export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
10
12
  export { createInquiries } from "./inquiries.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { defineSite, siteManifestSchema } from "./manifest.js";
2
2
  export { readSiteContent, cmsItemProps } from "./reader.js";
3
3
  export { fetchPublicSiteData } from "./cms-client.js";
4
4
  export { readReviews } from "./reviews.js";
5
+ export { businessStatus, describeClosure, formatWindow, openingHoursSpecification, readBusiness, todaysClosures, } from "./business.js";
5
6
  export { createCommerce } from "./commerce.js";
6
7
  export { createInquiries } from "./inquiries.js";
7
8
  export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
@@ -8,6 +8,13 @@ export interface ManifestField {
8
8
  allowTypeChange?: boolean;
9
9
  allowedTypes?: readonly FieldType[];
10
10
  generateMp4?: boolean;
11
+ /**
12
+ * A dropdown's allowed values (text fields only, 2–50, unique ignoring case
13
+ * and spaces). The panel edits the field as a dropdown and checks imported
14
+ * values against the list. The field still reads as a `string`: a stored
15
+ * value outside the list is possible and is shown as-is.
16
+ */
17
+ options?: readonly string[];
11
18
  }
12
19
  export interface ManifestSeedItem {
13
20
  name: string;
@@ -31,6 +38,13 @@ export interface ManifestSection {
31
38
  export interface ManifestPage {
32
39
  slug: string;
33
40
  title: string;
41
+ /**
42
+ * `"menu"` gives the client the panel's Menu page for this page: every
43
+ * repeatable section as an editable table, spreadsheet export and import.
44
+ * Needs at least one repeatable section; each repeatable section's first
45
+ * text field names its items.
46
+ */
47
+ kind?: "menu";
34
48
  sections: readonly ManifestSection[];
35
49
  }
36
50
  export interface SiteManifest {
@@ -40,9 +54,10 @@ export interface SiteManifest {
40
54
  export declare function defineSite<const T extends SiteManifest>(manifest: T): T;
41
55
  export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
42
56
  alias: z.ZodString;
43
- pages: z.ZodArray<z.ZodObject<{
57
+ pages: z.ZodArray<z.ZodEffects<z.ZodObject<{
44
58
  slug: z.ZodString;
45
59
  title: z.ZodString;
60
+ kind: z.ZodOptional<z.ZodLiteral<"menu">>;
46
61
  sections: z.ZodArray<z.ZodEffects<z.ZodObject<{
47
62
  key: z.ZodString;
48
63
  title: z.ZodString;
@@ -57,12 +72,14 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
57
72
  allowTypeChange: z.ZodOptional<z.ZodBoolean>;
58
73
  allowedTypes: z.ZodOptional<z.ZodArray<z.ZodEnum<["text", "image", "color", "audio", "video"]>, "many">>;
59
74
  generateMp4: z.ZodOptional<z.ZodBoolean>;
75
+ options: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
60
76
  }, "strict", z.ZodTypeAny, {
61
77
  label: string;
62
78
  type: "text" | "image" | "color" | "audio" | "video";
63
79
  default?: string | undefined;
64
80
  required?: boolean | undefined;
65
81
  allowTypeChange?: boolean | undefined;
82
+ options?: string[] | undefined;
66
83
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
67
84
  generateMp4?: boolean | undefined;
68
85
  }, {
@@ -71,6 +88,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
71
88
  default?: string | undefined;
72
89
  required?: boolean | undefined;
73
90
  allowTypeChange?: boolean | undefined;
91
+ options?: string[] | undefined;
74
92
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
75
93
  generateMp4?: boolean | undefined;
76
94
  }>>;
@@ -78,11 +96,11 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
78
96
  name: z.ZodString;
79
97
  values: z.ZodRecord<z.ZodString, z.ZodString>;
80
98
  }, "strict", z.ZodTypeAny, {
81
- values: Record<string, string>;
82
99
  name: string;
83
- }, {
84
100
  values: Record<string, string>;
101
+ }, {
85
102
  name: string;
103
+ values: Record<string, string>;
86
104
  }>, "many">>;
87
105
  }, "strict", z.ZodTypeAny, {
88
106
  key: string;
@@ -94,14 +112,15 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
94
112
  default?: string | undefined;
95
113
  required?: boolean | undefined;
96
114
  allowTypeChange?: boolean | undefined;
115
+ options?: string[] | undefined;
97
116
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
98
117
  generateMp4?: boolean | undefined;
99
118
  }>;
100
119
  allowGroupCreation?: boolean | undefined;
101
120
  batchUpload?: string | true | undefined;
102
121
  seed?: {
103
- values: Record<string, string>;
104
122
  name: string;
123
+ values: Record<string, string>;
105
124
  }[] | undefined;
106
125
  }, {
107
126
  key: string;
@@ -113,14 +132,15 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
113
132
  default?: string | undefined;
114
133
  required?: boolean | undefined;
115
134
  allowTypeChange?: boolean | undefined;
135
+ options?: string[] | undefined;
116
136
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
117
137
  generateMp4?: boolean | undefined;
118
138
  }>;
119
139
  allowGroupCreation?: boolean | undefined;
120
140
  batchUpload?: string | true | undefined;
121
141
  seed?: {
122
- values: Record<string, string>;
123
142
  name: string;
143
+ values: Record<string, string>;
124
144
  }[] | undefined;
125
145
  }>, {
126
146
  key: string;
@@ -132,14 +152,15 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
132
152
  default?: string | undefined;
133
153
  required?: boolean | undefined;
134
154
  allowTypeChange?: boolean | undefined;
155
+ options?: string[] | undefined;
135
156
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
136
157
  generateMp4?: boolean | undefined;
137
158
  }>;
138
159
  allowGroupCreation?: boolean | undefined;
139
160
  batchUpload?: string | true | undefined;
140
161
  seed?: {
141
- values: Record<string, string>;
142
162
  name: string;
163
+ values: Record<string, string>;
143
164
  }[] | undefined;
144
165
  }, {
145
166
  key: string;
@@ -151,14 +172,15 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
151
172
  default?: string | undefined;
152
173
  required?: boolean | undefined;
153
174
  allowTypeChange?: boolean | undefined;
175
+ options?: string[] | undefined;
154
176
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
155
177
  generateMp4?: boolean | undefined;
156
178
  }>;
157
179
  allowGroupCreation?: boolean | undefined;
158
180
  batchUpload?: string | true | undefined;
159
181
  seed?: {
160
- values: Record<string, string>;
161
182
  name: string;
183
+ values: Record<string, string>;
162
184
  }[] | undefined;
163
185
  }>, "many">;
164
186
  }, "strict", z.ZodTypeAny, {
@@ -174,16 +196,18 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
174
196
  default?: string | undefined;
175
197
  required?: boolean | undefined;
176
198
  allowTypeChange?: boolean | undefined;
199
+ options?: string[] | undefined;
177
200
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
178
201
  generateMp4?: boolean | undefined;
179
202
  }>;
180
203
  allowGroupCreation?: boolean | undefined;
181
204
  batchUpload?: string | true | undefined;
182
205
  seed?: {
183
- values: Record<string, string>;
184
206
  name: string;
207
+ values: Record<string, string>;
185
208
  }[] | undefined;
186
209
  }[];
210
+ kind?: "menu" | undefined;
187
211
  }, {
188
212
  title: string;
189
213
  slug: string;
@@ -197,16 +221,68 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
197
221
  default?: string | undefined;
198
222
  required?: boolean | undefined;
199
223
  allowTypeChange?: boolean | undefined;
224
+ options?: string[] | undefined;
200
225
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
201
226
  generateMp4?: boolean | undefined;
202
227
  }>;
203
228
  allowGroupCreation?: boolean | undefined;
204
229
  batchUpload?: string | true | undefined;
205
230
  seed?: {
231
+ name: string;
206
232
  values: Record<string, string>;
233
+ }[] | undefined;
234
+ }[];
235
+ kind?: "menu" | undefined;
236
+ }>, {
237
+ title: string;
238
+ slug: string;
239
+ sections: {
240
+ key: string;
241
+ title: string;
242
+ kind: "repeatable" | "singleton";
243
+ fields: Record<string, {
244
+ label: string;
245
+ type: "text" | "image" | "color" | "audio" | "video";
246
+ default?: string | undefined;
247
+ required?: boolean | undefined;
248
+ allowTypeChange?: boolean | undefined;
249
+ options?: string[] | undefined;
250
+ allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
251
+ generateMp4?: boolean | undefined;
252
+ }>;
253
+ allowGroupCreation?: boolean | undefined;
254
+ batchUpload?: string | true | undefined;
255
+ seed?: {
207
256
  name: string;
257
+ values: Record<string, string>;
208
258
  }[] | undefined;
209
259
  }[];
260
+ kind?: "menu" | undefined;
261
+ }, {
262
+ title: string;
263
+ slug: string;
264
+ sections: {
265
+ key: string;
266
+ title: string;
267
+ kind: "repeatable" | "singleton";
268
+ fields: Record<string, {
269
+ label: string;
270
+ type: "text" | "image" | "color" | "audio" | "video";
271
+ default?: string | undefined;
272
+ required?: boolean | undefined;
273
+ allowTypeChange?: boolean | undefined;
274
+ options?: string[] | undefined;
275
+ allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
276
+ generateMp4?: boolean | undefined;
277
+ }>;
278
+ allowGroupCreation?: boolean | undefined;
279
+ batchUpload?: string | true | undefined;
280
+ seed?: {
281
+ name: string;
282
+ values: Record<string, string>;
283
+ }[] | undefined;
284
+ }[];
285
+ kind?: "menu" | undefined;
210
286
  }>, "many">;
211
287
  }, "strict", z.ZodTypeAny, {
212
288
  alias: string;
@@ -223,16 +299,18 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
223
299
  default?: string | undefined;
224
300
  required?: boolean | undefined;
225
301
  allowTypeChange?: boolean | undefined;
302
+ options?: string[] | undefined;
226
303
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
227
304
  generateMp4?: boolean | undefined;
228
305
  }>;
229
306
  allowGroupCreation?: boolean | undefined;
230
307
  batchUpload?: string | true | undefined;
231
308
  seed?: {
232
- values: Record<string, string>;
233
309
  name: string;
310
+ values: Record<string, string>;
234
311
  }[] | undefined;
235
312
  }[];
313
+ kind?: "menu" | undefined;
236
314
  }[];
237
315
  }, {
238
316
  alias: string;
@@ -249,16 +327,18 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
249
327
  default?: string | undefined;
250
328
  required?: boolean | undefined;
251
329
  allowTypeChange?: boolean | undefined;
330
+ options?: string[] | undefined;
252
331
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
253
332
  generateMp4?: boolean | undefined;
254
333
  }>;
255
334
  allowGroupCreation?: boolean | undefined;
256
335
  batchUpload?: string | true | undefined;
257
336
  seed?: {
258
- values: Record<string, string>;
259
337
  name: string;
338
+ values: Record<string, string>;
260
339
  }[] | undefined;
261
340
  }[];
341
+ kind?: "menu" | undefined;
262
342
  }[];
263
343
  }>, {
264
344
  alias: string;
@@ -275,16 +355,18 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
275
355
  default?: string | undefined;
276
356
  required?: boolean | undefined;
277
357
  allowTypeChange?: boolean | undefined;
358
+ options?: string[] | undefined;
278
359
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
279
360
  generateMp4?: boolean | undefined;
280
361
  }>;
281
362
  allowGroupCreation?: boolean | undefined;
282
363
  batchUpload?: string | true | undefined;
283
364
  seed?: {
284
- values: Record<string, string>;
285
365
  name: string;
366
+ values: Record<string, string>;
286
367
  }[] | undefined;
287
368
  }[];
369
+ kind?: "menu" | undefined;
288
370
  }[];
289
371
  }, {
290
372
  alias: string;
@@ -301,15 +383,17 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
301
383
  default?: string | undefined;
302
384
  required?: boolean | undefined;
303
385
  allowTypeChange?: boolean | undefined;
386
+ options?: string[] | undefined;
304
387
  allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
305
388
  generateMp4?: boolean | undefined;
306
389
  }>;
307
390
  allowGroupCreation?: boolean | undefined;
308
391
  batchUpload?: string | true | undefined;
309
392
  seed?: {
310
- values: Record<string, string>;
311
393
  name: string;
394
+ values: Record<string, string>;
312
395
  }[] | undefined;
313
396
  }[];
397
+ kind?: "menu" | undefined;
314
398
  }[];
315
399
  }>;
package/dist/manifest.js CHANGED
@@ -15,8 +15,12 @@ const fieldSchema = z
15
15
  allowTypeChange: z.boolean().optional(),
16
16
  allowedTypes: z.array(fieldTypeSchema).optional(),
17
17
  generateMp4: z.boolean().optional(),
18
+ options: z.array(z.string().trim().min(1).max(60)).optional(),
18
19
  })
19
20
  .strict();
21
+ // The match key for options (and, in the panel, sheet, column and item names):
22
+ // trimmed, lower-case, internal whitespace collapsed.
23
+ const optionKey = (value) => value.trim().toLowerCase().replace(/\s+/g, " ");
20
24
  const seedItemSchema = z
21
25
  .object({
22
26
  name: z.string().trim().min(1),
@@ -107,14 +111,99 @@ const sectionSchema = z
107
111
  }
108
112
  }
109
113
  }
114
+ // Dropdown options: text fields only, 2–50 of them, unique by their match
115
+ // key. A default and every seed value must be one of them, exactly.
116
+ for (const [key, field] of Object.entries(section.fields)) {
117
+ const options = field.options;
118
+ if (options === undefined)
119
+ continue;
120
+ const where = `field "${key}" on section "${section.title}"`;
121
+ if (field.type !== "text") {
122
+ ctx.addIssue({
123
+ code: z.ZodIssueCode.custom,
124
+ message: `options on field "${key}" of section "${section.title}" need type: "text"`,
125
+ });
126
+ }
127
+ if (options.length < 2 || options.length > 50) {
128
+ ctx.addIssue({
129
+ code: z.ZodIssueCode.custom,
130
+ message: `${where} needs between 2 and 50 options (found ${options.length})`,
131
+ });
132
+ }
133
+ const seen = new Map();
134
+ for (const option of options) {
135
+ const first = seen.get(optionKey(option));
136
+ if (first !== undefined) {
137
+ ctx.addIssue({
138
+ code: z.ZodIssueCode.custom,
139
+ message: `option "${option}" of ${where} repeats "${first}" (options must differ by more than case and spaces)`,
140
+ });
141
+ }
142
+ else {
143
+ seen.set(optionKey(option), option);
144
+ }
145
+ }
146
+ if (field.default !== undefined && !options.includes(field.default)) {
147
+ ctx.addIssue({
148
+ code: z.ZodIssueCode.custom,
149
+ message: `default "${field.default}" of ${where} is not one of its options`,
150
+ });
151
+ }
152
+ for (const item of section.seed ?? []) {
153
+ if (Object.hasOwn(item.values, key) && !options.includes(item.values[key])) {
154
+ ctx.addIssue({
155
+ code: z.ZodIssueCode.custom,
156
+ message: `seed "${item.name}" sets "${key}" to "${item.values[key]}", which is not one of its options`,
157
+ });
158
+ }
159
+ }
160
+ }
110
161
  });
111
162
  const pageSchema = z
112
163
  .object({
113
164
  slug: z.string().trim().min(1),
114
165
  title: z.string().trim().min(1),
166
+ kind: z.literal("menu").optional(),
115
167
  sections: z.array(sectionSchema),
116
168
  })
117
- .strict();
169
+ .strict()
170
+ .superRefine((page, ctx) => {
171
+ if (page.kind !== "menu")
172
+ return;
173
+ // The panel's Menu page shows repeatable sections as tables; a menu page
174
+ // without one has nothing to show.
175
+ if (!page.sections.some((section) => section.kind === "repeatable")) {
176
+ ctx.addIssue({
177
+ code: z.ZodIssueCode.custom,
178
+ message: `menu page ${page.slug} needs at least one repeatable section`,
179
+ });
180
+ }
181
+ // On a menu the first text field (schema order) IS the item's name: the
182
+ // table's row title, the group name, the import's row key. A seed item's
183
+ // name must therefore equal the value seeding stores in that field.
184
+ for (const section of page.sections) {
185
+ if (section.kind !== "repeatable")
186
+ continue;
187
+ const nameEntry = Object.entries(section.fields).find(([, field]) => field.type === "text");
188
+ if (!nameEntry) {
189
+ ctx.addIssue({
190
+ code: z.ZodIssueCode.custom,
191
+ message: `section "${section.title}" on menu page ${page.slug} needs a text field (its first one names each item)`,
192
+ });
193
+ continue;
194
+ }
195
+ const [nameKey, nameField] = nameEntry;
196
+ for (const item of section.seed ?? []) {
197
+ const value = Object.hasOwn(item.values, nameKey) ? item.values[nameKey] : (nameField.default ?? "");
198
+ if (value !== item.name) {
199
+ ctx.addIssue({
200
+ code: z.ZodIssueCode.custom,
201
+ message: `seed "${item.name}" on menu section "${section.title}" must have the same name as its "${nameKey}" value ("${value}")`,
202
+ });
203
+ }
204
+ }
205
+ }
206
+ });
118
207
  export const siteManifestSchema = z
119
208
  .object({
120
209
  alias: z.string().trim().min(1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullgrainsoftware/portal",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Fullgrain Portal site SDK: manifest, typed content reader, sync CLI, preview bridge, commerce, inquiries and bookings clients",
5
5
  "repository": {
6
6
  "type": "git",