@velastack/cms 0.8.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
@@ -502,6 +502,41 @@ A cart whose checkout is a WhatsApp chat. Visitors add items with `whatsappCart.
502
502
 
503
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
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.
505
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.
506
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.
507
542
  - **After checkout**, `placed` holds the order's reference and chat link until `cart.clear()`. Adding an item starts the next order.
@@ -656,7 +691,7 @@ editable page metadata (cms.metadata)
656
691
 
657
692
  ### Site options
658
693
 
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.
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.
660
695
 
661
696
  ## Adapters
662
697
 
@@ -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,7 @@
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
48
  import { siteCurrency, siteLocale } from '../../core/site-currency.js';
49
49
  import { useFormTarget, type FormStatus } from './use-form-target.svelte.js';
50
50
  import { setWhatsAppFieldNames } from './cms-whatsapp-form.svelte';
@@ -121,12 +121,26 @@
121
121
  get placed() {
122
122
  return whatsappCart.placed;
123
123
  },
124
- 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())
125
127
  };
126
128
 
127
129
  // The cart lives in the browser, so the server renders it empty and it
128
130
  // fills in here.
129
- 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
+ };
130
144
 
131
145
  const field = (body: FormData, name: string) => {
132
146
  const value = body.get(name);
@@ -179,7 +193,10 @@
179
193
  subtotal: orderSubtotal(lines, whatsappCart.currency)
180
194
  };
181
195
  const text = tidyMessage(
182
- 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
+ })
183
200
  );
184
201
  const href = whatsappHref(number || settings.number, text);
185
202
  if (!href) {
@@ -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,6 +14,13 @@
14
14
  * when none is set or the value is not a three-letter code.
15
15
  */
16
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[];
17
24
  /**
18
25
  * The ISO 3166-1 alpha-2 code of the country the business is in, from a
19
26
  * `country` field declared like `currency`. `undefined` when none is set.
@@ -38,6 +38,33 @@ const siteCode = (site, key, shape) => {
38
38
  * when none is set or the value is not a three-letter code.
39
39
  */
40
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
+ };
41
68
  /**
42
69
  * The ISO 3166-1 alpha-2 code of the country the business is in, from a
43
70
  * `country` field declared like `currency`. `undefined` when none is set.
package/dist/index.d.ts CHANGED
@@ -40,7 +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, siteCurrency, siteLocale } from './core/site-currency.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';
44
45
  export * from './core/shapes/index.js';
45
46
  export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
46
47
  export { useCmsField, resolveScopeRef, ROOT_SCOPE_ID } from './components/cms/use-cms-field.svelte.js';
package/dist/index.js CHANGED
@@ -41,7 +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, siteCurrency, siteLocale } from './core/site-currency.js';
44
+ export { siteCountry, siteCurrencies, siteCurrency, siteLocale } from './core/site-currency.js';
45
+ export { displayCurrency, money } from './components/cms/display-currency.svelte.js';
45
46
  export * from './core/shapes/index.js';
46
47
  // Admin bar (sync wrapper; admin-bar-internal + seo-panel are async chunks)
47
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.8.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",