@velastack/cms 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -43
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/field-input.svelte +20 -1
- package/dist/components/admin-bar/field-input.svelte.d.ts +2 -1
- package/dist/components/admin-bar/fields-form.svelte +218 -0
- package/dist/components/admin-bar/fields-form.svelte.d.ts +16 -0
- package/dist/components/admin-bar/link-input.svelte +102 -0
- package/dist/components/admin-bar/link-input.svelte.d.ts +9 -0
- package/dist/components/admin-bar/list-field.svelte +93 -0
- package/dist/components/admin-bar/list-field.svelte.d.ts +20 -0
- package/dist/components/admin-bar/locales-panel.svelte +1 -1
- package/dist/components/admin-bar/page-options.d.ts +24 -0
- package/dist/components/admin-bar/page-options.js +53 -0
- package/dist/components/admin-bar/rich-text-input.svelte +92 -0
- package/dist/components/admin-bar/rich-text-input.svelte.d.ts +9 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte +15 -6
- package/dist/components/cms/cms-collection.svelte +46 -0
- package/dist/components/cms/cms-collection.svelte.d.ts +25 -0
- package/dist/components/cms/cms-contact.svelte +26 -0
- package/dist/components/cms/cms-contact.svelte.d.ts +14 -0
- package/dist/components/cms/cms-faq.svelte +22 -0
- package/dist/components/cms/cms-faq.svelte.d.ts +15 -0
- package/dist/components/cms/cms-gallery.svelte +31 -0
- package/dist/components/cms/cms-gallery.svelte.d.ts +16 -0
- package/dist/components/cms/cms-hours-editable.svelte +160 -0
- package/dist/components/cms/cms-hours-editable.svelte.d.ts +17 -0
- package/dist/components/cms/cms-hours.svelte +42 -0
- package/dist/components/cms/cms-hours.svelte.d.ts +19 -0
- package/dist/components/cms/cms-image.svelte +3 -25
- package/dist/components/cms/cms-image.svelte.d.ts +3 -11
- package/dist/components/cms/cms-link-editable.svelte +4 -60
- package/dist/components/cms/cms-link.svelte +3 -23
- package/dist/components/cms/cms-link.svelte.d.ts +3 -10
- package/dist/components/cms/cms-logos.svelte +29 -0
- package/dist/components/cms/cms-logos.svelte.d.ts +16 -0
- package/dist/components/cms/cms-nav.svelte +22 -0
- package/dist/components/cms/cms-nav.svelte.d.ts +15 -0
- package/dist/components/cms/cms-pricing.svelte +36 -0
- package/dist/components/cms/cms-pricing.svelte.d.ts +14 -0
- package/dist/components/cms/cms-schedule.svelte +35 -0
- package/dist/components/cms/cms-schedule.svelte.d.ts +16 -0
- package/dist/components/cms/cms-social-links.svelte +35 -0
- package/dist/components/cms/cms-social-links.svelte.d.ts +15 -0
- package/dist/components/cms/cms-stats.svelte +31 -0
- package/dist/components/cms/cms-stats.svelte.d.ts +16 -0
- package/dist/components/cms/cms-steps.svelte +31 -0
- package/dist/components/cms/cms-steps.svelte.d.ts +16 -0
- package/dist/components/cms/cms-structured-editable.svelte +29 -0
- package/dist/components/cms/{cms-repeater-editable.svelte.d.ts → cms-structured-editable.svelte.d.ts} +12 -8
- package/dist/components/cms/cms-structured.svelte +50 -0
- package/dist/components/cms/cms-structured.svelte.d.ts +49 -0
- package/dist/components/cms/cms-team.svelte +22 -0
- package/dist/components/cms/cms-team.svelte.d.ts +15 -0
- package/dist/components/cms/cms-testimonials.svelte +36 -0
- package/dist/components/cms/cms-testimonials.svelte.d.ts +15 -0
- package/dist/components/cms/cms-timeline.svelte +35 -0
- package/dist/components/cms/cms-timeline.svelte.d.ts +16 -0
- package/dist/components/cms/index.d.ts +15 -1
- package/dist/components/cms/index.js +15 -1
- package/dist/core/shapes/collection.d.ts +60 -0
- package/dist/core/shapes/collection.js +194 -0
- package/dist/core/shapes/contact.d.ts +70 -0
- package/dist/core/shapes/contact.js +214 -0
- package/dist/core/shapes/faq.d.ts +12 -0
- package/dist/core/shapes/faq.js +48 -0
- package/dist/core/shapes/form.d.ts +46 -0
- package/dist/core/shapes/form.js +7 -0
- package/dist/core/shapes/hours.d.ts +91 -0
- package/dist/core/shapes/hours.js +325 -0
- package/dist/core/shapes/image.d.ts +15 -0
- package/dist/core/shapes/image.js +27 -0
- package/dist/core/shapes/index.d.ts +31 -0
- package/dist/core/shapes/index.js +13 -0
- package/dist/core/shapes/link.d.ts +15 -0
- package/dist/core/shapes/link.js +42 -0
- package/dist/core/shapes/nav.d.ts +21 -0
- package/dist/core/shapes/nav.js +71 -0
- package/dist/core/shapes/presets.d.ts +65 -0
- package/dist/core/shapes/presets.js +152 -0
- package/dist/core/shapes/pricing.d.ts +40 -0
- package/dist/core/shapes/pricing.js +115 -0
- package/dist/core/shapes/social-links.d.ts +19 -0
- package/dist/core/shapes/social-links.js +83 -0
- package/dist/core/shapes/team.d.ts +22 -0
- package/dist/core/shapes/team.js +70 -0
- package/dist/core/shapes/testimonials.d.ts +24 -0
- package/dist/core/shapes/testimonials.js +86 -0
- package/dist/{components/cms → core}/structured-registry.d.ts +2 -2
- package/dist/core/structured.d.ts +33 -10
- package/dist/core/structured.js +98 -47
- package/dist/editor.d.ts +4 -0
- package/dist/editor.js +4 -0
- package/dist/index.d.ts +22 -8
- package/dist/index.js +24 -5
- package/package.json +1 -1
- package/dist/components/cms/cms-repeater-editable.svelte +0 -137
- package/dist/components/cms/cms-repeater.svelte +0 -52
- package/dist/components/cms/cms-repeater.svelte.d.ts +0 -31
- /package/dist/{components/cms → core}/structured-registry.js +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the generic structured editor renders for a shape. A component's
|
|
3
|
+
* schema lists its fields; the popover walks them and draws one input per
|
|
4
|
+
* field. `list` fields hold items (stable `id` each) with their own fields,
|
|
5
|
+
* so a collection item's details or a pricing tier's features are edited in
|
|
6
|
+
* place. Templates never see this: they get the typed value.
|
|
7
|
+
*/
|
|
8
|
+
import type { Tree } from '../path.js';
|
|
9
|
+
import { type Structured, type StructuredSchema } from '../structured.js';
|
|
10
|
+
export type FormFieldType = 'text' | 'long-string' | 'html' | 'number' | 'boolean' | 'date' | 'time' | 'url' | 'image' | 'images' | 'enum' | 'link' | 'strings' | 'rating' | 'list';
|
|
11
|
+
export type FormField = {
|
|
12
|
+
/** Dotted path within the object being edited (`name`, `price.amount`). */
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
type: FormFieldType;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** `enum`: the stored values, and optional display names. */
|
|
18
|
+
values?: readonly string[];
|
|
19
|
+
names?: Readonly<Record<string, string>>;
|
|
20
|
+
/** Share a row with the next field. */
|
|
21
|
+
half?: boolean;
|
|
22
|
+
/** Tab the field sits in. Fields without a group share the first tab. */
|
|
23
|
+
group?: string;
|
|
24
|
+
/** `list`: what one item is called ("tier"), its fields, a blank item
|
|
25
|
+
* (without `id`) and which field titles a collapsed card. */
|
|
26
|
+
itemLabel?: string;
|
|
27
|
+
fields?: readonly FormField[];
|
|
28
|
+
blank?: () => Tree;
|
|
29
|
+
titleKey?: string;
|
|
30
|
+
};
|
|
31
|
+
export type FormShape<T extends Tree> = Structured<T> & {
|
|
32
|
+
/** Popover header, e.g. "Opening hours". */
|
|
33
|
+
label: string;
|
|
34
|
+
fields: readonly FormField[];
|
|
35
|
+
};
|
|
36
|
+
export declare const defineForm: <T extends Tree>(schema: StructuredSchema<T> & {
|
|
37
|
+
label: string;
|
|
38
|
+
fields: readonly FormField[];
|
|
39
|
+
}) => FormShape<T>;
|
|
40
|
+
/** Shared vocabulary for the list-shaped components: `{ v: 1, items }`. */
|
|
41
|
+
export type ListValue<I extends {
|
|
42
|
+
id: string;
|
|
43
|
+
}> = {
|
|
44
|
+
v: 1;
|
|
45
|
+
items: I[];
|
|
46
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineStructured } from '../structured.js';
|
|
2
|
+
import { registerStructured } from '../structured-registry.js';
|
|
3
|
+
export const defineForm = (schema) => {
|
|
4
|
+
const { label, fields, ...rest } = schema;
|
|
5
|
+
const structured = registerStructured(defineStructured(rest));
|
|
6
|
+
return { ...structured, label, fields };
|
|
7
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CmsHours`: a week of opening hours in the business's timezone, with
|
|
3
|
+
* holiday exceptions and the labels a template needs around them. The
|
|
4
|
+
* stored value is data only; `hoursView` derives what the template renders
|
|
5
|
+
* (folded rows, today, open-now) in the visitor's locale.
|
|
6
|
+
*/
|
|
7
|
+
import type { Tree } from '../path.js';
|
|
8
|
+
export declare const DAY_KEYS: readonly ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
9
|
+
export type DayKey = (typeof DAY_KEYS)[number];
|
|
10
|
+
/** `HH:MM`, 24-hour. */
|
|
11
|
+
export type HoursRange = {
|
|
12
|
+
open: string;
|
|
13
|
+
close: string;
|
|
14
|
+
};
|
|
15
|
+
export type DayHours = {
|
|
16
|
+
closed: boolean;
|
|
17
|
+
ranges: HoursRange[];
|
|
18
|
+
};
|
|
19
|
+
export type HoursException = {
|
|
20
|
+
id: string;
|
|
21
|
+
/** `YYYY-MM-DD` */
|
|
22
|
+
date: string;
|
|
23
|
+
label: string;
|
|
24
|
+
closed: boolean;
|
|
25
|
+
ranges: HoursRange[];
|
|
26
|
+
};
|
|
27
|
+
export type HoursLabels = {
|
|
28
|
+
closed: string;
|
|
29
|
+
today: string;
|
|
30
|
+
openNow: string;
|
|
31
|
+
closedNow: string;
|
|
32
|
+
};
|
|
33
|
+
export type CmsHoursValue = {
|
|
34
|
+
v: 1;
|
|
35
|
+
/** IANA zone, e.g. `Europe/Madrid`. Empty means the visitor's zone. */
|
|
36
|
+
timezone: string;
|
|
37
|
+
days: Record<DayKey, DayHours>;
|
|
38
|
+
note: string;
|
|
39
|
+
exceptions: HoursException[];
|
|
40
|
+
labels: HoursLabels;
|
|
41
|
+
};
|
|
42
|
+
export declare const DEFAULT_HOURS_LABELS: HoursLabels;
|
|
43
|
+
export declare const cmsHours: import("./form.js").FormShape<CmsHoursValue>;
|
|
44
|
+
/** `09:00` → `9:00 AM` (en) / `9:00` (es). */
|
|
45
|
+
export declare const formatTime: (hhmm: string, locale: string) => string;
|
|
46
|
+
/** `9:00 AM – 5:00 PM, 6:00 – 10:00 PM`, or the closed label. */
|
|
47
|
+
export declare const formatRanges: (ranges: HoursRange[], locale: string, closedLabel: string) => string;
|
|
48
|
+
/** Short weekday names in the visitor's locale, Monday first. */
|
|
49
|
+
export declare const dayNames: (locale: string, weekday?: "short" | "long") => Record<DayKey, string>;
|
|
50
|
+
/** The current weekday, date and minute in `timezone` (the visitor's zone when empty or invalid). */
|
|
51
|
+
export declare const zonedNow: (timezone: string, now?: Date) => {
|
|
52
|
+
key: DayKey;
|
|
53
|
+
date: string;
|
|
54
|
+
minutes: number;
|
|
55
|
+
};
|
|
56
|
+
export type HoursRow = {
|
|
57
|
+
id: string;
|
|
58
|
+
keys: DayKey[];
|
|
59
|
+
/** `Mon – Fri`, `Sat`, `Sat, Sun` */
|
|
60
|
+
days: string;
|
|
61
|
+
/** Formatted ranges, or the closed label. */
|
|
62
|
+
text: string;
|
|
63
|
+
closed: boolean;
|
|
64
|
+
};
|
|
65
|
+
export type HoursToday = {
|
|
66
|
+
key: DayKey;
|
|
67
|
+
date: string;
|
|
68
|
+
/** The value's `labels.today`. */
|
|
69
|
+
label: string;
|
|
70
|
+
/** `Mon` in the visitor's locale. */
|
|
71
|
+
day: string;
|
|
72
|
+
text: string;
|
|
73
|
+
closed: boolean;
|
|
74
|
+
exception: HoursException | null;
|
|
75
|
+
};
|
|
76
|
+
export type HoursExceptionRow = HoursException & {
|
|
77
|
+
dateText: string;
|
|
78
|
+
text: string;
|
|
79
|
+
};
|
|
80
|
+
export type HoursView = CmsHoursValue & {
|
|
81
|
+
rows: HoursRow[];
|
|
82
|
+
today: HoursToday;
|
|
83
|
+
isOpenNow: boolean;
|
|
84
|
+
/** `labels.openNow` or `labels.closedNow`. */
|
|
85
|
+
status: string;
|
|
86
|
+
exceptionRows: HoursExceptionRow[];
|
|
87
|
+
};
|
|
88
|
+
export declare const formatDate: (iso: string, locale: string) => string;
|
|
89
|
+
export declare const hoursView: (value: CmsHoursValue, locale: string, now?: Date) => HoursView;
|
|
90
|
+
/** schema.org `openingHoursSpecification` entries, one per distinct range. */
|
|
91
|
+
export declare const toOpeningHoursSpecification: (value: CmsHoursValue) => Tree[];
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { asBoolean, asItems, asString, isPlainObject } from '../structured.js';
|
|
2
|
+
import { defineForm } from './form.js';
|
|
3
|
+
export const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
4
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
5
|
+
const asTime = (v, fallback) => typeof v === 'string' && TIME.test(v) ? v : fallback;
|
|
6
|
+
const asRanges = (v) => {
|
|
7
|
+
if (!Array.isArray(v))
|
|
8
|
+
return [];
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const r of v) {
|
|
11
|
+
if (!isPlainObject(r))
|
|
12
|
+
continue;
|
|
13
|
+
out.push({ open: asTime(r.open, '09:00'), close: asTime(r.close, '17:00') });
|
|
14
|
+
}
|
|
15
|
+
return out.slice(0, 2);
|
|
16
|
+
};
|
|
17
|
+
const asDay = (v) => {
|
|
18
|
+
if (!isPlainObject(v))
|
|
19
|
+
return { closed: true, ranges: [] };
|
|
20
|
+
const ranges = asRanges(v.ranges);
|
|
21
|
+
return { closed: asBoolean(v.closed, ranges.length === 0), ranges };
|
|
22
|
+
};
|
|
23
|
+
export const DEFAULT_HOURS_LABELS = {
|
|
24
|
+
closed: 'Closed',
|
|
25
|
+
today: 'Today',
|
|
26
|
+
openNow: 'Open now',
|
|
27
|
+
closedNow: 'Closed now'
|
|
28
|
+
};
|
|
29
|
+
const emptyDays = () => {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const k of DAY_KEYS)
|
|
32
|
+
out[k] = { closed: true, ranges: [] };
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
export const cmsHours = defineForm({
|
|
36
|
+
component: 'CmsHours',
|
|
37
|
+
label: 'Opening hours',
|
|
38
|
+
version: 1,
|
|
39
|
+
translatable: ['note', 'labels.closed', 'labels.today', 'labels.openNow', 'labels.closedNow'],
|
|
40
|
+
items: { key: 'exceptions', translatable: ['label'] },
|
|
41
|
+
normalize: (raw) => {
|
|
42
|
+
const r = isPlainObject(raw) ? raw : {};
|
|
43
|
+
const days = isPlainObject(r.days) ? r.days : {};
|
|
44
|
+
const labels = isPlainObject(r.labels) ? r.labels : {};
|
|
45
|
+
const out = {
|
|
46
|
+
v: 1,
|
|
47
|
+
timezone: asString(r.timezone),
|
|
48
|
+
days: emptyDays(),
|
|
49
|
+
note: asString(r.note),
|
|
50
|
+
exceptions: asItems(r.exceptions, (e) => {
|
|
51
|
+
const ranges = asRanges(e.ranges);
|
|
52
|
+
return {
|
|
53
|
+
date: asString(e.date),
|
|
54
|
+
label: asString(e.label),
|
|
55
|
+
closed: asBoolean(e.closed, ranges.length === 0),
|
|
56
|
+
ranges
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
labels: {
|
|
60
|
+
closed: asString(labels.closed, DEFAULT_HOURS_LABELS.closed),
|
|
61
|
+
today: asString(labels.today, DEFAULT_HOURS_LABELS.today),
|
|
62
|
+
openNow: asString(labels.openNow, DEFAULT_HOURS_LABELS.openNow),
|
|
63
|
+
closedNow: asString(labels.closedNow, DEFAULT_HOURS_LABELS.closedNow)
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const k of DAY_KEYS)
|
|
67
|
+
if (k in days)
|
|
68
|
+
out.days[k] = asDay(days[k]);
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
empty: () => ({
|
|
72
|
+
v: 1,
|
|
73
|
+
timezone: '',
|
|
74
|
+
days: emptyDays(),
|
|
75
|
+
note: '',
|
|
76
|
+
exceptions: [],
|
|
77
|
+
labels: { ...DEFAULT_HOURS_LABELS }
|
|
78
|
+
}),
|
|
79
|
+
// The week grid is a bespoke editor (cms-hours-editable); these are the
|
|
80
|
+
// fields it renders below the grid through the generic form.
|
|
81
|
+
fields: [
|
|
82
|
+
{ key: 'note', label: 'Note', type: 'text', placeholder: 'Closed on public holidays' },
|
|
83
|
+
{
|
|
84
|
+
key: 'exceptions',
|
|
85
|
+
label: 'Exceptions',
|
|
86
|
+
type: 'list',
|
|
87
|
+
itemLabel: 'exception',
|
|
88
|
+
titleKey: 'label',
|
|
89
|
+
blank: () => ({ date: '', label: '', closed: true, ranges: [] }),
|
|
90
|
+
fields: [
|
|
91
|
+
{ key: 'date', label: 'Date', type: 'date', half: true },
|
|
92
|
+
{ key: 'label', label: 'Label', type: 'text', placeholder: 'Christmas Day' },
|
|
93
|
+
{ key: 'closed', label: 'Closed all day', type: 'boolean' }
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'timezone',
|
|
98
|
+
label: 'Timezone',
|
|
99
|
+
type: 'text',
|
|
100
|
+
placeholder: 'Europe/Madrid',
|
|
101
|
+
group: 'Labels'
|
|
102
|
+
},
|
|
103
|
+
{ key: 'labels.closed', label: '“Closed”', type: 'text', half: true, group: 'Labels' },
|
|
104
|
+
{ key: 'labels.today', label: '“Today”', type: 'text', group: 'Labels' },
|
|
105
|
+
{ key: 'labels.openNow', label: '“Open now”', type: 'text', half: true, group: 'Labels' },
|
|
106
|
+
{ key: 'labels.closedNow', label: '“Closed now”', type: 'text', group: 'Labels' }
|
|
107
|
+
]
|
|
108
|
+
});
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Derived views
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
const minutesOf = (hhmm) => {
|
|
113
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
114
|
+
return h * 60 + m;
|
|
115
|
+
};
|
|
116
|
+
/** `09:00` → `9:00 AM` (en) / `9:00` (es). */
|
|
117
|
+
export const formatTime = (hhmm, locale) => {
|
|
118
|
+
if (!TIME.test(hhmm))
|
|
119
|
+
return hhmm;
|
|
120
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
121
|
+
try {
|
|
122
|
+
return new Intl.DateTimeFormat(locale, {
|
|
123
|
+
hour: 'numeric',
|
|
124
|
+
minute: '2-digit',
|
|
125
|
+
timeZone: 'UTC'
|
|
126
|
+
}).format(new Date(Date.UTC(2024, 0, 1, h, m)));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return hhmm;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
/** `9:00 AM – 5:00 PM, 6:00 – 10:00 PM`, or the closed label. */
|
|
133
|
+
export const formatRanges = (ranges, locale, closedLabel) => ranges.length === 0
|
|
134
|
+
? closedLabel
|
|
135
|
+
: ranges
|
|
136
|
+
.map((r) => `${formatTime(r.open, locale)} – ${formatTime(r.close, locale)}`)
|
|
137
|
+
.join(', ');
|
|
138
|
+
/** Short weekday names in the visitor's locale, Monday first. */
|
|
139
|
+
export const dayNames = (locale, weekday = 'short') => {
|
|
140
|
+
const out = {};
|
|
141
|
+
let fmt = null;
|
|
142
|
+
try {
|
|
143
|
+
fmt = new Intl.DateTimeFormat(locale, { weekday, timeZone: 'UTC' });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
fmt = null;
|
|
147
|
+
}
|
|
148
|
+
DAY_KEYS.forEach((k, i) => {
|
|
149
|
+
// 2024-01-01 is a Monday.
|
|
150
|
+
out[k] = fmt ? fmt.format(new Date(Date.UTC(2024, 0, 1 + i))) : k;
|
|
151
|
+
});
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
/** The current weekday, date and minute in `timezone` (the visitor's zone when empty or invalid). */
|
|
155
|
+
export const zonedNow = (timezone, now = new Date()) => {
|
|
156
|
+
const keyOf = (weekday) => {
|
|
157
|
+
const k = weekday.slice(0, 3).toLowerCase();
|
|
158
|
+
return DAY_KEYS.includes(k) ? k : 'mon';
|
|
159
|
+
};
|
|
160
|
+
if (timezone) {
|
|
161
|
+
try {
|
|
162
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
163
|
+
timeZone: timezone,
|
|
164
|
+
weekday: 'short',
|
|
165
|
+
year: 'numeric',
|
|
166
|
+
month: '2-digit',
|
|
167
|
+
day: '2-digit',
|
|
168
|
+
hour: '2-digit',
|
|
169
|
+
minute: '2-digit',
|
|
170
|
+
hourCycle: 'h23'
|
|
171
|
+
}).formatToParts(now);
|
|
172
|
+
const p = (t) => parts.find((x) => x.type === t)?.value ?? '';
|
|
173
|
+
return {
|
|
174
|
+
key: keyOf(p('weekday')),
|
|
175
|
+
date: `${p('year')}-${p('month')}-${p('day')}`,
|
|
176
|
+
minutes: Number(p('hour')) * 60 + Number(p('minute'))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* fall through to the visitor's zone */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
184
|
+
const day = (now.getDay() + 6) % 7; // Sunday → 6
|
|
185
|
+
return {
|
|
186
|
+
key: DAY_KEYS[day],
|
|
187
|
+
date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
|
188
|
+
minutes: now.getHours() * 60 + now.getMinutes()
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
const sameHours = (a, b) => a.closed === b.closed &&
|
|
192
|
+
a.ranges.length === b.ranges.length &&
|
|
193
|
+
a.ranges.every((r, i) => r.open === b.ranges[i].open && r.close === b.ranges[i].close);
|
|
194
|
+
const effective = (d) => d.closed ? [] : d.ranges;
|
|
195
|
+
const isOpenAt = (ranges, minutes) => ranges.some((r) => {
|
|
196
|
+
const open = minutesOf(r.open);
|
|
197
|
+
const close = minutesOf(r.close);
|
|
198
|
+
if (close <= open)
|
|
199
|
+
return minutes >= open || minutes < close; // overnight
|
|
200
|
+
return minutes >= open && minutes < close;
|
|
201
|
+
});
|
|
202
|
+
export const formatDate = (iso, locale) => {
|
|
203
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
204
|
+
if (!m)
|
|
205
|
+
return iso;
|
|
206
|
+
try {
|
|
207
|
+
return new Intl.DateTimeFormat(locale, {
|
|
208
|
+
day: 'numeric',
|
|
209
|
+
month: 'short',
|
|
210
|
+
timeZone: 'UTC'
|
|
211
|
+
}).format(new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return iso;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
export const hoursView = (value, locale, now = new Date()) => {
|
|
218
|
+
const names = dayNames(locale);
|
|
219
|
+
const closedLabel = value.labels.closed;
|
|
220
|
+
const rows = [];
|
|
221
|
+
for (const key of DAY_KEYS) {
|
|
222
|
+
const day = value.days[key];
|
|
223
|
+
const last = rows[rows.length - 1];
|
|
224
|
+
if (last && sameHours(value.days[last.keys[last.keys.length - 1]], day)) {
|
|
225
|
+
last.keys.push(key);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
rows.push({
|
|
229
|
+
id: key,
|
|
230
|
+
keys: [key],
|
|
231
|
+
days: '',
|
|
232
|
+
text: formatRanges(effective(day), locale, closedLabel),
|
|
233
|
+
closed: day.closed
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
const first = names[row.keys[0]];
|
|
238
|
+
const lastName = names[row.keys[row.keys.length - 1]];
|
|
239
|
+
row.days =
|
|
240
|
+
row.keys.length === 1
|
|
241
|
+
? first
|
|
242
|
+
: row.keys.length === 2
|
|
243
|
+
? `${first}, ${lastName}`
|
|
244
|
+
: `${first} – ${lastName}`;
|
|
245
|
+
}
|
|
246
|
+
const z = zonedNow(value.timezone, now);
|
|
247
|
+
const exception = value.exceptions.find((e) => e.date === z.date) ?? null;
|
|
248
|
+
const todayRanges = effective(exception ?? value.days[z.key]);
|
|
249
|
+
const today = {
|
|
250
|
+
key: z.key,
|
|
251
|
+
date: z.date,
|
|
252
|
+
label: value.labels.today,
|
|
253
|
+
day: names[z.key],
|
|
254
|
+
text: formatRanges(todayRanges, locale, closedLabel),
|
|
255
|
+
closed: todayRanges.length === 0,
|
|
256
|
+
exception
|
|
257
|
+
};
|
|
258
|
+
const isOpenNow = isOpenAt(todayRanges, z.minutes);
|
|
259
|
+
const exceptionRows = value.exceptions
|
|
260
|
+
.filter((e) => e.date !== '')
|
|
261
|
+
.map((e) => ({
|
|
262
|
+
...e,
|
|
263
|
+
dateText: formatDate(e.date, locale),
|
|
264
|
+
text: formatRanges(effective(e), locale, closedLabel)
|
|
265
|
+
}));
|
|
266
|
+
return {
|
|
267
|
+
...value,
|
|
268
|
+
rows,
|
|
269
|
+
today,
|
|
270
|
+
isOpenNow,
|
|
271
|
+
status: isOpenNow ? value.labels.openNow : value.labels.closedNow,
|
|
272
|
+
exceptionRows
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
const SCHEMA_DAYS = {
|
|
276
|
+
mon: 'Monday',
|
|
277
|
+
tue: 'Tuesday',
|
|
278
|
+
wed: 'Wednesday',
|
|
279
|
+
thu: 'Thursday',
|
|
280
|
+
fri: 'Friday',
|
|
281
|
+
sat: 'Saturday',
|
|
282
|
+
sun: 'Sunday'
|
|
283
|
+
};
|
|
284
|
+
/** schema.org `openingHoursSpecification` entries, one per distinct range. */
|
|
285
|
+
export const toOpeningHoursSpecification = (value) => {
|
|
286
|
+
const byRange = new Map();
|
|
287
|
+
for (const key of DAY_KEYS) {
|
|
288
|
+
for (const r of effective(value.days[key])) {
|
|
289
|
+
const k = `${r.open}-${r.close}`;
|
|
290
|
+
const entry = byRange.get(k) ?? { opens: r.open, closes: r.close, days: [] };
|
|
291
|
+
entry.days.push(SCHEMA_DAYS[key]);
|
|
292
|
+
byRange.set(k, entry);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const out = [...byRange.values()].map((e) => ({
|
|
296
|
+
'@type': 'OpeningHoursSpecification',
|
|
297
|
+
dayOfWeek: e.days,
|
|
298
|
+
opens: e.opens,
|
|
299
|
+
closes: e.closes
|
|
300
|
+
}));
|
|
301
|
+
for (const e of value.exceptions) {
|
|
302
|
+
if (!e.date)
|
|
303
|
+
continue;
|
|
304
|
+
if (e.closed || e.ranges.length === 0) {
|
|
305
|
+
out.push({
|
|
306
|
+
'@type': 'OpeningHoursSpecification',
|
|
307
|
+
validFrom: e.date,
|
|
308
|
+
validThrough: e.date,
|
|
309
|
+
opens: '00:00',
|
|
310
|
+
closes: '00:00'
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
for (const r of e.ranges) {
|
|
315
|
+
out.push({
|
|
316
|
+
'@type': 'OpeningHoursSpecification',
|
|
317
|
+
validFrom: e.date,
|
|
318
|
+
validThrough: e.date,
|
|
319
|
+
opens: r.open,
|
|
320
|
+
closes: r.close
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored shape of an image slot. Seeds and fallbacks may pass a bare URL
|
|
3
|
+
* string; it is normalized to `{ url }`. A cleared slot stores `url: null`.
|
|
4
|
+
*/
|
|
5
|
+
export type CmsImageValue = {
|
|
6
|
+
url?: string | null;
|
|
7
|
+
alt?: string | null;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
};
|
|
11
|
+
export declare const normalizeImage: (raw: unknown) => CmsImageValue;
|
|
12
|
+
/** An image inside a structured value: `null` when there is no URL. */
|
|
13
|
+
export declare const asImage: (raw: unknown) => CmsImageValue | null;
|
|
14
|
+
/** A list of images, dropping entries without a URL. */
|
|
15
|
+
export declare const asImages: (raw: unknown) => CmsImageValue[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const normalizeImage = (raw) => {
|
|
2
|
+
if (raw == null)
|
|
3
|
+
return {};
|
|
4
|
+
if (typeof raw === 'string')
|
|
5
|
+
return { url: raw };
|
|
6
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
7
|
+
const r = raw;
|
|
8
|
+
const out = {};
|
|
9
|
+
if (typeof r.url === 'string')
|
|
10
|
+
out.url = r.url;
|
|
11
|
+
if (typeof r.alt === 'string')
|
|
12
|
+
out.alt = r.alt;
|
|
13
|
+
if (typeof r.width === 'number')
|
|
14
|
+
out.width = r.width;
|
|
15
|
+
if (typeof r.height === 'number')
|
|
16
|
+
out.height = r.height;
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
};
|
|
21
|
+
/** An image inside a structured value: `null` when there is no URL. */
|
|
22
|
+
export const asImage = (raw) => {
|
|
23
|
+
const img = normalizeImage(raw);
|
|
24
|
+
return img.url ? img : null;
|
|
25
|
+
};
|
|
26
|
+
/** A list of images, dropping entries without a URL. */
|
|
27
|
+
export const asImages = (raw) => Array.isArray(raw) ? raw.map(asImage).filter((i) => i !== null) : [];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured value shapes behind the editor components. Importing this
|
|
3
|
+
* module registers every schema, so the Locales panel can count translatable
|
|
4
|
+
* strings for any usage the manifest recorded.
|
|
5
|
+
*/
|
|
6
|
+
export type { FormField, FormFieldType, FormShape, ListValue } from './form.js';
|
|
7
|
+
export { defineForm } from './form.js';
|
|
8
|
+
export { normalizeLink, asLink, linkHref } from './link.js';
|
|
9
|
+
export type { CmsLinkValue } from './link.js';
|
|
10
|
+
export { normalizeImage, asImage, asImages } from './image.js';
|
|
11
|
+
export type { CmsImageValue } from './image.js';
|
|
12
|
+
export { cmsNav, navView, isActive } from './nav.js';
|
|
13
|
+
export type { CmsNavValue, NavItem, NavItemView } from './nav.js';
|
|
14
|
+
export { cmsHours, hoursView, formatTime, formatRanges, formatDate, dayNames, zonedNow, toOpeningHoursSpecification, DAY_KEYS, DEFAULT_HOURS_LABELS } from './hours.js';
|
|
15
|
+
export type { CmsHoursValue, DayKey, DayHours, HoursRange, HoursException, HoursLabels, HoursRow, HoursToday, HoursExceptionRow, HoursView } from './hours.js';
|
|
16
|
+
export { cmsContact, contactView, formatAddress, telHref, whatsappHref, toPostalAddress, DEFAULT_CONTACT_LABELS } from './contact.js';
|
|
17
|
+
export type { CmsContactValue, ContactAddress, ContactEmail, ContactLabels, ContactPhone, ContactView } from './contact.js';
|
|
18
|
+
export { cmsSocialLinks, socialLinksView, toSameAs, SOCIAL_PLATFORMS, SOCIAL_PLATFORM_NAMES } from './social-links.js';
|
|
19
|
+
export type { CmsSocialLinksValue, SocialLink, SocialLinkView, SocialPlatform } from './social-links.js';
|
|
20
|
+
export { cmsTeam, teamView } from './team.js';
|
|
21
|
+
export type { CmsTeamValue, TeamLink, TeamMember } from './team.js';
|
|
22
|
+
export { cmsTestimonials, testimonialsView, toReview } from './testimonials.js';
|
|
23
|
+
export type { CmsTestimonialsValue, Testimonial } from './testimonials.js';
|
|
24
|
+
export { cmsCollection, collectionView, collectionTags, formatCollectionPrice, toItemList } from './collection.js';
|
|
25
|
+
export type { CmsCollectionValue, CollectionDetail, CollectionItem, CollectionItemView, CollectionPrice, CollectionSlice } from './collection.js';
|
|
26
|
+
export { cmsPricing, pricingView, formatPrice } from './pricing.js';
|
|
27
|
+
export type { CmsPricingValue, PricingFeature, PricingTier, PricingTierView } from './pricing.js';
|
|
28
|
+
export { cmsFaq, faqView, toFaqPage } from './faq.js';
|
|
29
|
+
export type { CmsFaqValue, FaqItem } from './faq.js';
|
|
30
|
+
export { cmsStats, cmsSteps, cmsTimeline, cmsGallery, cmsLogos, cmsSchedule, logosView, scheduleByDay } from './presets.js';
|
|
31
|
+
export type { CmsStatsValue, StatItem, CmsStepsValue, StepItem, CmsTimelineValue, TimelineItem, CmsGalleryValue, GalleryItem, CmsLogosValue, LogoItem, LogoItemView, CmsScheduleValue, ScheduleItem } from './presets.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { defineForm } from './form.js';
|
|
2
|
+
export { normalizeLink, asLink, linkHref } from './link.js';
|
|
3
|
+
export { normalizeImage, asImage, asImages } from './image.js';
|
|
4
|
+
export { cmsNav, navView, isActive } from './nav.js';
|
|
5
|
+
export { cmsHours, hoursView, formatTime, formatRanges, formatDate, dayNames, zonedNow, toOpeningHoursSpecification, DAY_KEYS, DEFAULT_HOURS_LABELS } from './hours.js';
|
|
6
|
+
export { cmsContact, contactView, formatAddress, telHref, whatsappHref, toPostalAddress, DEFAULT_CONTACT_LABELS } from './contact.js';
|
|
7
|
+
export { cmsSocialLinks, socialLinksView, toSameAs, SOCIAL_PLATFORMS, SOCIAL_PLATFORM_NAMES } from './social-links.js';
|
|
8
|
+
export { cmsTeam, teamView } from './team.js';
|
|
9
|
+
export { cmsTestimonials, testimonialsView, toReview } from './testimonials.js';
|
|
10
|
+
export { cmsCollection, collectionView, collectionTags, formatCollectionPrice, toItemList } from './collection.js';
|
|
11
|
+
export { cmsPricing, pricingView, formatPrice } from './pricing.js';
|
|
12
|
+
export { cmsFaq, faqView, toFaqPage } from './faq.js';
|
|
13
|
+
export { cmsStats, cmsSteps, cmsTimeline, cmsGallery, cmsLogos, cmsSchedule, logosView, scheduleByDay } from './presets.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type CmsLinkValue = {
|
|
2
|
+
label?: string;
|
|
3
|
+
/** Raw URL — used when `routeId` is absent. */
|
|
4
|
+
href?: string;
|
|
5
|
+
/** SvelteKit route id, e.g. `/blog/[slug]`. Resolved at render time. */
|
|
6
|
+
routeId?: string;
|
|
7
|
+
params?: Record<string, string>;
|
|
8
|
+
newTab?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare const normalizeLink: (raw: unknown) => CmsLinkValue;
|
|
11
|
+
/** A link inside a structured value: `null` when nothing points anywhere. */
|
|
12
|
+
export declare const asLink: (raw: unknown) => CmsLinkValue | null;
|
|
13
|
+
/** The URL a link points at, or `fallback` when it points nowhere or the
|
|
14
|
+
* route cannot be resolved. */
|
|
15
|
+
export declare const linkHref: (link: CmsLinkValue | null | undefined, fallback?: string) => string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveRouteUrl } from '../../components/admin-bar/resolve-route.js';
|
|
2
|
+
import { isPlainObject } from '../structured.js';
|
|
3
|
+
export const normalizeLink = (raw) => {
|
|
4
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
5
|
+
return {};
|
|
6
|
+
const r = raw;
|
|
7
|
+
const out = {};
|
|
8
|
+
if (typeof r.label === 'string')
|
|
9
|
+
out.label = r.label;
|
|
10
|
+
if (typeof r.href === 'string')
|
|
11
|
+
out.href = r.href;
|
|
12
|
+
if (typeof r.routeId === 'string')
|
|
13
|
+
out.routeId = r.routeId;
|
|
14
|
+
if (r.params && typeof r.params === 'object' && !Array.isArray(r.params)) {
|
|
15
|
+
out.params = r.params;
|
|
16
|
+
}
|
|
17
|
+
if (typeof r.newTab === 'boolean')
|
|
18
|
+
out.newTab = r.newTab;
|
|
19
|
+
return out;
|
|
20
|
+
};
|
|
21
|
+
/** A link inside a structured value: `null` when nothing points anywhere. */
|
|
22
|
+
export const asLink = (raw) => {
|
|
23
|
+
if (!isPlainObject(raw))
|
|
24
|
+
return null;
|
|
25
|
+
const link = normalizeLink(raw);
|
|
26
|
+
return link.href || link.routeId ? link : null;
|
|
27
|
+
};
|
|
28
|
+
/** The URL a link points at, or `fallback` when it points nowhere or the
|
|
29
|
+
* route cannot be resolved. */
|
|
30
|
+
export const linkHref = (link, fallback = '') => {
|
|
31
|
+
if (!link)
|
|
32
|
+
return fallback;
|
|
33
|
+
if (link.routeId) {
|
|
34
|
+
try {
|
|
35
|
+
return resolveRouteUrl(link.routeId, link.params ?? {});
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return link.href ?? fallback;
|
|
42
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ListValue } from './form.js';
|
|
2
|
+
import { type CmsLinkValue } from './link.js';
|
|
3
|
+
export type NavItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
link: CmsLinkValue | null;
|
|
7
|
+
children: NavItem[];
|
|
8
|
+
};
|
|
9
|
+
export type CmsNavValue = ListValue<NavItem>;
|
|
10
|
+
export type NavItemView = {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
href: string;
|
|
14
|
+
newTab: boolean;
|
|
15
|
+
children: NavItemView[];
|
|
16
|
+
};
|
|
17
|
+
export declare const cmsNav: import("./form.js").FormShape<CmsNavValue>;
|
|
18
|
+
/** Items with resolved hrefs, dropping items that have no label. */
|
|
19
|
+
export declare const navView: (value: CmsNavValue) => NavItemView[];
|
|
20
|
+
/** Whether `item` (or one of its children) points at the current page. */
|
|
21
|
+
export declare const isActive: (item: NavItemView, url: URL | string) => boolean;
|