domma-cms 0.84.1 → 0.85.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.
Files changed (50) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/themes.js +1 -1
  5. package/package.json +2 -2
  6. package/plugins/free-tier.lock.json +49 -1
  7. package/plugins/shopping-cart/CLAUDE.md +93 -0
  8. package/plugins/shopping-cart/admin/css/index.css +224 -0
  9. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  10. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  11. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  12. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  13. package/plugins/shopping-cart/admin/views/products.js +547 -0
  14. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  15. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  16. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  17. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  18. package/plugins/shopping-cart/config.js +52 -0
  19. package/plugins/shopping-cart/inject/body.html +2 -0
  20. package/plugins/shopping-cart/lib/extensions.js +96 -0
  21. package/plugins/shopping-cart/lib/payments.js +281 -0
  22. package/plugins/shopping-cart/lib/pricing.js +232 -0
  23. package/plugins/shopping-cart/lib/render.js +282 -0
  24. package/plugins/shopping-cart/lib/samples.js +34 -0
  25. package/plugins/shopping-cart/lib/shop.js +436 -0
  26. package/plugins/shopping-cart/lib/summary.js +127 -0
  27. package/plugins/shopping-cart/plugin.js +559 -0
  28. package/plugins/shopping-cart/plugin.json +52 -0
  29. package/plugins/shopping-cart/plugin.public.js +144 -0
  30. package/plugins/shopping-cart/public/cart.js +715 -0
  31. package/plugins/shopping-cart/public/lib/money.js +105 -0
  32. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  33. package/plugins/shopping-cart/public/lib/products.js +235 -0
  34. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  35. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  36. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  37. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  38. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  39. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  40. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  41. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  42. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  43. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  44. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  45. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  46. package/plugins/shopping-cart/public/shop.css +415 -0
  47. package/plugins/shopping-cart/templates/shop.html +4 -0
  48. package/plugins/shopping-cart/tests/api.test.js +144 -0
  49. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  50. package/scripts/setup.js +1 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Money, in minor units. PURE - shared by the server, the storefront script
3
+ * and the admin (both load it from public/, which the CMS serves).
4
+ *
5
+ * Every amount the Cart stores or adds up is an INTEGER count of the
6
+ * currency's smallest unit (pence, cents, yen), so £19.99 is 1999 and no
7
+ * total ever drifts by a floating-point penny. Only the edges convert: what a
8
+ * person types, what a page shows, and what a payment provider is sent.
9
+ *
10
+ * @module shopping-cart/lib/money
11
+ */
12
+
13
+ /** The currencies on offer, with how many decimal places each one has. */
14
+ export const CURRENCIES = {
15
+ GBP: {label: 'Pound sterling', decimals: 2},
16
+ EUR: {label: 'Euro', decimals: 2},
17
+ USD: {label: 'US dollar', decimals: 2},
18
+ CAD: {label: 'Canadian dollar', decimals: 2},
19
+ AUD: {label: 'Australian dollar', decimals: 2},
20
+ NZD: {label: 'New Zealand dollar', decimals: 2},
21
+ CHF: {label: 'Swiss franc', decimals: 2},
22
+ SEK: {label: 'Swedish krona', decimals: 2},
23
+ NOK: {label: 'Norwegian krone', decimals: 2},
24
+ DKK: {label: 'Danish krone', decimals: 2},
25
+ PLN: {label: 'Polish złoty', decimals: 2},
26
+ ZAR: {label: 'South African rand', decimals: 2},
27
+ JPY: {label: 'Japanese yen', decimals: 0}
28
+ };
29
+
30
+ /** A known currency code, or GBP. */
31
+ export function currencyOf(code) {
32
+ const c = String(code || '').toUpperCase();
33
+ return CURRENCIES[c] ? c : 'GBP';
34
+ }
35
+
36
+ export const decimalsOf = (code) => CURRENCIES[currencyOf(code)].decimals;
37
+
38
+ /**
39
+ * Minor units from what a person typed: "19.99", "£1,299.5", 12 -> 1999, 129950, 1200.
40
+ * Returns null for anything that is not a sensible, non-negative amount.
41
+ *
42
+ * @param {string|number} value
43
+ * @param {string} currency
44
+ * @returns {number|null}
45
+ */
46
+ export function toMinor(value, currency = 'GBP') {
47
+ if (value === null || value === undefined || value === '') return null;
48
+ const d = decimalsOf(currency);
49
+ let n;
50
+ if (typeof value === 'number') n = value;
51
+ else {
52
+ const t = String(value).replace(/[^\d.,-]/g, '').replace(/,(?=\d{3}(\D|$))/g, '').replace(',', '.');
53
+ if (!/^-?\d*\.?\d*$/.test(t) || t === '' || t === '.' || t === '-') return null;
54
+ n = Number(t);
55
+ }
56
+ if (!Number.isFinite(n) || n < 0 || n > 1e9) return null;
57
+ return Math.round(n * 10 ** d);
58
+ }
59
+
60
+ /** "19.99" from 1999 - for an input's value, no symbol, no grouping. */
61
+ export function fromMinor(minor, currency = 'GBP') {
62
+ const d = decimalsOf(currency);
63
+ const n = Number(minor) || 0;
64
+ return (n / 10 ** d).toFixed(d);
65
+ }
66
+
67
+ /**
68
+ * "£19.99" from 1999. A whole amount drops its pennies when `compact` is set
69
+ * ("£20" on a product card), never on a total.
70
+ *
71
+ * @param {number} minor
72
+ * @param {string} currency
73
+ * @param {{compact?: boolean, locale?: string}} [opts]
74
+ */
75
+ export function formatMoney(minor, currency = 'GBP', {compact = false, locale} = {}) {
76
+ const code = currencyOf(currency);
77
+ const d = CURRENCIES[code].decimals;
78
+ const n = (Number(minor) || 0) / 10 ** d;
79
+ const whole = compact && Number.isInteger(n);
80
+ try {
81
+ return new Intl.NumberFormat(locale || (code === 'GBP' ? 'en-GB' : undefined), {
82
+ style: 'currency', currency: code,
83
+ minimumFractionDigits: whole ? 0 : d, maximumFractionDigits: whole ? 0 : d
84
+ }).format(n);
85
+ } catch {
86
+ return `${code} ${n.toFixed(d)}`;
87
+ }
88
+ }
89
+
90
+ /** Percentage of an amount, rounded half up to the minor unit. */
91
+ export function percentOf(minor, percent) {
92
+ return Math.round((Number(minor) || 0) * (Number(percent) || 0) / 100);
93
+ }
94
+
95
+ /**
96
+ * The tax held INSIDE a tax-inclusive price: £120 at 20% holds £20.
97
+ *
98
+ * @param {number} grossMinor
99
+ * @param {number} ratePercent
100
+ */
101
+ export function includedTax(grossMinor, ratePercent) {
102
+ const r = Number(ratePercent) || 0;
103
+ if (r <= 0) return 0;
104
+ return Math.round((Number(grossMinor) || 0) * r / (100 + r));
105
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Orders. PURE - statuses and what may follow each, cleaning what a shopper
3
+ * types at checkout, and the countries on offer. The server enforces it; the
4
+ * admin builds its menus from the same table, so an action it offers is one
5
+ * the server will take.
6
+ *
7
+ * @module shopping-cart/public/lib/orders
8
+ */
9
+
10
+ /**
11
+ * pending - checkout started, not paid (a provider's page is open, or was abandoned)
12
+ * awaiting - the shopper chose to pay offline (bank transfer, on collection)
13
+ * paid - money received; stock taken
14
+ * fulfilled - sent, collected or delivered
15
+ * cancelled - never paid, and closed
16
+ * refunded - paid, then given back
17
+ */
18
+ export const STATUSES = ['pending', 'awaiting', 'paid', 'fulfilled', 'cancelled', 'refunded'];
19
+
20
+ export const STATUS_LABEL = {
21
+ pending: 'Checkout started',
22
+ awaiting: 'Awaiting payment',
23
+ paid: 'Paid - to fulfil',
24
+ fulfilled: 'Fulfilled',
25
+ cancelled: 'Cancelled',
26
+ refunded: 'Refunded'
27
+ };
28
+
29
+ /** For shoppers: their receipt page. */
30
+ export const STATUS_PUBLIC = {
31
+ pending: 'Waiting for payment',
32
+ awaiting: 'Waiting for payment',
33
+ paid: 'Paid - being prepared',
34
+ fulfilled: 'On its way',
35
+ cancelled: 'Cancelled',
36
+ refunded: 'Refunded'
37
+ };
38
+
39
+ /** A tone for chips: the CSS class suffix. */
40
+ export const STATUS_TONE = {
41
+ pending: 'muted', awaiting: 'warning', paid: 'primary', fulfilled: 'success', cancelled: 'muted', refunded: 'danger'
42
+ };
43
+
44
+ /** What may follow each status - from the admin, by hand. Payment itself moves pending/awaiting -> paid. */
45
+ export const NEXT = {
46
+ pending: ['paid', 'awaiting', 'cancelled'],
47
+ awaiting: ['paid', 'cancelled'],
48
+ paid: ['fulfilled', 'refunded'],
49
+ fulfilled: ['paid', 'refunded'],
50
+ cancelled: [],
51
+ refunded: []
52
+ };
53
+
54
+ export const canMove = (from, to) => (NEXT[from] || []).includes(to);
55
+
56
+ /** Statuses that count as money in (revenue, stock taken). */
57
+ export const SOLD = new Set(['paid', 'fulfilled']);
58
+
59
+ /** "#1042". */
60
+ export const orderLabel = (order) => `#${order?.number ?? '-'}`;
61
+
62
+ const text = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
63
+
64
+ /** A plausible email: one @, a dot after it, no spaces. */
65
+ export const isEmail = (s) => /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,}$/i.test(String(s || ''));
66
+
67
+ /** The countries a shopper can pick, most likely first. ISO 3166-1 alpha-2. */
68
+ export const COUNTRIES = {
69
+ GB: 'United Kingdom', IE: 'Ireland', US: 'United States', CA: 'Canada', AU: 'Australia', NZ: 'New Zealand',
70
+ FR: 'France', DE: 'Germany', ES: 'Spain', IT: 'Italy', NL: 'Netherlands', BE: 'Belgium', LU: 'Luxembourg',
71
+ PT: 'Portugal', AT: 'Austria', CH: 'Switzerland', DK: 'Denmark', SE: 'Sweden', NO: 'Norway', FI: 'Finland',
72
+ IS: 'Iceland', PL: 'Poland', CZ: 'Czechia', SK: 'Slovakia', HU: 'Hungary', SI: 'Slovenia', HR: 'Croatia',
73
+ RO: 'Romania', BG: 'Bulgaria', GR: 'Greece', CY: 'Cyprus', MT: 'Malta', EE: 'Estonia', LV: 'Latvia',
74
+ LT: 'Lithuania', JE: 'Jersey', GG: 'Guernsey', IM: 'Isle of Man', GI: 'Gibraltar', JP: 'Japan',
75
+ SG: 'Singapore', HK: 'Hong Kong', AE: 'United Arab Emirates', ZA: 'South Africa', IN: 'India', MX: 'Mexico', BR: 'Brazil'
76
+ };
77
+
78
+ /**
79
+ * What a shopper typed at checkout, cleaned. `needsAddress` when anything in
80
+ * the basket is shipped and the shopper is not collecting.
81
+ *
82
+ * @param {object} input - {customer: {name, email, phone}, address: {...}, note}
83
+ * @param {{needsAddress?: boolean, countries?: string[]}} [opts]
84
+ * @returns {{customer: object, address: object|null, note: string, errors: Object<string, string>}}
85
+ */
86
+ export function cleanCheckout(input = {}, {needsAddress = true, countries = null} = {}) {
87
+ const errors = {};
88
+ const c = input.customer || {};
89
+ const customer = {
90
+ name: text(c.name, 120),
91
+ email: text(c.email, 200).toLowerCase(),
92
+ phone: text(c.phone, 40)
93
+ };
94
+ if (!customer.name) errors.name = 'Your name, please.';
95
+ if (!isEmail(customer.email)) errors.email = 'An email address we can send the receipt to.';
96
+
97
+ let address = null;
98
+ if (needsAddress) {
99
+ const a = input.address || {};
100
+ address = {
101
+ line1: text(a.line1, 160),
102
+ line2: text(a.line2, 160),
103
+ city: text(a.city, 100),
104
+ region: text(a.region, 100),
105
+ postcode: text(a.postcode, 20).toUpperCase(),
106
+ country: String(a.country || '').toUpperCase().slice(0, 2)
107
+ };
108
+ if (!address.line1) errors.line1 = 'The first line of the address.';
109
+ if (!address.city) errors.city = 'Town or city.';
110
+ if (!address.postcode && !['IE', 'HK', 'AE'].includes(address.country)) errors.postcode = 'Postcode.';
111
+ const allowed = countries?.length ? countries : Object.keys(COUNTRIES);
112
+ if (!allowed.includes(address.country)) errors.country = 'We do not deliver there yet.';
113
+ }
114
+ return {customer, address, note: String(input.note ?? '').trim().slice(0, 1000), errors};
115
+ }
116
+
117
+ /** An address on lines, for a receipt or a packing slip. */
118
+ export function addressLines(a) {
119
+ if (!a) return [];
120
+ return [a.line1, a.line2, [a.city, a.region].filter(Boolean).join(', '), a.postcode, COUNTRIES[a.country] || a.country]
121
+ .filter(Boolean);
122
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Products. PURE - the server cleans every write with it, the admin's editor
3
+ * checks as you type with the same rules, and the storefront reads stock and
4
+ * variants through it.
5
+ *
6
+ * A product is stored as collection entry data:
7
+ *
8
+ * {name, slug, summary, description, price, compareAt, sku, images[],
9
+ * category, tags[], status, featured, position,
10
+ * trackStock, stock, optionName, variants[{id, name, price, sku, stock}],
11
+ * shippable, weight, taxClass}
12
+ *
13
+ * Prices are minor units (see money.js). `variants[].price` null = the
14
+ * product's price. Stock is only counted when `trackStock` is on; with
15
+ * variants it is counted per variant, otherwise on the product.
16
+ *
17
+ * @module shopping-cart/public/lib/products
18
+ */
19
+
20
+ import {toMinor} from './money.js';
21
+
22
+ export const STATUSES = ['active', 'draft', 'archived'];
23
+ export const STATUS_LABEL = {active: 'On sale', draft: 'Draft', archived: 'Archived'};
24
+ export const TAX_CLASSES = ['standard', 'reduced', 'zero'];
25
+ export const MAX_IMAGES = 12;
26
+ export const MAX_VARIANTS = 60;
27
+
28
+ /** "Blue Mug (Large)!" -> "blue-mug-large". */
29
+ export function slugify(text) {
30
+ return String(text || '')
31
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '')
32
+ .toLowerCase().replace(/&/g, ' and ')
33
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
34
+ .slice(0, 80);
35
+ }
36
+
37
+ const text = (v, max) => String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, max);
38
+
39
+ /** A picture address a page may show: site-relative or http(s). */
40
+ export function safeImage(url) {
41
+ const u = String(url || '').trim();
42
+ if (!u) return '';
43
+ if (/^\/(?!\/)[^\s"'<>]*$/.test(u)) return u;
44
+ if (/^https?:\/\/[^\s"'<>]+$/i.test(u)) return u;
45
+ return '';
46
+ }
47
+
48
+ /** An amount field: a number already in minor units, or typed text in major units. */
49
+ function amount(value, currency, {minor = false} = {}) {
50
+ if (value === null || value === undefined || value === '') return null;
51
+ if (minor) {
52
+ const n = Number(value);
53
+ return Number.isInteger(n) && n >= 0 && n <= 1e11 ? n : NaN;
54
+ }
55
+ const m = toMinor(value, currency);
56
+ return m === null ? NaN : m;
57
+ }
58
+
59
+ function wholeOrNull(value, {min = 0, max = 1e9} = {}) {
60
+ if (value === null || value === undefined || value === '') return null;
61
+ const n = Number(value);
62
+ return Number.isInteger(n) && n >= min && n <= max ? n : NaN;
63
+ }
64
+
65
+ const newId = () => Math.random().toString(36).slice(2, 10);
66
+
67
+ /**
68
+ * Clean a product as sent by the admin. Amounts arrive in MINOR units
69
+ * (`minor: true`, the API's contract) or as typed text (the editor's check).
70
+ *
71
+ * @param {object} input
72
+ * @param {{currency?: string, minor?: boolean}} [opts]
73
+ * @returns {{data: object, errors: Object<string, string>}}
74
+ */
75
+ export function cleanProduct(input = {}, {currency = 'GBP', minor = true} = {}) {
76
+ const errors = {};
77
+ const name = text(input.name, 140);
78
+ if (!name) errors.name = 'Give the product a name.';
79
+
80
+ const slug = slugify(input.slug || name);
81
+ if (!slug) errors.slug = 'The address needs letters or numbers.';
82
+
83
+ const price = amount(input.price, currency, {minor});
84
+ if (price === null) errors.price = 'Give it a price (0 for free).';
85
+ else if (Number.isNaN(price)) errors.price = 'Not a price.';
86
+
87
+ let compareAt = amount(input.compareAt, currency, {minor});
88
+ if (Number.isNaN(compareAt)) errors.compareAt = 'Not a price.';
89
+ else if (compareAt !== null && Number.isInteger(price) && compareAt <= price) compareAt = null;
90
+
91
+ const status = STATUSES.includes(input.status) ? input.status : 'draft';
92
+ const trackStock = Boolean(input.trackStock);
93
+ const stock = wholeOrNull(input.stock);
94
+ if (Number.isNaN(stock)) errors.stock = 'A whole number, 0 or more.';
95
+
96
+ const images = (Array.isArray(input.images) ? input.images : String(input.images || '').split('\n'))
97
+ .map(safeImage).filter(Boolean).slice(0, MAX_IMAGES);
98
+
99
+ const tags = [...new Set((Array.isArray(input.tags) ? input.tags : String(input.tags || '').split(','))
100
+ .map(t => text(t, 40).toLowerCase()).filter(Boolean))].slice(0, 20);
101
+
102
+ const variants = [];
103
+ const seen = new Set();
104
+ for (const [i, v] of (Array.isArray(input.variants) ? input.variants : []).slice(0, MAX_VARIANTS).entries()) {
105
+ const vName = text(v?.name, 80);
106
+ if (!vName) { errors[`variant${i}`] = 'Each option needs a name.'; continue; }
107
+ if (seen.has(vName.toLowerCase())) { errors[`variant${i}`] = `"${vName}" is listed twice.`; continue; }
108
+ seen.add(vName.toLowerCase());
109
+ const vPrice = amount(v.price, currency, {minor});
110
+ const vStock = wholeOrNull(v.stock);
111
+ if (Number.isNaN(vPrice)) errors[`variant${i}`] = `${vName}: not a price.`;
112
+ if (Number.isNaN(vStock)) errors[`variant${i}`] = `${vName}: stock is a whole number.`;
113
+ variants.push({
114
+ id: /^[a-z0-9]{4,16}$/.test(v.id || '') ? v.id : newId(),
115
+ name: vName,
116
+ price: Number.isInteger(vPrice) ? vPrice : null,
117
+ sku: text(v.sku, 60),
118
+ stock: Number.isInteger(vStock) ? vStock : null
119
+ });
120
+ }
121
+
122
+ const weight = wholeOrNull(input.weight, {max: 1e7});
123
+ if (Number.isNaN(weight)) errors.weight = 'Grams, a whole number.';
124
+
125
+ const data = {
126
+ name,
127
+ slug,
128
+ summary: text(input.summary, 300),
129
+ description: text(input.description, 20000),
130
+ price: Number.isInteger(price) ? price : 0,
131
+ compareAt: Number.isInteger(compareAt) ? compareAt : null,
132
+ sku: text(input.sku, 60),
133
+ images,
134
+ category: text(input.category, 60),
135
+ tags,
136
+ status,
137
+ featured: Boolean(input.featured),
138
+ position: Number.isFinite(Number(input.position)) ? Math.round(Number(input.position)) : 0,
139
+ trackStock,
140
+ stock: Number.isInteger(stock) ? stock : (trackStock && !variants.length ? 0 : null),
141
+ optionName: text(input.optionName, 40) || (variants.length ? 'Option' : ''),
142
+ variants,
143
+ shippable: input.shippable === undefined ? true : Boolean(input.shippable),
144
+ weight: Number.isInteger(weight) ? weight : null,
145
+ taxClass: TAX_CLASSES.includes(input.taxClass) ? input.taxClass : 'standard'
146
+ };
147
+ return {data, errors};
148
+ }
149
+
150
+ /** A variant of a product, or null. With no variants, the product itself (id ''). */
151
+ export function variantOf(product, variantId = '') {
152
+ const vs = product?.variants || [];
153
+ if (!vs.length) return variantId ? null : {id: '', name: '', price: null, sku: product?.sku || '', stock: product?.stock ?? null};
154
+ return vs.find(v => v.id === variantId) || null;
155
+ }
156
+
157
+ /** Unit price of a product (or one of its variants), in minor units. */
158
+ export function unitPrice(product, variantId = '') {
159
+ const v = variantOf(product, variantId);
160
+ return v && Number.isInteger(v.price) ? v.price : (product?.price || 0);
161
+ }
162
+
163
+ /**
164
+ * How many can be bought: Infinity when stock is not counted.
165
+ *
166
+ * @param {object} product
167
+ * @param {string} [variantId]
168
+ */
169
+ export function available(product, variantId = '') {
170
+ if (!product?.trackStock) return Infinity;
171
+ const v = variantOf(product, variantId);
172
+ if (!v) return 0;
173
+ const n = product.variants?.length ? v.stock : product.stock;
174
+ return Number.isInteger(n) && n > 0 ? n : 0;
175
+ }
176
+
177
+ /** All stock of a product, summed over its variants; null when not counted. */
178
+ export function totalStock(product) {
179
+ if (!product?.trackStock) return null;
180
+ if (product.variants?.length) return product.variants.reduce((s, v) => s + (Number(v.stock) || 0), 0);
181
+ return Number(product.stock) || 0;
182
+ }
183
+
184
+ /** The lowest and highest price across variants - "From £8" on a card. */
185
+ export function priceRange(product) {
186
+ const prices = product?.variants?.length
187
+ ? product.variants.map(v => (Number.isInteger(v.price) ? v.price : product.price))
188
+ : [product?.price || 0];
189
+ return {min: Math.min(...prices), max: Math.max(...prices)};
190
+ }
191
+
192
+ /**
193
+ * The fields a shopper may see. Never the cost of anything internal: no
194
+ * position, no SKU-level stock numbers beyond "how many can I buy".
195
+ *
196
+ * @param {{id: string, data: object}} entry
197
+ */
198
+ export function publicProduct(entry) {
199
+ const p = entry.data || {};
200
+ const range = priceRange(p);
201
+ return {
202
+ id: entry.id,
203
+ name: p.name,
204
+ slug: p.slug,
205
+ summary: p.summary || '',
206
+ description: p.description || '',
207
+ price: p.price,
208
+ compareAt: p.compareAt ?? null,
209
+ images: p.images || [],
210
+ category: p.category || '',
211
+ tags: p.tags || [],
212
+ featured: Boolean(p.featured),
213
+ optionName: p.optionName || '',
214
+ variants: (p.variants || []).map(v => ({
215
+ id: v.id, name: v.name,
216
+ price: Number.isInteger(v.price) ? v.price : p.price,
217
+ available: finite(available(p, v.id))
218
+ })),
219
+ available: finite(p.variants?.length ? totalStock(p) ?? Infinity : available(p)),
220
+ minPrice: range.min,
221
+ maxPrice: range.max,
222
+ shippable: p.shippable !== false
223
+ };
224
+ }
225
+
226
+ /** JSON has no Infinity: unlimited travels as null. */
227
+ const finite = (n) => (n === Infinity || n === null ? null : n);
228
+
229
+ /** Sort for a storefront: featured first, then the admin's position, then name. */
230
+ export function byShelf(a, b) {
231
+ const pa = a.data || a, pb = b.data || b;
232
+ return (Number(pb.featured) - Number(pa.featured))
233
+ || ((pa.position || 0) - (pb.position || 0))
234
+ || String(pa.name).localeCompare(String(pb.name));
235
+ }
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f3e3c7"/><stop offset="1" stop-color="#d9b37c"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/><circle cx="640" cy="180" r="220" fill="#fff" opacity=".18"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+ <g transform="translate(400 520) scale(1.12) translate(-400 -520)">
7
+ <rect x="270" y="420" width="260" height="360" rx="30" fill="#fbf7ef"/>
8
+ <rect x="270" y="420" width="260" height="360" rx="30" fill="none" stroke="#e7dcc9" stroke-width="6"/>
9
+ <rect x="300" y="540" width="200" height="140" rx="12" fill="#2d2a32"/>
10
+ <text x="400" y="600" text-anchor="middle" font-family="Georgia,serif" font-size="34" fill="#f3e3c7" letter-spacing="4">FIG</text>
11
+ <text x="400" y="645" text-anchor="middle" font-family="Georgia,serif" font-size="20" fill="#d9b37c" letter-spacing="6">&amp; CEDAR</text>
12
+ <ellipse cx="400" cy="420" rx="130" ry="22" fill="#efe6d6"/>
13
+ <path d="M400 420v-50" stroke="#2d2a32" stroke-width="6"/>
14
+ <path d="M400 290c35 40 30 80 0 90c-30-10-35-50 0-90z" fill="#ffb347"/>
15
+ <path d="M400 325c15 20 12 40 0 45c-12-5-15-25 0-45z" fill="#fff3c4"/></g>
16
+ </svg>
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f3e3c7"/><stop offset="1" stop-color="#d9b37c"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+
7
+ <rect x="270" y="420" width="260" height="360" rx="30" fill="#fbf7ef"/>
8
+ <rect x="270" y="420" width="260" height="360" rx="30" fill="none" stroke="#e7dcc9" stroke-width="6"/>
9
+ <rect x="300" y="540" width="200" height="140" rx="12" fill="#2d2a32"/>
10
+ <text x="400" y="600" text-anchor="middle" font-family="Georgia,serif" font-size="34" fill="#f3e3c7" letter-spacing="4">FIG</text>
11
+ <text x="400" y="645" text-anchor="middle" font-family="Georgia,serif" font-size="20" fill="#d9b37c" letter-spacing="6">&amp; CEDAR</text>
12
+ <ellipse cx="400" cy="420" rx="130" ry="22" fill="#efe6d6"/>
13
+ <path d="M400 420v-50" stroke="#2d2a32" stroke-width="6"/>
14
+ <path d="M400 290c35 40 30 80 0 90c-30-10-35-50 0-90z" fill="#ffb347"/>
15
+ <path d="M400 325c15 20 12 40 0 45c-12-5-15-25 0-45z" fill="#fff3c4"/>
16
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f6d8c4"/><stop offset="1" stop-color="#e9a98b"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/><circle cx="640" cy="180" r="220" fill="#fff" opacity=".18"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+ <g transform="translate(400 520) scale(1.12) translate(-400 -520)">
7
+ <path d="M560 470c70 0 110 40 110 100s-45 105-120 105" fill="none" stroke="#2f4858" stroke-width="42" stroke-linecap="round"/>
8
+ <path d="M230 380h340v330c0 55-45 95-100 95H330c-55 0-100-40-100-95z" fill="#2f4858"/>
9
+ <path d="M230 380h340v70H230z" fill="#3d5d72"/>
10
+ <ellipse cx="400" cy="380" rx="170" ry="30" fill="#24394a"/>
11
+ <ellipse cx="400" cy="376" rx="150" ry="20" fill="#8b5a3c"/>
12
+ <path d="M270 520c40 10 90 10 130 0s90-10 130 0" fill="none" stroke="#f6d8c4" stroke-width="10" stroke-linecap="round" opacity=".7"/>
13
+ <path d="M350 330c-15-30 15-45 0-80M410 320c-15-30 15-45 0-80M470 330c-15-30 15-45 0-80" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" opacity=".55"/></g>
14
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f6d8c4"/><stop offset="1" stop-color="#e9a98b"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+
7
+ <path d="M560 470c70 0 110 40 110 100s-45 105-120 105" fill="none" stroke="#2f4858" stroke-width="42" stroke-linecap="round"/>
8
+ <path d="M230 380h340v330c0 55-45 95-100 95H330c-55 0-100-40-100-95z" fill="#2f4858"/>
9
+ <path d="M230 380h340v70H230z" fill="#3d5d72"/>
10
+ <ellipse cx="400" cy="380" rx="170" ry="30" fill="#24394a"/>
11
+ <ellipse cx="400" cy="376" rx="150" ry="20" fill="#8b5a3c"/>
12
+ <path d="M270 520c40 10 90 10 130 0s90-10 130 0" fill="none" stroke="#f6d8c4" stroke-width="10" stroke-linecap="round" opacity=".7"/>
13
+ <path d="M350 330c-15-30 15-45 0-80M410 320c-15-30 15-45 0-80M470 330c-15-30 15-45 0-80" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" opacity=".55"/>
14
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d8dcf0"/><stop offset="1" stop-color="#9aa3d6"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/><circle cx="640" cy="180" r="220" fill="#fff" opacity=".18"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+ <g transform="translate(400 520) scale(1.12) translate(-400 -520)">
7
+ <g transform="rotate(-6 400 520)">
8
+ <rect x="240" y="250" width="330" height="480" rx="18" fill="#2b2d5c"/>
9
+ <rect x="240" y="250" width="40" height="480" rx="10" fill="#1e2046"/>
10
+ <rect x="545" y="250" width="14" height="480" fill="#f07167"/>
11
+ <rect x="330" y="360" width="190" height="110" rx="8" fill="none" stroke="#c9cdf2" stroke-width="5"/>
12
+ <text x="425" y="425" text-anchor="middle" font-family="Georgia,serif" font-size="30" fill="#e7e9fb" letter-spacing="3">NOTES</text>
13
+ <path d="M565 700l40 60 10-70" fill="#f07167"/>
14
+ </g></g>
15
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d8dcf0"/><stop offset="1" stop-color="#9aa3d6"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+
7
+ <g transform="rotate(-6 400 520)">
8
+ <rect x="240" y="250" width="330" height="480" rx="18" fill="#2b2d5c"/>
9
+ <rect x="240" y="250" width="40" height="480" rx="10" fill="#1e2046"/>
10
+ <rect x="545" y="250" width="14" height="480" fill="#f07167"/>
11
+ <rect x="330" y="360" width="190" height="110" rx="8" fill="none" stroke="#c9cdf2" stroke-width="5"/>
12
+ <text x="425" y="425" text-anchor="middle" font-family="Georgia,serif" font-size="30" fill="#e7e9fb" letter-spacing="3">NOTES</text>
13
+ <path d="M565 700l40 60 10-70" fill="#f07167"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#efe7df"/><stop offset="1" stop-color="#cdbcb0"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/><circle cx="640" cy="180" r="220" fill="#fff" opacity=".18"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+ <g transform="translate(400 520) scale(1.12) translate(-400 -520)">
7
+ <rect x="215" y="200" width="370" height="560" fill="#3b3130"/>
8
+ <rect x="245" y="230" width="310" height="500" fill="#fdfaf5"/>
9
+ <rect x="275" y="260" width="250" height="360" fill="#f4d6b8"/>
10
+ <circle cx="450" cy="340" r="42" fill="#e76f51"/>
11
+ <path d="M275 620l80-120 60 70 45-50 65 100z" fill="#2a9d8f"/>
12
+ <path d="M275 620l110-160 140 160z" fill="#264653" opacity=".85"/>
13
+ <text x="400" y="680" text-anchor="middle" font-family="Georgia,serif" font-size="22" fill="#3b3130" letter-spacing="5">DUSK</text></g>
14
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#efe7df"/><stop offset="1" stop-color="#cdbcb0"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+
7
+ <rect x="215" y="200" width="370" height="560" fill="#3b3130"/>
8
+ <rect x="245" y="230" width="310" height="500" fill="#fdfaf5"/>
9
+ <rect x="275" y="260" width="250" height="360" fill="#f4d6b8"/>
10
+ <circle cx="450" cy="340" r="42" fill="#e76f51"/>
11
+ <path d="M275 620l80-120 60 70 45-50 65 100z" fill="#2a9d8f"/>
12
+ <path d="M275 620l110-160 140 160z" fill="#264653" opacity=".85"/>
13
+ <text x="400" y="680" text-anchor="middle" font-family="Georgia,serif" font-size="22" fill="#3b3130" letter-spacing="5">DUSK</text>
14
+ </svg>
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe6ea"/><stop offset="1" stop-color="#7fb5bf"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/><circle cx="640" cy="180" r="220" fill="#fff" opacity=".18"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+ <g transform="translate(400 520) scale(1.12) translate(-400 -520)">
7
+ <path d="M300 250l-150 80 60 140 70-30v360h340V440l70 30 60-140-150-80c-10 50-50 80-100 80s-90-30-100-80z" fill="#fbfbf8"/>
8
+ <path d="M300 250c10 50 50 80 100 80s90-30 100-80" fill="none" stroke="#dde3e4" stroke-width="10"/>
9
+ <circle cx="400" cy="540" r="70" fill="#f28f6b"/>
10
+ <path d="M330 560h140" stroke="#fbfbf8" stroke-width="10"/>
11
+ <path d="M345 590h110M362 620h76" stroke="#f28f6b" stroke-width="10" stroke-linecap="round"/></g>
12
+ </svg>
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe6ea"/><stop offset="1" stop-color="#7fb5bf"/></linearGradient>
3
+ <radialGradient id="glow" cx=".3" cy=".2" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs>
4
+ <rect width="800" height="1000" fill="url(#bg)"/><rect width="800" height="1000" fill="url(#glow)"/>
5
+ <ellipse cx="400" cy="800" rx="230" ry="26" fill="#000" opacity=".13"/>
6
+
7
+ <path d="M300 250l-150 80 60 140 70-30v360h340V440l70 30 60-140-150-80c-10 50-50 80-100 80s-90-30-100-80z" fill="#fbfbf8"/>
8
+ <path d="M300 250c10 50 50 80 100 80s90-30 100-80" fill="none" stroke="#dde3e4" stroke-width="10"/>
9
+ <circle cx="400" cy="540" r="70" fill="#f28f6b"/>
10
+ <path d="M330 560h140" stroke="#fbfbf8" stroke-width="10"/>
11
+ <path d="M345 590h110M362 620h76" stroke="#f28f6b" stroke-width="10" stroke-linecap="round"/>
12
+ </svg>