@velastack/cms 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -317,6 +317,8 @@ Hours, navigation, a team, a price list: one value with a fixed shape, edited in
317
317
 
318
318
  Templates use only these exports; there is no open item schema. A shape outside the presets is a new preset in the package. `fallback` takes the full value or a bare item list and is normalized like a stored value, so a hand-written seed with missing fields is safe. Prices, times, dates and day names format in the visitor's locale; `isOpenNow` is computed when the page renders, so a prerendered page shows the state at build time.
319
319
 
320
+ **Prices are in the site's currency, formatted for the business's country.** A business has one currency, the one it prices in and is paid in, declared as a `currency` field in a Site Options group (see [Site options](#site-options)), and one country (`country`, ISO 3166-1 alpha-2). `CmsCollection` fills the currency into each item's `price`, and `CmsPricing` uses it when the block sets none, so a template formats `item.price` or reads `priceText` without knowing about it; `siteCurrency(cms.site)` reads it for anything else. Format with `siteLocale(cms.site, locale)`, the page's language in the business's country: a bare `es` shows MXN as `360,00 MXN`, `es-MX` as `$360.00`. There is no per-item currency in the editor; an item written with one before this is still read as written.
321
+
320
322
  Every value type (`CmsHoursValue`, `NavItem`, …), schema (`cmsHours`, …) and JSON-LD helper is exported from the package root.
321
323
 
322
324
  ### Building one
@@ -498,7 +500,8 @@ A cart whose checkout is a WhatsApp chat. Visitors add items with `whatsappCart.
498
500
  </CmsWhatsAppCart>
499
501
  ```
500
502
 
501
- - **One currency, the business's own.** Every price is in it, and the order and message are too. It comes from the `currency` site option (declare it in `createCms({ site })`), or the `currency` prop. `formatPrice` formats an amount in the page's language, so an English page shows MXN as `MX$5.50` and a Spanish one as `$5.50`.
503
+ - **One currency, the business's own.** Every price is in it, and the order and message are too. It comes from the `currency` Site Option (a `currency` field in any group of `createCms({ site })`), or the `currency` prop. `formatPrice` formats an amount in the page's language in the business's country (`siteLocale`), so on a Mexican site an English page shows MXN as `MX$5.50` and a Spanish one as `$5.50`.
504
+ - **The number** is the field's own setting, or the `number` prop when the template already keeps it in its contact details (`<CmsContact>`'s `whatsapp`), so an editor enters it once.
502
505
  - **Prices are optional**, and written as an editor types them (`4.5`). `cart.subtotal` exists only when every item has a price; otherwise the business quotes the total in the chat.
503
506
  - **The message is the editor's**, set from the pencil like `CmsWhatsAppForm`'s, with `{ref}`, `{items}`, `{total}`, `{name}`, `{phone}` and `{note}`. Until one is set, a default lists the items, the total and the customer. Lines an empty placeholder leaves blank are dropped.
504
507
  - **After checkout**, `placed` holds the order's reference and chat link until `cart.clear()`. Adding an item starts the next order.
@@ -653,7 +656,7 @@ editable page metadata (cms.metadata)
653
656
 
654
657
  ### Site options
655
658
 
656
- `createCms({ site })` declares the **Site Options** panel: project-wide values that never render on the page — the schema.org type (`enum`), the default share image (`image`), which locales are enabled (`boolean`). Field types are `text`, `markdown`, `number`, `datetime`, `url`, `color`, `image`, `enum` (with `values`) and `boolean`. Anything a visitor sees belongs in the root layout scope, edited in place with `scope="root"`; the site tree is one non-localised tree per project.
659
+ `createCms({ site })` declares the **Site Options** panel: project-wide values that never render on the page — the schema.org type (`enum`), the default share image (`image`), which locales are enabled (`boolean`), the business's currency and country (`text`, ISO 4217 and ISO 3166-1 alpha-2, in a group such as `business`: `siteCurrency`, `siteCountry` and `siteLocale` find a `currency` or `country` field in any group). Field types are `text`, `markdown`, `number`, `datetime`, `url`, `color`, `image`, `enum` (with `values`) and `boolean`. Anything a visitor sees belongs in the root layout scope, edited in place with `scope="root"`; the site tree is one non-localised tree per project.
657
660
 
658
661
  ## Adapters
659
662
 
@@ -10,6 +10,8 @@
10
10
  <script lang="ts">
11
11
  import type { Snippet } from 'svelte';
12
12
  import Structured from './cms-structured.svelte';
13
+ import { cms } from './cms-store.svelte.js';
14
+ import { siteCurrency } from '../../core/site-currency.js';
13
15
  import {
14
16
  cmsCollection,
15
17
  collectionView,
@@ -33,6 +35,8 @@
33
35
  children: Snippet<[CollectionItemView[]]>;
34
36
  };
35
37
  let { name, scope, fallback, value, limit, featured, tag, children }: Props = $props();
38
+ // Prices are in the site's currency unless an item still carries its own.
39
+ const currency = $derived(siteCurrency(cms.site));
36
40
  </script>
37
41
 
38
42
  <Structured
@@ -41,6 +45,6 @@
41
45
  {scope}
42
46
  {fallback}
43
47
  {value}
44
- view={(v: CmsCollectionValue) => collectionView(v, { limit, featured, tag })}
48
+ view={(v: CmsCollectionValue) => collectionView(v, { limit, featured, tag }, currency)}
45
49
  {children}
46
50
  />
@@ -6,7 +6,8 @@
6
6
  <script lang="ts">
7
7
  import type { Snippet } from 'svelte';
8
8
  import Structured from './cms-structured.svelte';
9
- import { cmsStore } from './cms-store.svelte.js';
9
+ import { cms, cmsStore } from './cms-store.svelte.js';
10
+ import { siteCurrency, siteLocale } from '../../core/site-currency.js';
10
11
  import {
11
12
  cmsPricing,
12
13
  pricingView,
@@ -22,7 +23,10 @@
22
23
  children: Snippet<[PricingTierView[]]>;
23
24
  };
24
25
  let { name, scope, fallback, value, children }: Props = $props();
25
- const locale = $derived(cmsStore.activeLocale || 'en');
26
+ // The page's language in the business's country, and the site's currency
27
+ // unless the block sets one of its own.
28
+ const locale = $derived(siteLocale(cms.site, cmsStore.activeLocale || 'en') ?? 'en');
29
+ const currency = $derived(siteCurrency(cms.site));
26
30
  </script>
27
31
 
28
32
  <Structured
@@ -31,6 +35,6 @@
31
35
  {scope}
32
36
  {fallback}
33
37
  {value}
34
- view={(v: CmsPricingValue) => pricingView(v, locale)}
38
+ view={(v: CmsPricingValue) => pricingView(v, locale, currency)}
35
39
  {children}
36
40
  />
@@ -45,6 +45,7 @@
45
45
  import Structured from './cms-structured.svelte';
46
46
  import FormControls from './cms-form-controls.svelte';
47
47
  import { cms, cmsStore } from './cms-store.svelte.js';
48
+ import { siteCurrency, siteLocale } from '../../core/site-currency.js';
48
49
  import { useFormTarget, type FormStatus } from './use-form-target.svelte.js';
49
50
  import { setWhatsAppFieldNames } from './cms-whatsapp-form.svelte';
50
51
  import { cmsWhatsApp, type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
@@ -66,9 +67,14 @@
66
67
  action?: string;
67
68
  /**
68
69
  * ISO 4217: the business's currency, which every price is in. Defaults
69
- * to the `currency` site option.
70
+ * to the `currency` Site Option.
70
71
  */
71
72
  currency?: string;
73
+ /**
74
+ * The business's WhatsApp number, when the template keeps it in its
75
+ * contact details rather than in this field's settings.
76
+ */
77
+ number?: string;
72
78
  children: Snippet<[CmsWhatsAppCartState]>;
73
79
  };
74
80
  let {
@@ -79,20 +85,23 @@
79
85
  key,
80
86
  action,
81
87
  currency,
88
+ number,
82
89
  children: content,
83
90
  ...rest
84
91
  }: Props = $props();
85
92
 
86
93
  $effect.pre(() => {
87
- const fromSite = cms.site.currency;
88
- whatsappCart.currency =
89
- currency ?? (typeof fromSite === 'string' ? fromSite.trim().toUpperCase() : '');
94
+ whatsappCart.currency = currency?.trim().toUpperCase() || siteCurrency(cms.site) || '';
90
95
  });
91
96
 
92
- // The page's language, not the browser's: an English page shows MXN as
93
- // `MX$5.50`, a Spanish one as `$5.50`.
97
+ // The page's language in the business's country, not the browser's
98
+ // settings: on a Mexican site an English page shows MXN as `MX$5.50` and a
99
+ // Spanish one as `$5.50`.
94
100
  const pageLocale = () =>
95
- (typeof document !== 'undefined' && document.documentElement.lang) || undefined;
101
+ siteLocale(
102
+ cms.site,
103
+ (typeof document !== 'undefined' && document.documentElement.lang) || undefined
104
+ );
96
105
 
97
106
  const target = useFormTarget(() => ({ key, action }));
98
107
  setWhatsAppFieldNames(() => ORDER_PLACEHOLDERS);
@@ -172,7 +181,7 @@
172
181
  const text = tidyMessage(
173
182
  fillMessage(settings.message || DEFAULT_ORDER_MESSAGE, orderPlaceholders(order, pageLocale()))
174
183
  );
175
- const href = whatsappHref(settings.number, text);
184
+ const href = whatsappHref(number || settings.number, text);
176
185
  if (!href) {
177
186
  status = 'error';
178
187
  message = 'No WhatsApp number is set.';
@@ -39,9 +39,14 @@ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
39
39
  action?: string;
40
40
  /**
41
41
  * ISO 4217: the business's currency, which every price is in. Defaults
42
- * to the `currency` site option.
42
+ * to the `currency` Site Option.
43
43
  */
44
44
  currency?: string;
45
+ /**
46
+ * The business's WhatsApp number, when the template keeps it in its
47
+ * contact details rather than in this field's settings.
48
+ */
49
+ number?: string;
45
50
  children: Snippet<[CmsWhatsAppCartState]>;
46
51
  };
47
52
  declare const CmsWhatsappCart: import("svelte").Component<Props, {}, "">;
@@ -14,7 +14,11 @@ export type CollectionDetail = {
14
14
  };
15
15
  export type CollectionPrice = {
16
16
  amount: number | null;
17
- /** ISO 4217, e.g. `EUR`. */
17
+ /**
18
+ * ISO 4217. Normally blank: a price is in the site's currency (the
19
+ * `currency` Site Option), which `collectionView` fills in. Content
20
+ * written before that may still carry one per item; it is kept as read.
21
+ */
18
22
  currency: string;
19
23
  /** `night`, `person`, `hour`… shown after the amount. */
20
24
  period: string;
@@ -50,8 +54,12 @@ export type CollectionSlice = {
50
54
  /** Only items carrying this tag. */
51
55
  tag?: string;
52
56
  };
53
- /** Visible items (not hidden, with a title), optionally sliced. */
54
- export declare const collectionView: (value: CmsCollectionValue, slice?: CollectionSlice) => CollectionItemView[];
57
+ /**
58
+ * Visible items (not hidden, with a title), optionally sliced. `currency` is
59
+ * the site's; a price without one of its own is in it, so templates format
60
+ * `item.price` as is.
61
+ */
62
+ export declare const collectionView: (value: CmsCollectionValue, slice?: CollectionSlice, currency?: string) => CollectionItemView[];
55
63
  /** Every tag in use, in first-seen order. */
56
64
  export declare const collectionTags: (value: CmsCollectionValue) => string[];
57
65
  /** `from €120 / night`, or empty when there is no amount. */
@@ -128,22 +128,19 @@ export const cmsCollection = defineForm({
128
128
  placeholder: 'from',
129
129
  group: 'Pricing'
130
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
- },
131
+ // In the site's currency (Site Options); there is no per-item one.
132
+ { key: 'price.amount', label: 'Amount', type: 'number', half: true, group: 'Pricing' },
140
133
  { key: 'price.period', label: 'Per', type: 'text', placeholder: 'night', group: 'Pricing' }
141
134
  ]
142
135
  }
143
136
  ]
144
137
  });
145
- /** Visible items (not hidden, with a title), optionally sliced. */
146
- export const collectionView = (value, slice = {}) => {
138
+ /**
139
+ * Visible items (not hidden, with a title), optionally sliced. `currency` is
140
+ * the site's; a price without one of its own is in it, so templates format
141
+ * `item.price` as is.
142
+ */
143
+ export const collectionView = (value, slice = {}, currency) => {
147
144
  let items = value.items.filter((i) => !i.hidden && i.title !== '');
148
145
  if (slice.featured)
149
146
  items = items.filter((i) => i.featured);
@@ -151,7 +148,11 @@ export const collectionView = (value, slice = {}) => {
151
148
  items = items.filter((i) => i.tags.includes(slice.tag));
152
149
  if (slice.limit !== undefined && slice.limit >= 0)
153
150
  items = items.slice(0, slice.limit);
154
- return items.map((i) => ({ ...i, href: i.link ? linkHref(i.link) || null : null }));
151
+ return items.map((i) => ({
152
+ ...i,
153
+ price: i.price.currency || !currency ? i.price : { ...i.price, currency },
154
+ href: i.link ? linkHref(i.link) || null : null
155
+ }));
155
156
  };
156
157
  /** Every tag in use, in first-seen order. */
157
158
  export const collectionTags = (value) => {
@@ -22,7 +22,7 @@ export type PricingTier = {
22
22
  };
23
23
  export type CmsPricingValue = {
24
24
  v: 1;
25
- /** ISO 4217, e.g. `EUR`. */
25
+ /** ISO 4217. Blank means the site's currency (the `currency` Site Option). */
26
26
  currency: string;
27
27
  labels: {
28
28
  custom: string;
@@ -36,5 +36,8 @@ export type PricingTierView = PricingTier & {
36
36
  export declare const cmsPricing: import("./form.js").FormShape<CmsPricingValue>;
37
37
  /** `€29 / month`, the custom label, or empty without an amount. */
38
38
  export declare const formatPrice: (tier: PricingTier, value: Pick<CmsPricingValue, "currency" | "labels">, locale: string) => string;
39
- /** Tiers with a name, each with its formatted price and CTA href. */
40
- export declare const pricingView: (value: CmsPricingValue, locale: string) => PricingTierView[];
39
+ /**
40
+ * Tiers with a name, each with its formatted price and CTA href. `currency`
41
+ * is the site's, used when the block sets none of its own.
42
+ */
43
+ export declare const pricingView: (value: CmsPricingValue, locale: string, currency?: string) => PricingTierView[];
@@ -42,7 +42,13 @@ export const cmsPricing = defineForm({
42
42
  },
43
43
  empty: () => ({ v: 1, currency: '', labels: { custom: 'Custom' }, items: [] }),
44
44
  fields: [
45
- { key: 'currency', label: 'Currency', type: 'text', half: true, placeholder: 'EUR' },
45
+ {
46
+ key: 'currency',
47
+ label: 'Currency',
48
+ type: 'text',
49
+ half: true,
50
+ placeholder: 'Blank: the site’s'
51
+ },
46
52
  { key: 'labels.custom', label: '“Custom” price label', type: 'text' },
47
53
  {
48
54
  key: 'items',
@@ -105,11 +111,17 @@ export const formatPrice = (tier, value, locale) => {
105
111
  }
106
112
  return tier.price.period ? `${amount} / ${tier.price.period}` : amount;
107
113
  };
108
- /** Tiers with a name, each with its formatted price and CTA href. */
109
- export const pricingView = (value, locale) => value.items
110
- .filter((t) => t.name !== '')
111
- .map((t) => ({
112
- ...t,
113
- priceText: formatPrice(t, value, locale),
114
- href: t.cta ? linkHref(t.cta) || null : null
115
- }));
114
+ /**
115
+ * Tiers with a name, each with its formatted price and CTA href. `currency`
116
+ * is the site's, used when the block sets none of its own.
117
+ */
118
+ export const pricingView = (value, locale, currency) => {
119
+ const priced = value.currency || !currency ? value : { ...value, currency };
120
+ return value.items
121
+ .filter((t) => t.name !== '')
122
+ .map((t) => ({
123
+ ...t,
124
+ priceText: formatPrice(t, priced, locale),
125
+ href: t.cta ? linkHref(t.cta) || null : null
126
+ }));
127
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The business's currency, from Site Options.
3
+ *
4
+ * A site has one currency: the one the business prices in and is paid in
5
+ * (MXN for a bakery in Mexico). Templates declare it as a `currency` field in
6
+ * one of their Site Options groups (`business.currency`, say), and every
7
+ * price on the site is in it: a collection item's, a pricing tier's, the
8
+ * WhatsApp cart's. A visitor sees another currency only as a display
9
+ * conversion, which is a later concern, never as a second price.
10
+ */
11
+ /**
12
+ * The ISO 4217 code a Site Options tree declares: a top-level `currency`, or
13
+ * a `currency` field in whichever group the template put it in. `undefined`
14
+ * when none is set or the value is not a three-letter code.
15
+ */
16
+ export declare const siteCurrency: (site: Record<string, unknown> | undefined) => string | undefined;
17
+ /**
18
+ * The ISO 3166-1 alpha-2 code of the country the business is in, from a
19
+ * `country` field declared like `currency`. `undefined` when none is set.
20
+ */
21
+ export declare const siteCountry: (site: Record<string, unknown> | undefined) => string | undefined;
22
+ /**
23
+ * The locale prices format in: the page's language, in the business's
24
+ * country. A bare language does not know where the business is, and that
25
+ * decides the format: Spanish alone shows MXN as `360,00 MXN`, Mexican
26
+ * Spanish (`es-MX`) as `$360.00`, which is what the bakery's customers read.
27
+ * A `language` that already names a region is kept as it is.
28
+ */
29
+ export declare const siteLocale: (site: Record<string, unknown> | undefined, language: string | undefined) => string | undefined;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The business's currency, from Site Options.
3
+ *
4
+ * A site has one currency: the one the business prices in and is paid in
5
+ * (MXN for a bakery in Mexico). Templates declare it as a `currency` field in
6
+ * one of their Site Options groups (`business.currency`, say), and every
7
+ * price on the site is in it: a collection item's, a pricing tier's, the
8
+ * WhatsApp cart's. A visitor sees another currency only as a display
9
+ * conversion, which is a later concern, never as a second price.
10
+ */
11
+ const CURRENCY = /^[A-Z]{3}$/;
12
+ const COUNTRY = /^[A-Z]{2}$/;
13
+ const asCode = (value, shape) => {
14
+ if (typeof value !== 'string')
15
+ return undefined;
16
+ const code = value.trim().toUpperCase();
17
+ return shape.test(code) ? code : undefined;
18
+ };
19
+ /** A `key` field at the top level or in any group, as an upper-cased code of `shape`. */
20
+ const siteCode = (site, key, shape) => {
21
+ if (!site)
22
+ return undefined;
23
+ const top = asCode(site[key], shape);
24
+ if (top)
25
+ return top;
26
+ for (const group of Object.values(site)) {
27
+ if (group && typeof group === 'object' && !Array.isArray(group)) {
28
+ const code = asCode(group[key], shape);
29
+ if (code)
30
+ return code;
31
+ }
32
+ }
33
+ return undefined;
34
+ };
35
+ /**
36
+ * The ISO 4217 code a Site Options tree declares: a top-level `currency`, or
37
+ * a `currency` field in whichever group the template put it in. `undefined`
38
+ * when none is set or the value is not a three-letter code.
39
+ */
40
+ export const siteCurrency = (site) => siteCode(site, 'currency', CURRENCY);
41
+ /**
42
+ * The ISO 3166-1 alpha-2 code of the country the business is in, from a
43
+ * `country` field declared like `currency`. `undefined` when none is set.
44
+ */
45
+ export const siteCountry = (site) => siteCode(site, 'country', COUNTRY);
46
+ /**
47
+ * The locale prices format in: the page's language, in the business's
48
+ * country. A bare language does not know where the business is, and that
49
+ * decides the format: Spanish alone shows MXN as `360,00 MXN`, Mexican
50
+ * Spanish (`es-MX`) as `$360.00`, which is what the bakery's customers read.
51
+ * A `language` that already names a region is kept as it is.
52
+ */
53
+ export const siteLocale = (site, language) => {
54
+ const country = siteCountry(site);
55
+ if (!language)
56
+ return undefined;
57
+ if (!country || language.includes('-'))
58
+ return language;
59
+ return `${language}-${country}`;
60
+ };
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ export { default as CmsWhatsAppCart } from './components/cms/cms-whatsapp-cart.s
40
40
  export type { CmsWhatsAppCartState } from './components/cms/cms-whatsapp-cart.svelte';
41
41
  export { whatsappCart } from './components/cms/whatsapp-cart.svelte.js';
42
42
  export type { WhatsAppCartItem, WhatsAppPlacedOrder } from './components/cms/whatsapp-cart.svelte.js';
43
+ export { siteCountry, siteCurrency, siteLocale } from './core/site-currency.js';
43
44
  export * from './core/shapes/index.js';
44
45
  export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
45
46
  export { useCmsField, resolveScopeRef, ROOT_SCOPE_ID } from './components/cms/use-cms-field.svelte.js';
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ export { default as CmsFormFields } from './components/cms/cms-form-fields.svelt
41
41
  export { default as CmsWhatsAppForm } from './components/cms/cms-whatsapp-form.svelte';
42
42
  export { default as CmsWhatsAppCart } from './components/cms/cms-whatsapp-cart.svelte';
43
43
  export { whatsappCart } from './components/cms/whatsapp-cart.svelte.js';
44
+ export { siteCountry, siteCurrency, siteLocale } from './core/site-currency.js';
44
45
  export * from './core/shapes/index.js';
45
46
  // Admin bar (sync wrapper; admin-bar-internal + seo-panel are async chunks)
46
47
  export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velastack/cms",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
5
5
  "keywords": [
6
6
  "svelte",