@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 +141 -0
- package/dist/business.d.ts +39 -0
- package/dist/business.js +325 -0
- package/dist/cms-types.d.ts +45 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +96 -12
- package/dist/manifest.js +90 -1
- package/package.json +1 -1
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
|
+
}>;
|
package/dist/business.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cms-types.d.ts
CHANGED
|
@@ -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
|
|
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";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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