@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
|
@@ -7,7 +7,21 @@ export { default as CmsNumber } from './cms-number.svelte';
|
|
|
7
7
|
export { default as CmsDateTime } from './cms-date-time.svelte';
|
|
8
8
|
export { default as CmsLink } from './cms-link.svelte';
|
|
9
9
|
export type { CmsLinkValue, CmsLinkRenderProps } from './cms-link.svelte';
|
|
10
|
-
export { default as
|
|
10
|
+
export { default as CmsNav } from './cms-nav.svelte';
|
|
11
|
+
export { default as CmsHours } from './cms-hours.svelte';
|
|
12
|
+
export { default as CmsContact } from './cms-contact.svelte';
|
|
13
|
+
export { default as CmsSocialLinks } from './cms-social-links.svelte';
|
|
14
|
+
export { default as CmsCollection } from './cms-collection.svelte';
|
|
15
|
+
export { default as CmsTeam } from './cms-team.svelte';
|
|
16
|
+
export { default as CmsTestimonials } from './cms-testimonials.svelte';
|
|
17
|
+
export { default as CmsPricing } from './cms-pricing.svelte';
|
|
18
|
+
export { default as CmsFaq } from './cms-faq.svelte';
|
|
19
|
+
export { default as CmsStats } from './cms-stats.svelte';
|
|
20
|
+
export { default as CmsSteps } from './cms-steps.svelte';
|
|
21
|
+
export { default as CmsTimeline } from './cms-timeline.svelte';
|
|
22
|
+
export { default as CmsGallery } from './cms-gallery.svelte';
|
|
23
|
+
export { default as CmsLogos } from './cms-logos.svelte';
|
|
24
|
+
export { default as CmsSchedule } from './cms-schedule.svelte';
|
|
11
25
|
export { default as CmsEntries } from './cms-entries.svelte';
|
|
12
26
|
export { CMS_SCOPE, getCmsScope, getCmsValue } from './scope.js';
|
|
13
27
|
export type { CmsScope, CmsPayload, CmsEntry } from './scope.js';
|
|
@@ -6,6 +6,20 @@ export { default as CmsBoolean } from './cms-boolean.svelte';
|
|
|
6
6
|
export { default as CmsNumber } from './cms-number.svelte';
|
|
7
7
|
export { default as CmsDateTime } from './cms-date-time.svelte';
|
|
8
8
|
export { default as CmsLink } from './cms-link.svelte';
|
|
9
|
-
export { default as
|
|
9
|
+
export { default as CmsNav } from './cms-nav.svelte';
|
|
10
|
+
export { default as CmsHours } from './cms-hours.svelte';
|
|
11
|
+
export { default as CmsContact } from './cms-contact.svelte';
|
|
12
|
+
export { default as CmsSocialLinks } from './cms-social-links.svelte';
|
|
13
|
+
export { default as CmsCollection } from './cms-collection.svelte';
|
|
14
|
+
export { default as CmsTeam } from './cms-team.svelte';
|
|
15
|
+
export { default as CmsTestimonials } from './cms-testimonials.svelte';
|
|
16
|
+
export { default as CmsPricing } from './cms-pricing.svelte';
|
|
17
|
+
export { default as CmsFaq } from './cms-faq.svelte';
|
|
18
|
+
export { default as CmsStats } from './cms-stats.svelte';
|
|
19
|
+
export { default as CmsSteps } from './cms-steps.svelte';
|
|
20
|
+
export { default as CmsTimeline } from './cms-timeline.svelte';
|
|
21
|
+
export { default as CmsGallery } from './cms-gallery.svelte';
|
|
22
|
+
export { default as CmsLogos } from './cms-logos.svelte';
|
|
23
|
+
export { default as CmsSchedule } from './cms-schedule.svelte';
|
|
10
24
|
export { default as CmsEntries } from './cms-entries.svelte';
|
|
11
25
|
export { CMS_SCOPE, getCmsScope, getCmsValue } from './scope.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CmsCollection`: services, rooms, programmes, tours, projects, case
|
|
3
|
+
* studies. One fixed core plus a `details` key-value list for kind-specific
|
|
4
|
+
* facts. Lists only: an item's `link` covers "read more".
|
|
5
|
+
*/
|
|
6
|
+
import type { Tree } from '../path.js';
|
|
7
|
+
import { type ListValue } from './form.js';
|
|
8
|
+
import { type CmsImageValue } from './image.js';
|
|
9
|
+
import { type CmsLinkValue } from './link.js';
|
|
10
|
+
export type CollectionDetail = {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
value: string;
|
|
14
|
+
};
|
|
15
|
+
export type CollectionPrice = {
|
|
16
|
+
amount: number | null;
|
|
17
|
+
/** ISO 4217, e.g. `EUR`. */
|
|
18
|
+
currency: string;
|
|
19
|
+
/** `night`, `person`, `hour`… shown after the amount. */
|
|
20
|
+
period: string;
|
|
21
|
+
/** `from`, shown before the amount. */
|
|
22
|
+
prefix: string;
|
|
23
|
+
};
|
|
24
|
+
export type CollectionItem = {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
body: string;
|
|
29
|
+
image: CmsImageValue | null;
|
|
30
|
+
gallery: CmsImageValue[];
|
|
31
|
+
price: CollectionPrice;
|
|
32
|
+
duration: string;
|
|
33
|
+
link: CmsLinkValue | null;
|
|
34
|
+
tags: string[];
|
|
35
|
+
features: string[];
|
|
36
|
+
featured: boolean;
|
|
37
|
+
hidden: boolean;
|
|
38
|
+
details: CollectionDetail[];
|
|
39
|
+
};
|
|
40
|
+
export type CmsCollectionValue = ListValue<CollectionItem>;
|
|
41
|
+
export type CollectionItemView = CollectionItem & {
|
|
42
|
+
href: string | null;
|
|
43
|
+
};
|
|
44
|
+
export declare const cmsCollection: import("./form.js").FormShape<CmsCollectionValue>;
|
|
45
|
+
export type CollectionSlice = {
|
|
46
|
+
/** At most this many items. */
|
|
47
|
+
limit?: number;
|
|
48
|
+
/** Only featured items. */
|
|
49
|
+
featured?: boolean;
|
|
50
|
+
/** Only items carrying this tag. */
|
|
51
|
+
tag?: string;
|
|
52
|
+
};
|
|
53
|
+
/** Visible items (not hidden, with a title), optionally sliced. */
|
|
54
|
+
export declare const collectionView: (value: CmsCollectionValue, slice?: CollectionSlice) => CollectionItemView[];
|
|
55
|
+
/** Every tag in use, in first-seen order. */
|
|
56
|
+
export declare const collectionTags: (value: CmsCollectionValue) => string[];
|
|
57
|
+
/** `from €120 / night`, or empty when there is no amount. */
|
|
58
|
+
export declare const formatCollectionPrice: (price: CollectionPrice, locale: string) => string;
|
|
59
|
+
/** A schema.org `ItemList` of the visible items. */
|
|
60
|
+
export declare const toItemList: (value: CmsCollectionValue, baseUrl?: string) => Tree;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { asBoolean, asItems, asNumber, asString, asStrings, isPlainObject } from '../structured.js';
|
|
2
|
+
import { defineForm } from './form.js';
|
|
3
|
+
import { asImage, asImages } from './image.js';
|
|
4
|
+
import { asLink, linkHref } from './link.js';
|
|
5
|
+
export const cmsCollection = defineForm({
|
|
6
|
+
component: 'CmsCollection',
|
|
7
|
+
label: 'Collection',
|
|
8
|
+
version: 1,
|
|
9
|
+
translatable: [],
|
|
10
|
+
items: {
|
|
11
|
+
key: 'items',
|
|
12
|
+
translatable: [
|
|
13
|
+
'title',
|
|
14
|
+
'summary',
|
|
15
|
+
'body',
|
|
16
|
+
'duration',
|
|
17
|
+
'tags',
|
|
18
|
+
'features',
|
|
19
|
+
'price.period',
|
|
20
|
+
'price.prefix'
|
|
21
|
+
],
|
|
22
|
+
items: { key: 'details', translatable: ['label', 'value'] }
|
|
23
|
+
},
|
|
24
|
+
normalize: (raw) => {
|
|
25
|
+
const items = Array.isArray(raw) ? raw : isPlainObject(raw) ? raw.items : [];
|
|
26
|
+
return {
|
|
27
|
+
v: 1,
|
|
28
|
+
items: asItems(items, (r) => {
|
|
29
|
+
const p = isPlainObject(r.price) ? r.price : {};
|
|
30
|
+
return {
|
|
31
|
+
title: asString(r.title),
|
|
32
|
+
summary: asString(r.summary),
|
|
33
|
+
body: asString(r.body),
|
|
34
|
+
image: asImage(r.image),
|
|
35
|
+
gallery: asImages(r.gallery),
|
|
36
|
+
price: {
|
|
37
|
+
amount: asNumber(p.amount),
|
|
38
|
+
currency: asString(p.currency),
|
|
39
|
+
period: asString(p.period),
|
|
40
|
+
prefix: asString(p.prefix)
|
|
41
|
+
},
|
|
42
|
+
duration: asString(r.duration),
|
|
43
|
+
link: asLink(r.link),
|
|
44
|
+
tags: asStrings(r.tags).filter((t) => t !== ''),
|
|
45
|
+
features: asStrings(r.features).filter((f) => f !== ''),
|
|
46
|
+
featured: asBoolean(r.featured),
|
|
47
|
+
hidden: asBoolean(r.hidden),
|
|
48
|
+
details: asItems(r.details, (d) => ({
|
|
49
|
+
label: asString(d.label),
|
|
50
|
+
value: asString(d.value)
|
|
51
|
+
}))
|
|
52
|
+
};
|
|
53
|
+
})
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
empty: () => ({ v: 1, items: [] }),
|
|
57
|
+
fields: [
|
|
58
|
+
{
|
|
59
|
+
key: 'items',
|
|
60
|
+
label: 'Items',
|
|
61
|
+
type: 'list',
|
|
62
|
+
itemLabel: 'item',
|
|
63
|
+
titleKey: 'title',
|
|
64
|
+
blank: () => ({
|
|
65
|
+
title: '',
|
|
66
|
+
summary: '',
|
|
67
|
+
body: '',
|
|
68
|
+
image: null,
|
|
69
|
+
gallery: [],
|
|
70
|
+
price: { amount: null, currency: '', period: '', prefix: '' },
|
|
71
|
+
duration: '',
|
|
72
|
+
link: null,
|
|
73
|
+
tags: [],
|
|
74
|
+
features: [],
|
|
75
|
+
featured: false,
|
|
76
|
+
hidden: false,
|
|
77
|
+
details: []
|
|
78
|
+
}),
|
|
79
|
+
fields: [
|
|
80
|
+
{ key: 'title', label: 'Title', type: 'text', group: 'Details' },
|
|
81
|
+
{ key: 'summary', label: 'Summary', type: 'long-string', group: 'Details' },
|
|
82
|
+
{ key: 'body', label: 'Body', type: 'html', group: 'Details' },
|
|
83
|
+
{
|
|
84
|
+
key: 'duration',
|
|
85
|
+
label: 'Duration',
|
|
86
|
+
type: 'text',
|
|
87
|
+
half: true,
|
|
88
|
+
placeholder: '3 hours',
|
|
89
|
+
group: 'Details'
|
|
90
|
+
},
|
|
91
|
+
{ key: 'link', label: 'Link', type: 'link', group: 'Details' },
|
|
92
|
+
{
|
|
93
|
+
key: 'tags',
|
|
94
|
+
label: 'Tags',
|
|
95
|
+
type: 'strings',
|
|
96
|
+
placeholder: 'One per line',
|
|
97
|
+
group: 'Details'
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: 'features',
|
|
101
|
+
label: 'Features',
|
|
102
|
+
type: 'strings',
|
|
103
|
+
placeholder: 'One per line',
|
|
104
|
+
group: 'Details'
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: 'details',
|
|
108
|
+
label: 'Details',
|
|
109
|
+
type: 'list',
|
|
110
|
+
itemLabel: 'detail',
|
|
111
|
+
titleKey: 'label',
|
|
112
|
+
group: 'Details',
|
|
113
|
+
blank: () => ({ label: '', value: '' }),
|
|
114
|
+
fields: [
|
|
115
|
+
{ key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Sleeps' },
|
|
116
|
+
{ key: 'value', label: 'Value', type: 'text', placeholder: '4' }
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
{ key: 'featured', label: 'Featured', type: 'boolean', group: 'Details' },
|
|
120
|
+
{ key: 'hidden', label: 'Hidden', type: 'boolean', group: 'Details' },
|
|
121
|
+
{ key: 'image', label: 'Image', type: 'image', group: 'Media' },
|
|
122
|
+
{ key: 'gallery', label: 'Gallery', type: 'images', group: 'Media' },
|
|
123
|
+
{
|
|
124
|
+
key: 'price.prefix',
|
|
125
|
+
label: 'Prefix',
|
|
126
|
+
type: 'text',
|
|
127
|
+
half: true,
|
|
128
|
+
placeholder: 'from',
|
|
129
|
+
group: 'Pricing'
|
|
130
|
+
},
|
|
131
|
+
{ key: 'price.amount', label: 'Amount', type: 'number', group: 'Pricing' },
|
|
132
|
+
{
|
|
133
|
+
key: 'price.currency',
|
|
134
|
+
label: 'Currency',
|
|
135
|
+
type: 'text',
|
|
136
|
+
half: true,
|
|
137
|
+
placeholder: 'EUR',
|
|
138
|
+
group: 'Pricing'
|
|
139
|
+
},
|
|
140
|
+
{ key: 'price.period', label: 'Per', type: 'text', placeholder: 'night', group: 'Pricing' }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
});
|
|
145
|
+
/** Visible items (not hidden, with a title), optionally sliced. */
|
|
146
|
+
export const collectionView = (value, slice = {}) => {
|
|
147
|
+
let items = value.items.filter((i) => !i.hidden && i.title !== '');
|
|
148
|
+
if (slice.featured)
|
|
149
|
+
items = items.filter((i) => i.featured);
|
|
150
|
+
if (slice.tag)
|
|
151
|
+
items = items.filter((i) => i.tags.includes(slice.tag));
|
|
152
|
+
if (slice.limit !== undefined && slice.limit >= 0)
|
|
153
|
+
items = items.slice(0, slice.limit);
|
|
154
|
+
return items.map((i) => ({ ...i, href: i.link ? linkHref(i.link) || null : null }));
|
|
155
|
+
};
|
|
156
|
+
/** Every tag in use, in first-seen order. */
|
|
157
|
+
export const collectionTags = (value) => {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const item of value.items)
|
|
160
|
+
for (const t of item.tags)
|
|
161
|
+
if (!out.includes(t))
|
|
162
|
+
out.push(t);
|
|
163
|
+
return out;
|
|
164
|
+
};
|
|
165
|
+
/** `from €120 / night`, or empty when there is no amount. */
|
|
166
|
+
export const formatCollectionPrice = (price, locale) => {
|
|
167
|
+
if (price.amount === null)
|
|
168
|
+
return '';
|
|
169
|
+
let amount;
|
|
170
|
+
try {
|
|
171
|
+
amount = price.currency
|
|
172
|
+
? new Intl.NumberFormat(locale, {
|
|
173
|
+
style: 'currency',
|
|
174
|
+
currency: price.currency,
|
|
175
|
+
maximumFractionDigits: Number.isInteger(price.amount) ? 0 : 2
|
|
176
|
+
}).format(price.amount)
|
|
177
|
+
: new Intl.NumberFormat(locale).format(price.amount);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
amount = `${price.currency} ${price.amount}`.trim();
|
|
181
|
+
}
|
|
182
|
+
return [price.prefix, amount, price.period ? `/ ${price.period}` : ''].filter(Boolean).join(' ');
|
|
183
|
+
};
|
|
184
|
+
/** A schema.org `ItemList` of the visible items. */
|
|
185
|
+
export const toItemList = (value, baseUrl = '') => ({
|
|
186
|
+
'@context': 'https://schema.org',
|
|
187
|
+
'@type': 'ItemList',
|
|
188
|
+
itemListElement: collectionView(value).map((i, n) => {
|
|
189
|
+
const item = { '@type': 'ListItem', position: n + 1, name: i.title };
|
|
190
|
+
if (i.href)
|
|
191
|
+
item.url = baseUrl + i.href;
|
|
192
|
+
return item;
|
|
193
|
+
})
|
|
194
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CmsContact`: where and how to reach the business. Labels translate;
|
|
3
|
+
* addresses and numbers do not. `contactView` adds the hrefs and the
|
|
4
|
+
* formatted address a template renders.
|
|
5
|
+
*/
|
|
6
|
+
import type { Tree } from '../path.js';
|
|
7
|
+
export type ContactPhone = {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
number: string;
|
|
11
|
+
};
|
|
12
|
+
export type ContactEmail = {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
address: string;
|
|
16
|
+
};
|
|
17
|
+
export type ContactAddress = {
|
|
18
|
+
lines: string[];
|
|
19
|
+
locality: string;
|
|
20
|
+
region: string;
|
|
21
|
+
postcode: string;
|
|
22
|
+
country: string;
|
|
23
|
+
};
|
|
24
|
+
export type ContactLabels = {
|
|
25
|
+
address: string;
|
|
26
|
+
phone: string;
|
|
27
|
+
email: string;
|
|
28
|
+
whatsapp: string;
|
|
29
|
+
directions: string;
|
|
30
|
+
};
|
|
31
|
+
export type CmsContactValue = {
|
|
32
|
+
v: 1;
|
|
33
|
+
name: string;
|
|
34
|
+
address: ContactAddress;
|
|
35
|
+
phones: ContactPhone[];
|
|
36
|
+
emails: ContactEmail[];
|
|
37
|
+
whatsapp: string;
|
|
38
|
+
directionsUrl: string;
|
|
39
|
+
geo: {
|
|
40
|
+
lat: number;
|
|
41
|
+
lng: number;
|
|
42
|
+
} | null;
|
|
43
|
+
labels: ContactLabels;
|
|
44
|
+
};
|
|
45
|
+
export declare const DEFAULT_CONTACT_LABELS: ContactLabels;
|
|
46
|
+
export declare const cmsContact: import("./form.js").FormShape<CmsContactValue>;
|
|
47
|
+
export declare const telHref: (number: string) => string;
|
|
48
|
+
export declare const whatsappHref: (number: string) => string;
|
|
49
|
+
/** Address lines in the country's conventional order, empty parts dropped. */
|
|
50
|
+
export declare const formatAddress: (address: ContactAddress) => string[];
|
|
51
|
+
export type ContactView = Omit<CmsContactValue, 'phones' | 'emails'> & {
|
|
52
|
+
phones: Array<ContactPhone & {
|
|
53
|
+
href: string;
|
|
54
|
+
}>;
|
|
55
|
+
emails: Array<ContactEmail & {
|
|
56
|
+
href: string;
|
|
57
|
+
}>;
|
|
58
|
+
/** First phone's `tel:` href, or `null`. */
|
|
59
|
+
telHref: string | null;
|
|
60
|
+
/** First email's `mailto:`, or `null`. */
|
|
61
|
+
mailto: string | null;
|
|
62
|
+
whatsappHref: string | null;
|
|
63
|
+
/** `directionsUrl`, else a Google Maps link from `geo` or the address. */
|
|
64
|
+
mapsHref: string | null;
|
|
65
|
+
formattedAddress: string[];
|
|
66
|
+
hasAddress: boolean;
|
|
67
|
+
};
|
|
68
|
+
export declare const contactView: (value: CmsContactValue) => ContactView;
|
|
69
|
+
/** A schema.org `PostalAddress` node, or `null` without an address. */
|
|
70
|
+
export declare const toPostalAddress: (value: CmsContactValue) => Tree | null;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { asItems, asNumber, asString, asStrings, isPlainObject } from '../structured.js';
|
|
2
|
+
import { defineForm } from './form.js';
|
|
3
|
+
export const DEFAULT_CONTACT_LABELS = {
|
|
4
|
+
address: 'Address',
|
|
5
|
+
phone: 'Phone',
|
|
6
|
+
email: 'Email',
|
|
7
|
+
whatsapp: 'WhatsApp',
|
|
8
|
+
directions: 'Directions'
|
|
9
|
+
};
|
|
10
|
+
export const cmsContact = defineForm({
|
|
11
|
+
component: 'CmsContact',
|
|
12
|
+
label: 'Contact details',
|
|
13
|
+
version: 1,
|
|
14
|
+
translatable: [
|
|
15
|
+
'labels.address',
|
|
16
|
+
'labels.phone',
|
|
17
|
+
'labels.email',
|
|
18
|
+
'labels.whatsapp',
|
|
19
|
+
'labels.directions'
|
|
20
|
+
],
|
|
21
|
+
items: [
|
|
22
|
+
{ key: 'phones', translatable: ['label'] },
|
|
23
|
+
{ key: 'emails', translatable: ['label'] }
|
|
24
|
+
],
|
|
25
|
+
normalize: (raw) => {
|
|
26
|
+
const r = isPlainObject(raw) ? raw : {};
|
|
27
|
+
const a = isPlainObject(r.address) ? r.address : {};
|
|
28
|
+
const g = isPlainObject(r.geo) ? r.geo : null;
|
|
29
|
+
const lat = g ? asNumber(g.lat) : null;
|
|
30
|
+
const lng = g ? asNumber(g.lng) : null;
|
|
31
|
+
const labels = isPlainObject(r.labels) ? r.labels : {};
|
|
32
|
+
return {
|
|
33
|
+
v: 1,
|
|
34
|
+
name: asString(r.name),
|
|
35
|
+
address: {
|
|
36
|
+
lines: asStrings(a.lines).filter((l) => l !== ''),
|
|
37
|
+
locality: asString(a.locality),
|
|
38
|
+
region: asString(a.region),
|
|
39
|
+
postcode: asString(a.postcode),
|
|
40
|
+
country: asString(a.country)
|
|
41
|
+
},
|
|
42
|
+
phones: asItems(r.phones, (p) => ({
|
|
43
|
+
label: asString(p.label),
|
|
44
|
+
number: asString(p.number)
|
|
45
|
+
})),
|
|
46
|
+
emails: asItems(r.emails, (e) => ({
|
|
47
|
+
label: asString(e.label),
|
|
48
|
+
address: asString(e.address)
|
|
49
|
+
})),
|
|
50
|
+
whatsapp: asString(r.whatsapp),
|
|
51
|
+
directionsUrl: asString(r.directionsUrl),
|
|
52
|
+
geo: lat !== null && lng !== null ? { lat, lng } : null,
|
|
53
|
+
labels: {
|
|
54
|
+
address: asString(labels.address, DEFAULT_CONTACT_LABELS.address),
|
|
55
|
+
phone: asString(labels.phone, DEFAULT_CONTACT_LABELS.phone),
|
|
56
|
+
email: asString(labels.email, DEFAULT_CONTACT_LABELS.email),
|
|
57
|
+
whatsapp: asString(labels.whatsapp, DEFAULT_CONTACT_LABELS.whatsapp),
|
|
58
|
+
directions: asString(labels.directions, DEFAULT_CONTACT_LABELS.directions)
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
empty: () => ({
|
|
63
|
+
v: 1,
|
|
64
|
+
name: '',
|
|
65
|
+
address: { lines: [], locality: '', region: '', postcode: '', country: '' },
|
|
66
|
+
phones: [],
|
|
67
|
+
emails: [],
|
|
68
|
+
whatsapp: '',
|
|
69
|
+
directionsUrl: '',
|
|
70
|
+
geo: null,
|
|
71
|
+
labels: { ...DEFAULT_CONTACT_LABELS }
|
|
72
|
+
}),
|
|
73
|
+
fields: [
|
|
74
|
+
{ key: 'name', label: 'Business name', type: 'text' },
|
|
75
|
+
{
|
|
76
|
+
key: 'address.lines',
|
|
77
|
+
label: 'Street address',
|
|
78
|
+
type: 'strings',
|
|
79
|
+
placeholder: 'One line per row'
|
|
80
|
+
},
|
|
81
|
+
{ key: 'address.locality', label: 'City', type: 'text', half: true },
|
|
82
|
+
{ key: 'address.region', label: 'Region', type: 'text' },
|
|
83
|
+
{ key: 'address.postcode', label: 'Postcode', type: 'text', half: true },
|
|
84
|
+
{ key: 'address.country', label: 'Country', type: 'text' },
|
|
85
|
+
{
|
|
86
|
+
key: 'phones',
|
|
87
|
+
label: 'Phone numbers',
|
|
88
|
+
type: 'list',
|
|
89
|
+
itemLabel: 'number',
|
|
90
|
+
titleKey: 'label',
|
|
91
|
+
blank: () => ({ label: '', number: '' }),
|
|
92
|
+
fields: [
|
|
93
|
+
{ key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Front desk' },
|
|
94
|
+
{ key: 'number', label: 'Number', type: 'text', placeholder: '+34 600 000 000' }
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: 'emails',
|
|
99
|
+
label: 'Email addresses',
|
|
100
|
+
type: 'list',
|
|
101
|
+
itemLabel: 'address',
|
|
102
|
+
titleKey: 'label',
|
|
103
|
+
blank: () => ({ label: '', address: '' }),
|
|
104
|
+
fields: [
|
|
105
|
+
{ key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Bookings' },
|
|
106
|
+
{ key: 'address', label: 'Address', type: 'text', placeholder: 'hello@example.com' }
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
{ key: 'whatsapp', label: 'WhatsApp number', type: 'text', group: 'Map & links' },
|
|
110
|
+
{ key: 'directionsUrl', label: 'Directions URL', type: 'url', group: 'Map & links' },
|
|
111
|
+
{ key: 'geo.lat', label: 'Latitude', type: 'number', half: true, group: 'Map & links' },
|
|
112
|
+
{ key: 'geo.lng', label: 'Longitude', type: 'number', group: 'Map & links' },
|
|
113
|
+
{ key: 'labels.address', label: '“Address”', type: 'text', half: true, group: 'Labels' },
|
|
114
|
+
{ key: 'labels.phone', label: '“Phone”', type: 'text', group: 'Labels' },
|
|
115
|
+
{ key: 'labels.email', label: '“Email”', type: 'text', half: true, group: 'Labels' },
|
|
116
|
+
{ key: 'labels.whatsapp', label: '“WhatsApp”', type: 'text', group: 'Labels' },
|
|
117
|
+
{ key: 'labels.directions', label: '“Directions”', type: 'text', group: 'Labels' }
|
|
118
|
+
]
|
|
119
|
+
});
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Derived view
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
export const telHref = (number) => `tel:${number.replace(/[^\d+]/g, '')}`;
|
|
124
|
+
export const whatsappHref = (number) => `https://wa.me/${number.replace(/[^\d]/g, '')}`;
|
|
125
|
+
/** Countries that write the postcode before the locality. Matched on ISO
|
|
126
|
+
* code or English name, case-insensitively. */
|
|
127
|
+
const POSTCODE_FIRST = new Set([
|
|
128
|
+
'es',
|
|
129
|
+
'spain',
|
|
130
|
+
'fr',
|
|
131
|
+
'france',
|
|
132
|
+
'de',
|
|
133
|
+
'germany',
|
|
134
|
+
'it',
|
|
135
|
+
'italy',
|
|
136
|
+
'pt',
|
|
137
|
+
'portugal',
|
|
138
|
+
'nl',
|
|
139
|
+
'netherlands',
|
|
140
|
+
'be',
|
|
141
|
+
'belgium',
|
|
142
|
+
'at',
|
|
143
|
+
'austria',
|
|
144
|
+
'ch',
|
|
145
|
+
'switzerland',
|
|
146
|
+
'se',
|
|
147
|
+
'sweden',
|
|
148
|
+
'no',
|
|
149
|
+
'norway',
|
|
150
|
+
'dk',
|
|
151
|
+
'denmark',
|
|
152
|
+
'fi',
|
|
153
|
+
'finland',
|
|
154
|
+
'pl',
|
|
155
|
+
'poland',
|
|
156
|
+
'cz',
|
|
157
|
+
'czechia',
|
|
158
|
+
'mx',
|
|
159
|
+
'mexico'
|
|
160
|
+
]);
|
|
161
|
+
/** Address lines in the country's conventional order, empty parts dropped. */
|
|
162
|
+
export const formatAddress = (address) => {
|
|
163
|
+
const { lines, locality, region, postcode, country } = address;
|
|
164
|
+
const postcodeFirst = POSTCODE_FIRST.has(country.trim().toLowerCase());
|
|
165
|
+
const cityLine = postcodeFirst
|
|
166
|
+
? [postcode, locality].filter(Boolean).join(' ') + (region ? ` (${region})` : '')
|
|
167
|
+
: [[locality, region].filter(Boolean).join(', '), postcode].filter(Boolean).join(' ');
|
|
168
|
+
return [...lines, cityLine, country].map((l) => l.trim()).filter((l) => l !== '');
|
|
169
|
+
};
|
|
170
|
+
export const contactView = (value) => {
|
|
171
|
+
const phones = value.phones
|
|
172
|
+
.filter((p) => p.number !== '')
|
|
173
|
+
.map((p) => ({ ...p, href: telHref(p.number) }));
|
|
174
|
+
const emails = value.emails
|
|
175
|
+
.filter((e) => e.address !== '')
|
|
176
|
+
.map((e) => ({ ...e, href: `mailto:${e.address}` }));
|
|
177
|
+
const formattedAddress = formatAddress(value.address);
|
|
178
|
+
let mapsHref = value.directionsUrl || null;
|
|
179
|
+
if (!mapsHref && value.geo) {
|
|
180
|
+
mapsHref = `https://www.google.com/maps/search/?api=1&query=${value.geo.lat},${value.geo.lng}`;
|
|
181
|
+
}
|
|
182
|
+
if (!mapsHref && formattedAddress.length > 0) {
|
|
183
|
+
mapsHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(formattedAddress.join(', '))}`;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...value,
|
|
187
|
+
phones,
|
|
188
|
+
emails,
|
|
189
|
+
telHref: phones[0]?.href ?? null,
|
|
190
|
+
mailto: emails[0]?.href ?? null,
|
|
191
|
+
whatsappHref: value.whatsapp ? whatsappHref(value.whatsapp) : null,
|
|
192
|
+
mapsHref,
|
|
193
|
+
formattedAddress,
|
|
194
|
+
hasAddress: formattedAddress.length > 0
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
/** A schema.org `PostalAddress` node, or `null` without an address. */
|
|
198
|
+
export const toPostalAddress = (value) => {
|
|
199
|
+
const a = value.address;
|
|
200
|
+
if (a.lines.length === 0 && !a.locality && !a.postcode && !a.country)
|
|
201
|
+
return null;
|
|
202
|
+
const out = { '@type': 'PostalAddress' };
|
|
203
|
+
if (a.lines.length)
|
|
204
|
+
out.streetAddress = a.lines.join(', ');
|
|
205
|
+
if (a.locality)
|
|
206
|
+
out.addressLocality = a.locality;
|
|
207
|
+
if (a.region)
|
|
208
|
+
out.addressRegion = a.region;
|
|
209
|
+
if (a.postcode)
|
|
210
|
+
out.postalCode = a.postcode;
|
|
211
|
+
if (a.country)
|
|
212
|
+
out.addressCountry = a.country;
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ListValue } from './form.js';
|
|
2
|
+
export type FaqItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
question: string;
|
|
5
|
+
answer: string;
|
|
6
|
+
};
|
|
7
|
+
export type CmsFaqValue = ListValue<FaqItem>;
|
|
8
|
+
export declare const cmsFaq: import("./form.js").FormShape<CmsFaqValue>;
|
|
9
|
+
/** Questions that have both a question and an answer. */
|
|
10
|
+
export declare const faqView: (value: CmsFaqValue) => FaqItem[];
|
|
11
|
+
/** A schema.org `FAQPage` node for `<script type="application/ld+json">`. */
|
|
12
|
+
export declare const toFaqPage: (value: CmsFaqValue) => Record<string, unknown>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** `CmsFaq`: questions with rich answers, plus FAQPage JSON-LD. */
|
|
2
|
+
import { asItems, asString, isPlainObject } from '../structured.js';
|
|
3
|
+
import { defineForm } from './form.js';
|
|
4
|
+
export const cmsFaq = defineForm({
|
|
5
|
+
component: 'CmsFaq',
|
|
6
|
+
label: 'FAQ',
|
|
7
|
+
version: 1,
|
|
8
|
+
translatable: [],
|
|
9
|
+
items: { key: 'items', translatable: ['question', 'answer'] },
|
|
10
|
+
normalize: (raw) => {
|
|
11
|
+
const items = Array.isArray(raw) ? raw : isPlainObject(raw) ? raw.items : [];
|
|
12
|
+
return {
|
|
13
|
+
v: 1,
|
|
14
|
+
items: asItems(items, (r) => ({
|
|
15
|
+
question: asString(r.question),
|
|
16
|
+
answer: asString(r.answer)
|
|
17
|
+
}))
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
empty: () => ({ v: 1, items: [] }),
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
key: 'items',
|
|
24
|
+
label: 'Questions',
|
|
25
|
+
type: 'list',
|
|
26
|
+
itemLabel: 'question',
|
|
27
|
+
titleKey: 'question',
|
|
28
|
+
blank: () => ({ question: '', answer: '' }),
|
|
29
|
+
fields: [
|
|
30
|
+
{ key: 'question', label: 'Question', type: 'text' },
|
|
31
|
+
{ key: 'answer', label: 'Answer', type: 'html' }
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
const stripTags = (html) => html.replace(/<[^>]*>/g, '').trim();
|
|
37
|
+
/** Questions that have both a question and an answer. */
|
|
38
|
+
export const faqView = (value) => value.items.filter((i) => i.question !== '' && stripTags(i.answer) !== '');
|
|
39
|
+
/** A schema.org `FAQPage` node for `<script type="application/ld+json">`. */
|
|
40
|
+
export const toFaqPage = (value) => ({
|
|
41
|
+
'@context': 'https://schema.org',
|
|
42
|
+
'@type': 'FAQPage',
|
|
43
|
+
mainEntity: faqView(value).map((i) => ({
|
|
44
|
+
'@type': 'Question',
|
|
45
|
+
name: i.question,
|
|
46
|
+
acceptedAnswer: { '@type': 'Answer', text: i.answer }
|
|
47
|
+
}))
|
|
48
|
+
});
|