@velastack/cms 0.7.0 → 0.9.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,43 @@ 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.
505
+ - **Another currency on the page** (see [`displayCurrency`](#displaycurrency-prices-in-the-visitors-currency)): `formatPrice` converts and marks the estimate `≈`, and the message gets an `{estimate}` placeholder with the total as the visitor read it. The order and the record stay in the business's currency.
506
+
507
+ ### `displayCurrency`: prices in the visitor's currency
508
+
509
+ A site's prices are in the business's currency. When the `currencies` Site Option lists more (a hotel: `USD, EUR`), a visitor can read prices in one of them: converted at the day's rate and marked `≈`, an estimate of a price that is still charged in the business's currency. The store is headless; the template draws the select.
510
+
511
+ ```svelte
512
+ <script lang="ts">
513
+ import { onMount } from 'svelte';
514
+ import { displayCurrency, CmsCollection } from '@velastack/cms';
515
+ onMount(() => displayCurrency.load());
516
+ </script>
517
+
518
+ {#if displayCurrency.offered.length > 1}
519
+ <select
520
+ value={displayCurrency.current}
521
+ onchange={(e) => displayCurrency.set(e.currentTarget.value)}
522
+ >
523
+ {#each displayCurrency.offered as code (code)}<option value={code}>{code}</option>{/each}
524
+ </select>
525
+ {/if}
526
+
527
+ <CmsCollection name="rooms" scope="root">
528
+ {#snippet children(rooms)}
529
+ {#each rooms as room (room.id)}
530
+ <p>{displayCurrency.formatCollectionPrice(room.price, cms.locale)}</p>
531
+ {/each}
532
+ {/snippet}
533
+ </CmsCollection>
534
+ ```
535
+
536
+ - `offered` is the business's currency first, then the others; `current` is the visitor's pick, kept in a `currency` cookie for a year; `set(code)` changes it.
537
+ - `format(amount, from?, language?)` and `formatCollectionPrice(price, language?)` return the price as the visitor reads it, formatted for the page's language in the business's country (`siteLocale`), with `≈` when converted. `convert(amount, from?)` gives the number, or `null` without a rate.
538
+ - The rates are central bank reference rates blended by [Frankfurter](https://frankfurter.dev), published once a day at `velastack.dev/v1/rates` for every site; `load()` fetches them in the browser once a day and keeps them in localStorage. A page without rates shows the business's currency. Nothing is fetched while the site offers one currency.
539
+ - On a static site the first render is in the business's currency and the visitor's pick applies after load, since there is no server to read the cookie.
502
540
  - **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
541
  - **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
542
  - **After checkout**, `placed` holds the order's reference and chat link until `cart.clear()`. Adding an item starts the next order.
@@ -653,7 +691,7 @@ editable page metadata (cms.metadata)
653
691
 
654
692
  ### Site options
655
693
 
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.
694
+ `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), and the other currencies it shows prices in (`currencies`, a comma-separated `text`; `siteCurrencies`). 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
695
 
658
696
  ## Adapters
659
697
 
@@ -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
  />
@@ -33,7 +33,6 @@
33
33
  import type { HTMLFormAttributes } from 'svelte/elements';
34
34
  import {
35
35
  fillMessage,
36
- formatMoney,
37
36
  openChat,
38
37
  orderPlaceholders,
39
38
  orderRef,
@@ -45,6 +44,8 @@
45
44
  import Structured from './cms-structured.svelte';
46
45
  import FormControls from './cms-form-controls.svelte';
47
46
  import { cms, cmsStore } from './cms-store.svelte.js';
47
+ import { displayCurrency } from './display-currency.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);
@@ -112,12 +121,26 @@
112
121
  get placed() {
113
122
  return whatsappCart.placed;
114
123
  },
115
- formatPrice: (amount) => formatMoney(amount, whatsappCart.currency, pageLocale())
124
+ // In the currency the visitor reads prices in, as an estimate when it is
125
+ // not the business's own.
126
+ formatPrice: (amount) => displayCurrency.format(amount, whatsappCart.currency, pageLocale())
116
127
  };
117
128
 
118
129
  // The cart lives in the browser, so the server renders it empty and it
119
130
  // fills in here.
120
- onMount(() => whatsappCart.load());
131
+ onMount(() => {
132
+ whatsappCart.load();
133
+ displayCurrency.load();
134
+ });
135
+
136
+ /** `≈ US$20`, when the visitor was reading prices in another currency; else blank. */
137
+ const estimateOf = (order: Order): string => {
138
+ if (order.subtotal === undefined || displayCurrency.current === order.currency) return '';
139
+ const converted = displayCurrency.convert(order.subtotal, order.currency);
140
+ return converted === null
141
+ ? ''
142
+ : displayCurrency.format(order.subtotal, order.currency, pageLocale());
143
+ };
121
144
 
122
145
  const field = (body: FormData, name: string) => {
123
146
  const value = body.get(name);
@@ -170,9 +193,12 @@
170
193
  subtotal: orderSubtotal(lines, whatsappCart.currency)
171
194
  };
172
195
  const text = tidyMessage(
173
- fillMessage(settings.message || DEFAULT_ORDER_MESSAGE, orderPlaceholders(order, pageLocale()))
196
+ fillMessage(settings.message || DEFAULT_ORDER_MESSAGE, {
197
+ ...orderPlaceholders(order, pageLocale()),
198
+ estimate: estimateOf(order)
199
+ })
174
200
  );
175
- const href = whatsappHref(settings.number, text);
201
+ const href = whatsappHref(number || settings.number, text);
176
202
  if (!href) {
177
203
  status = 'error';
178
204
  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, {}, "">;
@@ -0,0 +1,42 @@
1
+ import type { CollectionPrice } from '../../core/shapes/collection.js';
2
+ export declare const RATES_URL = "https://velastack.dev/v1/rates";
3
+ export type Rates = {
4
+ base: string;
5
+ date: string;
6
+ rates: Record<string, number>;
7
+ fetchedAt: number;
8
+ };
9
+ declare class DisplayCurrency {
10
+ #private;
11
+ rates: Rates | null;
12
+ /** The business's currency, from Site Options. */
13
+ get home(): string;
14
+ /** The currencies the site shows prices in: the business's own, then the others it offers. */
15
+ get offered(): string[];
16
+ /** The currency prices show in now. */
17
+ get current(): string;
18
+ /** Reads the cookie and the day's rates, once, in the browser. */
19
+ load(): void;
20
+ /** Shows prices in `code`, one of `offered`, and remembers it. */
21
+ set(code: string): void;
22
+ /** `amount` in `from` as the current currency, or `null` without a rate for either. */
23
+ convert(amount: number, from?: string): number | null;
24
+ /**
25
+ * `amount` in `from`, as the visitor should read it: in the current currency
26
+ * with `≈` when it was converted, else as it is. `language` is the page's
27
+ * (`cms.locale`, or `<html lang>`), formatted for the business's country.
28
+ */
29
+ format(amount: number, from?: string, language?: string): string;
30
+ /**
31
+ * A collection item's price (`from €120 / night`) as the visitor reads it:
32
+ * converted and marked `≈` when they picked another currency. Empty
33
+ * without an amount, as `formatCollectionPrice` is.
34
+ */
35
+ formatCollectionPrice(price: CollectionPrice, language?: string): string;
36
+ /** For tests and servers that already have the day's rates. */
37
+ useRates(rates: Rates | null): void;
38
+ }
39
+ /** `amount` in `code`, formatted for `locale`; a code Intl refuses prints as `CODE 12`. */
40
+ export declare const money: (amount: number, code: string, locale: string) => string;
41
+ export declare const displayCurrency: DisplayCurrency;
42
+ export {};
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The currency a visitor reads prices in.
3
+ *
4
+ * Every price on a site is in the business's currency, the `currency` Site
5
+ * Option. When the `currencies` option offers more, the visitor can pick one
6
+ * and sees each price converted at the day's rate, marked `≈`: an estimate,
7
+ * never what they are charged. The choice is kept in a cookie for a year.
8
+ *
9
+ * Rates come from velastack.dev, which publishes central bank reference rates
10
+ * once for every site (https://velastack.dev/v1/rates); they are fetched in
11
+ * the browser once a day and kept in localStorage. Headless: a template draws
12
+ * its own select from `offered` and `current`, and calls `set`.
13
+ */
14
+ import { cms } from './cms-store.svelte.js';
15
+ import { siteCurrencies, siteCurrency, siteLocale } from '../../core/site-currency.js';
16
+ export const RATES_URL = 'https://velastack.dev/v1/rates';
17
+ const COOKIE = 'currency';
18
+ const RATES_KEY = 'vela-cms:rates';
19
+ const DAY = 24 * 60 * 60 * 1000;
20
+ class DisplayCurrency {
21
+ /** The visitor's pick, or `''` for the business's own currency. */
22
+ #picked = $state('');
23
+ rates = $state(null);
24
+ #loaded = false;
25
+ /** The business's currency, from Site Options. */
26
+ get home() {
27
+ return siteCurrency(cms.site) ?? '';
28
+ }
29
+ /** The currencies the site shows prices in: the business's own, then the others it offers. */
30
+ get offered() {
31
+ const home = this.home;
32
+ if (!home)
33
+ return [];
34
+ return [home, ...siteCurrencies(cms.site).filter((code) => code !== home)];
35
+ }
36
+ /** The currency prices show in now. */
37
+ get current() {
38
+ return this.#picked && this.offered.includes(this.#picked) ? this.#picked : this.home;
39
+ }
40
+ /** Reads the cookie and the day's rates, once, in the browser. */
41
+ load() {
42
+ if (typeof document === 'undefined' || this.#loaded)
43
+ return;
44
+ this.#loaded = true;
45
+ const saved = document.cookie.match(/(?:^|;\s*)currency=([A-Z]{3})/)?.[1];
46
+ if (saved)
47
+ this.#picked = saved;
48
+ if (this.current !== this.home)
49
+ void this.#loadRates();
50
+ }
51
+ /** Shows prices in `code`, one of `offered`, and remembers it. */
52
+ set(code) {
53
+ if (!this.offered.includes(code))
54
+ return;
55
+ this.#picked = code;
56
+ if (typeof document !== 'undefined') {
57
+ document.cookie = `${COOKIE}=${code}; path=/; max-age=${365 * 24 * 60 * 60}; samesite=lax`;
58
+ }
59
+ if (code !== this.home)
60
+ void this.#loadRates();
61
+ }
62
+ /** `amount` in `from` as the current currency, or `null` without a rate for either. */
63
+ convert(amount, from = this.home) {
64
+ if (from === this.current)
65
+ return amount;
66
+ const rates = this.rates?.rates;
67
+ const fromRate = rates?.[from];
68
+ const toRate = rates?.[this.current];
69
+ if (!fromRate || !toRate)
70
+ return null;
71
+ return (amount / fromRate) * toRate;
72
+ }
73
+ /**
74
+ * `amount` in `from`, as the visitor should read it: in the current currency
75
+ * with `≈` when it was converted, else as it is. `language` is the page's
76
+ * (`cms.locale`, or `<html lang>`), formatted for the business's country.
77
+ */
78
+ format(amount, from = this.home, language) {
79
+ const locale = siteLocale(cms.site, language ?? pageLanguage()) ?? 'en';
80
+ const converted = this.convert(amount, from);
81
+ if (converted === null || from === this.current)
82
+ return money(amount, from, locale);
83
+ return `≈ ${money(converted, this.current, locale)}`;
84
+ }
85
+ /**
86
+ * A collection item's price (`from €120 / night`) as the visitor reads it:
87
+ * converted and marked `≈` when they picked another currency. Empty
88
+ * without an amount, as `formatCollectionPrice` is.
89
+ */
90
+ formatCollectionPrice(price, language) {
91
+ if (price.amount === null)
92
+ return '';
93
+ const amount = this.format(price.amount, price.currency || this.home, language);
94
+ return [price.prefix, amount, price.period ? `/ ${price.period}` : '']
95
+ .filter(Boolean)
96
+ .join(' ');
97
+ }
98
+ /** For tests and servers that already have the day's rates. */
99
+ useRates(rates) {
100
+ this.rates = rates;
101
+ }
102
+ async #loadRates() {
103
+ if (this.rates && Date.now() - this.rates.fetchedAt < DAY)
104
+ return;
105
+ try {
106
+ const cached = JSON.parse(localStorage.getItem(RATES_KEY) ?? 'null');
107
+ if (cached && Date.now() - cached.fetchedAt < DAY) {
108
+ this.rates = cached;
109
+ return;
110
+ }
111
+ }
112
+ catch {
113
+ // Unreadable storage: fetch instead.
114
+ }
115
+ try {
116
+ const response = await fetch(RATES_URL);
117
+ if (!response.ok)
118
+ return;
119
+ const body = (await response.json());
120
+ const rates = { ...body, fetchedAt: Date.now() };
121
+ this.rates = rates;
122
+ localStorage.setItem(RATES_KEY, JSON.stringify(rates));
123
+ }
124
+ catch {
125
+ // No rates: prices stay in the business's currency.
126
+ }
127
+ }
128
+ }
129
+ const pageLanguage = () => (typeof document !== 'undefined' && document.documentElement.lang) || undefined;
130
+ /** `amount` in `code`, formatted for `locale`; a code Intl refuses prints as `CODE 12`. */
131
+ export const money = (amount, code, locale) => {
132
+ try {
133
+ return new Intl.NumberFormat(locale, {
134
+ style: 'currency',
135
+ currency: code,
136
+ maximumFractionDigits: Number.isInteger(amount) && amount >= 100 ? 0 : 2
137
+ }).format(amount);
138
+ }
139
+ catch {
140
+ return `${code} ${amount}`.trim();
141
+ }
142
+ };
143
+ export const displayCurrency = new DisplayCurrency();
@@ -4,7 +4,11 @@
4
4
  */
5
5
  /** The `{placeholders}` an order message can use. */
6
6
  export declare const ORDER_PLACEHOLDERS: string[];
7
- /** Until the editor writes one. Lines left empty (no total, no note) are dropped. */
8
- export declare const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n\n{name} \u00B7 {phone}\n{note}";
7
+ /**
8
+ * Until the editor writes one. Lines left empty (no total, no estimate, no
9
+ * note) are dropped. `{estimate}` is the total in the currency the visitor
10
+ * was reading prices in, when it was not the business's own.
11
+ */
12
+ export declare const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n{estimate}\n\n{name} \u00B7 {phone}\n{note}";
9
13
  /** Drops the blank lines and dangling spaces that empty placeholders leave behind. */
10
14
  export declare const tidyMessage: (message: string) => string;
@@ -3,9 +3,13 @@
3
3
  * the order by `@velastack/whatsapp`'s `orderPlaceholders`.
4
4
  */
5
5
  /** The `{placeholders}` an order message can use. */
6
- export const ORDER_PLACEHOLDERS = ['ref', 'items', 'total', 'name', 'phone', 'note'];
7
- /** Until the editor writes one. Lines left empty (no total, no note) are dropped. */
8
- export const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n\n{name} · {phone}\n{note}";
6
+ export const ORDER_PLACEHOLDERS = ['ref', 'items', 'total', 'estimate', 'name', 'phone', 'note'];
7
+ /**
8
+ * Until the editor writes one. Lines left empty (no total, no estimate, no
9
+ * note) are dropped. `{estimate}` is the total in the currency the visitor
10
+ * was reading prices in, when it was not the business's own.
11
+ */
12
+ export const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n{estimate}\n\n{name} · {phone}\n{note}";
9
13
  /** Drops the blank lines and dangling spaces that empty placeholders leave behind. */
10
14
  export const tidyMessage = (message) => message
11
15
  .replace(/[ \t]+$/gm, '')
@@ -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,36 @@
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 other currencies the site shows prices in, from a `currencies` field
19
+ * declared like `currency`: a list, or a comma-separated text as the Site
20
+ * Options panel stores it. Upper-cased three-letter codes, deduplicated;
21
+ * empty when none is set.
22
+ */
23
+ export declare const siteCurrencies: (site: Record<string, unknown> | undefined) => string[];
24
+ /**
25
+ * The ISO 3166-1 alpha-2 code of the country the business is in, from a
26
+ * `country` field declared like `currency`. `undefined` when none is set.
27
+ */
28
+ export declare const siteCountry: (site: Record<string, unknown> | undefined) => string | undefined;
29
+ /**
30
+ * The locale prices format in: the page's language, in the business's
31
+ * country. A bare language does not know where the business is, and that
32
+ * decides the format: Spanish alone shows MXN as `360,00 MXN`, Mexican
33
+ * Spanish (`es-MX`) as `$360.00`, which is what the bakery's customers read.
34
+ * A `language` that already names a region is kept as it is.
35
+ */
36
+ export declare const siteLocale: (site: Record<string, unknown> | undefined, language: string | undefined) => string | undefined;
@@ -0,0 +1,87 @@
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 other currencies the site shows prices in, from a `currencies` field
43
+ * declared like `currency`: a list, or a comma-separated text as the Site
44
+ * Options panel stores it. Upper-cased three-letter codes, deduplicated;
45
+ * empty when none is set.
46
+ */
47
+ export const siteCurrencies = (site) => {
48
+ if (!site)
49
+ return [];
50
+ const raw = site.currencies ??
51
+ Object.values(site).find((group) => group && typeof group === 'object' && !Array.isArray(group) && 'currencies' in group);
52
+ const value = raw && typeof raw === 'object' && !Array.isArray(raw)
53
+ ? raw.currencies
54
+ : raw;
55
+ const entries = Array.isArray(value)
56
+ ? value
57
+ : typeof value === 'string'
58
+ ? value.split(/[,\s]+/)
59
+ : [];
60
+ const codes = [];
61
+ for (const entry of entries) {
62
+ const code = asCode(entry, CURRENCY);
63
+ if (code && !codes.includes(code))
64
+ codes.push(code);
65
+ }
66
+ return codes;
67
+ };
68
+ /**
69
+ * The ISO 3166-1 alpha-2 code of the country the business is in, from a
70
+ * `country` field declared like `currency`. `undefined` when none is set.
71
+ */
72
+ export const siteCountry = (site) => siteCode(site, 'country', COUNTRY);
73
+ /**
74
+ * The locale prices format in: the page's language, in the business's
75
+ * country. A bare language does not know where the business is, and that
76
+ * decides the format: Spanish alone shows MXN as `360,00 MXN`, Mexican
77
+ * Spanish (`es-MX`) as `$360.00`, which is what the bakery's customers read.
78
+ * A `language` that already names a region is kept as it is.
79
+ */
80
+ export const siteLocale = (site, language) => {
81
+ const country = siteCountry(site);
82
+ if (!language)
83
+ return undefined;
84
+ if (!country || language.includes('-'))
85
+ return language;
86
+ return `${language}-${country}`;
87
+ };
package/dist/index.d.ts CHANGED
@@ -40,6 +40,8 @@ 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, siteCurrencies, siteCurrency, siteLocale } from './core/site-currency.js';
44
+ export { displayCurrency, money, type Rates } from './components/cms/display-currency.svelte.js';
43
45
  export * from './core/shapes/index.js';
44
46
  export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
45
47
  export { useCmsField, resolveScopeRef, ROOT_SCOPE_ID } from './components/cms/use-cms-field.svelte.js';
package/dist/index.js CHANGED
@@ -41,6 +41,8 @@ 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, siteCurrencies, siteCurrency, siteLocale } from './core/site-currency.js';
45
+ export { displayCurrency, money } from './components/cms/display-currency.svelte.js';
44
46
  export * from './core/shapes/index.js';
45
47
  // Admin bar (sync wrapper; admin-bar-internal + seo-panel are async chunks)
46
48
  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.9.0",
4
4
  "description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
5
5
  "keywords": [
6
6
  "svelte",