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
@@ -1 +1 @@
1
- export const THEMES=[{id:"charcoal-dark",label:"Charcoal",dark:!0,primary:"#5b8cff",bg:"#1a1d23"},{id:"charcoal-light",label:"Charcoal",dark:!1,primary:"#4a7aff",bg:"#f4f5f7"},{id:"ocean-dark",label:"Ocean",dark:!0,primary:"#00b4d8",bg:"#0d1b2a"},{id:"ocean-light",label:"Ocean",dark:!1,primary:"#0096c7",bg:"#e8f4f8"},{id:"forest-dark",label:"Forest",dark:!0,primary:"#52b788",bg:"#1a231e"},{id:"forest-light",label:"Forest",dark:!1,primary:"#40916c",bg:"#f0f7f4"},{id:"sunset-dark",label:"Sunset",dark:!0,primary:"#ff6b6b",bg:"#1f1a1a"},{id:"sunset-light",label:"Sunset",dark:!1,primary:"#e05555",bg:"#fff0f0"},{id:"royal-dark",label:"Royal",dark:!0,primary:"#9b59b6",bg:"#1a1525"},{id:"royal-light",label:"Royal",dark:!1,primary:"#8e44ad",bg:"#f5f0fa"},{id:"lemon-dark",label:"Lemon",dark:!0,primary:"#f1c40f",bg:"#1a1a10"},{id:"lemon-light",label:"Lemon",dark:!1,primary:"#d4ac0d",bg:"#fffef0"},{id:"silver-dark",label:"Silver",dark:!0,primary:"#95a5a6",bg:"#1c1e20"},{id:"silver-light",label:"Silver",dark:!1,primary:"#7f8c8d",bg:"#f2f3f4"},{id:"grayve-dark",label:"Grayve",dark:!0,primary:"#00bcd4",bg:"#1a1e21"},{id:"grayve-light",label:"Grayve",dark:!1,primary:"#00838f",bg:"#ffffff"},{id:"mint-dark",label:"Mint",dark:!0,primary:"#00c896",bg:"#0f1f1a"},{id:"mint-light",label:"Mint",dark:!1,primary:"#00a878",bg:"#f0faf6"},{id:"unicorn-dark",label:"Unicorn",dark:!0,primary:"#e040fb",bg:"#1a0a1e"},{id:"unicorn-light",label:"Unicorn",dark:!1,primary:"#ba68c8",bg:"#fdf5ff"},{id:"dreamy-dark",label:"Dreamy",dark:!0,primary:"#7986cb",bg:"#15152a"},{id:"dreamy-light",label:"Dreamy",dark:!1,primary:"#5c6bc0",bg:"#f3f4fd"},{id:"christmas-dark",label:"Christmas",dark:!0,primary:"#e53935",bg:"#0a1a0a"},{id:"christmas-light",label:"Christmas",dark:!1,primary:"#c62828",bg:"#f5fff5"},{id:"wedding-dark",label:"Wedding",dark:!0,primary:"#ec407a",bg:"#1a0e14"},{id:"wedding-light",label:"Wedding",dark:!1,primary:"#e91e63",bg:"#fff5f8"},{id:"admin-sharp-indigo",label:"Admin \xB7 Sharp",name:"Indigo",admin:!0,dark:!1,primary:"#5b63a8",bg:"#eef1f6"},{id:"admin-sharp-steel",label:"Admin \xB7 Sharp",name:"Steel",admin:!0,dark:!1,primary:"#3b76bc",bg:"#eef1f6"},{id:"admin-sharp-teal",label:"Admin \xB7 Sharp",name:"Teal",admin:!0,dark:!1,primary:"#2a8178",bg:"#eef1f6"},{id:"admin-smooth-indigo",label:"Admin \xB7 Smooth",name:"Indigo",admin:!0,dark:!0,primary:"#5b63a8",bg:"#586170"},{id:"admin-smooth-steel",label:"Admin \xB7 Smooth",name:"Steel",admin:!0,dark:!0,primary:"#3b76bc",bg:"#586170"},{id:"admin-smooth-teal",label:"Admin \xB7 Smooth",name:"Teal",admin:!0,dark:!0,primary:"#2a8178",bg:"#586170"}];export function paletteOrder(r){const d=[...new Set(r.map(a=>a.label))],e=a=>a.toLowerCase().startsWith("admin")?1:0;return d.sort((a,l)=>e(a)-e(l)||a.localeCompare(l))}export function themesInDisplayOrder(r=THEMES){const d=paletteOrder(r);return[...r].sort((e,a)=>d.indexOf(e.label)-d.indexOf(a.label)||(e.dark===a.dark?0:e.dark?-1:1))}export function populateThemeSelect(r,{includeDefault:d=!1,defaultLabel:e="Use site default",includeAdmin:a=!1}={}){for(;r.firstChild;)r.removeChild(r.firstChild);if(d){const i=document.createElement("option");i.value="",i.textContent=e,r.appendChild(i)}const l=THEMES.filter(i=>a||!i.admin);for(const i of paletteOrder(l)){const n=document.createElement("optgroup");n.label=i;for(const f of l.filter(t=>t.label===i)){const t=document.createElement("option");t.value=f.id,t.textContent=f.name||`${f.label} ${f.dark?"Dark":"Light"}`,n.appendChild(t)}r.appendChild(n)}}
1
+ export const THEMES=[{id:"charcoal-dark",label:"Charcoal",dark:!0,primary:"#5b8cff",bg:"#1a1d23"},{id:"charcoal-light",label:"Charcoal",dark:!1,primary:"#4a7aff",bg:"#f4f5f7"},{id:"ocean-dark",label:"Ocean",dark:!0,primary:"#00b4d8",bg:"#0d1b2a"},{id:"ocean-light",label:"Ocean",dark:!1,primary:"#0096c7",bg:"#e8f4f8"},{id:"forest-dark",label:"Forest",dark:!0,primary:"#52b788",bg:"#1a231e"},{id:"forest-light",label:"Forest",dark:!1,primary:"#40916c",bg:"#f0f7f4"},{id:"sunset-dark",label:"Sunset",dark:!0,primary:"#ff6b6b",bg:"#1f1a1a"},{id:"sunset-light",label:"Sunset",dark:!1,primary:"#e05555",bg:"#fff0f0"},{id:"royal-dark",label:"Royal",dark:!0,primary:"#9b59b6",bg:"#1a1525"},{id:"royal-light",label:"Royal",dark:!1,primary:"#8e44ad",bg:"#f5f0fa"},{id:"lemon-dark",label:"Lemon",dark:!0,primary:"#f1c40f",bg:"#1a1a10"},{id:"lemon-light",label:"Lemon",dark:!1,primary:"#d4ac0d",bg:"#fffef0"},{id:"silver-dark",label:"Silver",dark:!0,primary:"#95a5a6",bg:"#1c1e20"},{id:"silver-light",label:"Silver",dark:!1,primary:"#7f8c8d",bg:"#f2f3f4"},{id:"grayve-dark",label:"Grayve",dark:!0,primary:"#00bcd4",bg:"#1a1e21"},{id:"grayve-light",label:"Grayve",dark:!1,primary:"#00838f",bg:"#ffffff"},{id:"mint-dark",label:"Mint",dark:!0,primary:"#00c896",bg:"#0f1f1a"},{id:"mint-light",label:"Mint",dark:!1,primary:"#00a878",bg:"#f0faf6"},{id:"unicorn-dark",label:"Unicorn",dark:!0,primary:"#e040fb",bg:"#1a0a1e"},{id:"unicorn-light",label:"Unicorn",dark:!1,primary:"#ba68c8",bg:"#fdf5ff"},{id:"dreamy-dark",label:"Dreamy",dark:!0,primary:"#7986cb",bg:"#15152a"},{id:"dreamy-light",label:"Dreamy",dark:!1,primary:"#5c6bc0",bg:"#f3f4fd"},{id:"christmas-dark",label:"Christmas",dark:!0,primary:"#e53935",bg:"#0a1a0a"},{id:"christmas-light",label:"Christmas",dark:!1,primary:"#c62828",bg:"#f5fff5"},{id:"wedding-dark",label:"Wedding",dark:!0,primary:"#ec407a",bg:"#1a0e14"},{id:"wedding-light",label:"Wedding",dark:!1,primary:"#e91e63",bg:"#fff5f8"},{id:"corporate-dark",label:"Corporate",dark:!0,primary:"#2563eb",bg:"#0b0f19"},{id:"corporate-light",label:"Corporate",dark:!1,primary:"#1e40af",bg:"#f8fafc"},{id:"admin-sharp-indigo",label:"Admin \xB7 Sharp",name:"Indigo",admin:!0,dark:!1,primary:"#5b63a8",bg:"#eef1f6"},{id:"admin-sharp-steel",label:"Admin \xB7 Sharp",name:"Steel",admin:!0,dark:!1,primary:"#3b76bc",bg:"#eef1f6"},{id:"admin-sharp-teal",label:"Admin \xB7 Sharp",name:"Teal",admin:!0,dark:!1,primary:"#2a8178",bg:"#eef1f6"},{id:"admin-slate-indigo",label:"Admin \xB7 Slate",name:"Indigo",admin:!0,dark:!0,primary:"#5b63a8",bg:"#252c37"},{id:"admin-slate-steel",label:"Admin \xB7 Slate",name:"Steel",admin:!0,dark:!0,primary:"#3b76bc",bg:"#252c37"},{id:"admin-slate-teal",label:"Admin \xB7 Slate",name:"Teal",admin:!0,dark:!0,primary:"#2a8178",bg:"#252c37"},{id:"admin-smooth-indigo",label:"Admin \xB7 Smooth",name:"Indigo",admin:!0,dark:!0,primary:"#5b63a8",bg:"#586170"},{id:"admin-smooth-steel",label:"Admin \xB7 Smooth",name:"Steel",admin:!0,dark:!0,primary:"#3b76bc",bg:"#586170"},{id:"admin-smooth-teal",label:"Admin \xB7 Smooth",name:"Teal",admin:!0,dark:!0,primary:"#2a8178",bg:"#586170"}];export function paletteOrder(e){const d=[...new Set(e.map(a=>a.label))],r=a=>a.toLowerCase().startsWith("admin")?1:0;return d.sort((a,l)=>r(a)-r(l)||a.localeCompare(l))}export function themesInDisplayOrder(e=THEMES){const d=paletteOrder(e);return[...e].sort((r,a)=>d.indexOf(r.label)-d.indexOf(a.label)||(r.dark===a.dark?0:r.dark?-1:1))}export function populateThemeSelect(e,{includeDefault:d=!1,defaultLabel:r="Use site default",includeAdmin:a=!1}={}){for(;e.firstChild;)e.removeChild(e.firstChild);if(d){const i=document.createElement("option");i.value="",i.textContent=r,e.appendChild(i)}const l=THEMES.filter(i=>a||!i.admin);for(const i of paletteOrder(l)){const f=document.createElement("optgroup");f.label=i;for(const n of l.filter(t=>t.label===i)){const t=document.createElement("option");t.value=n.id,t.textContent=n.name||`${n.label} ${n.dark?"Dark":"Light"}`,f.appendChild(t)}e.appendChild(f)}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.84.1",
3
+ "version": "0.85.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -83,7 +83,7 @@
83
83
  "@fastify/rate-limit": "^10.3.0",
84
84
  "@fastify/static": "9.1.1",
85
85
  "bcryptjs": "^3.0.3",
86
- "domma-js": "0.44.5",
86
+ "domma-js": "0.44.6",
87
87
  "dotenv": "^17.2.3",
88
88
  "fastify": "5.8.5",
89
89
  "gray-matter": "^4.0.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@46444b1",
3
+ "source": "dcms-marketplace@9bdfedf",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.6.3",
@@ -99,6 +99,54 @@
99
99
  "server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
100
100
  "tests/security.test.js": "951636a0e42ac8dbf78b69ca7e7e2adcd4e29d94a51998e738c3afa742c00866"
101
101
  }
102
+ },
103
+ "shopping-cart": {
104
+ "version": "1.0.0",
105
+ "files": {
106
+ "CLAUDE.md": "0f89bab23796eba425e26792c952eb00eddb09fb2c74a4a7a1fb3722437dae0b",
107
+ "admin/css/index.css": "a6bab1d0072e364ee1d94c2d7d7b99c3660ba91c5eba537e269a579f5b17f924",
108
+ "admin/lib/kit.js": "3e721a53a97f136833975f99d9f985fcec4018b328b4d355267fe11702234fa0",
109
+ "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
110
+ "admin/views/orders.js": "8255b75a4a0eeccec7cf76982a5f64469ab7a2ad5f742a54afe55bca61ace713",
111
+ "admin/views/overview.js": "3901dfb9527fed0689bb31098488136471ff081b9fe28979339f39b2d53e3bd3",
112
+ "admin/views/products.js": "4947de61aa57da59fd0e819ac0f5380bbdee6b73855bd91d3872445fdd6c3e2d",
113
+ "admin/views/settings.js": "28cb0b5e8d45e3723ffe77e46299bfc13fbad02a2ae8103843c6894dc232387a",
114
+ "admin/views/shop.js": "a53a1793251b962a87c5d01685f934b6476bcfcdd51e3dea3a319eb8c03a4137",
115
+ "collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
116
+ "collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
117
+ "config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
118
+ "inject/body.html": "de20f92c83229545d20e5186fd37ef0046a8820881bff34e7df5391cd7a2c75d",
119
+ "lib/extensions.js": "2cba90efef5b437ace557c7265fe7363ac7923d155681087a63e31e1168f6601",
120
+ "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
121
+ "lib/pricing.js": "94953075062e9b36cdbd6ea149662d013d97ee12359a9ff7459669beff99ab99",
122
+ "lib/render.js": "3eacb4f7bf62376873e6fe59e2fe55e72a97fda8ed6bff73b93791f568cceffd",
123
+ "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
124
+ "lib/shop.js": "36e1cea342e23f1a53b9fac63b993f860afc1bcb74b25bef9f7bf8eaf07d2d85",
125
+ "lib/summary.js": "a510386e28eb19d9617f488551d1057819ae9f76fc85c744f2e5dbce492607fe",
126
+ "plugin.js": "594c2174e36566f90ac1e9ff11cb76656ad111dc392dc8e9556d731b7dd0a41a",
127
+ "plugin.json": "d5a3aeebb422cde97ac30605e8706acfb39e69601e7e6ae5d626c3744d49093c",
128
+ "plugin.public.js": "176383dfa7af913c433e0397584c46a67a21ab3307b80a08f0aaf50da2634f9e",
129
+ "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
130
+ "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
131
+ "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
132
+ "public/lib/products.js": "c698dcb509dfc09a135e4039ec085dda8a3606e7f9f9586ea0fcf068f9147438",
133
+ "public/samples/candle-2.svg": "aca5691b9168467caf6feb7622ab154ee92504be8b887ac6c818db22476e7bed",
134
+ "public/samples/candle.svg": "ef75e95a53ce13e88928004c6f1290c45a1094765e2ba741ae1fc55d404b881f",
135
+ "public/samples/mug-2.svg": "5f37d23e90b7e79e42d38009a56b3ea26adae03586830a0cd418565491bd4532",
136
+ "public/samples/mug.svg": "e29e268fae300b95e38021443aa6df57e6a4dcb14c4058e5ef7106eb9bc1be6c",
137
+ "public/samples/notebook-2.svg": "21fbeaaab182485b3b9dde4e3b9d83cf3f9cadd5825225632cda14f0453c9538",
138
+ "public/samples/notebook.svg": "ff659345f465324199858f992e779aa7e0c4308e3cc39f15f7d5a0d4618b21d0",
139
+ "public/samples/print-2.svg": "09385dc66f5902caa399c6a4cc11ae320a779cbd355b7965d573483af9ccb818",
140
+ "public/samples/print.svg": "47e0f0960231b81b2d03f205c9b86e03f7c22011a2f243cfa915d08a73de377d",
141
+ "public/samples/tee-2.svg": "aaec073d128b3ea90e58225dd34daca8cbc39aa0f3112d2bc65119284718b5e4",
142
+ "public/samples/tee.svg": "287a82d7fc00acb1543b3209927ae20dcbe3d4b2a2b3de03574ab0c463385f3c",
143
+ "public/samples/tote-2.svg": "9c7f41cc57866e71973e413a6c87f46be48f7488d3490ceea58e24559c334813",
144
+ "public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
145
+ "public/shop.css": "277904382e491f0ecc76aad916126970b87e161ef06ac6deeff193358b4e4c31",
146
+ "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
147
+ "tests/api.test.js": "a806df62981574a98d435eefc98143b2b03071b5f58a4e7db3cab93850c0e8ae",
148
+ "tests/pure.test.js": "dbe35ed7e94cea2f96d42d5abe7d41173393180ff5764c0539799329aa61bfa0"
149
+ }
102
150
  }
103
151
  }
104
152
  }
@@ -0,0 +1,93 @@
1
+ # shopping-cart - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Sell from a Domma CMS site. 1.0.0 (2026-09-26): products with options (size, colour) each with its own price,
6
+ SKU and stock; a storefront at a configurable address (`/shop`) with category chips, search and sort; product
7
+ pages with a gallery; a basket kept in the shopper's browser that follows them round the site (floating
8
+ button + drawer on every page); one-page checkout; Stripe (hosted Checkout), PayPal (Orders v2) or payment
9
+ offline; a receipt page that waits for the payment to be confirmed; orders in the admin with fulfil (tracking),
10
+ refund (through the provider), cancel, packing slips; an Overview with takings and what needs doing; a sidebar
11
+ badge for paid orders to fulfil; sample products with bundled pictures.
12
+
13
+ `tier: free`. Shopping Cart Pro (plugins/shopping-cart-pro) is an ADD-ON that plugs into this one - it does not
14
+ supersede it (see Extensions).
15
+
16
+ ## The one rule about money
17
+
18
+ Every amount is an integer in MINOR units (pence) - products, orders, settings (`shippingFlat: 395` = £3.95).
19
+ Only the edges convert: the admin's inputs (`kit.moneyIn/moneyOut`), display (`formatMoney`), PayPal's decimal
20
+ string (`fromMinor`). The browser never sends a price: `POST /quote` and `POST /checkout` take product ids,
21
+ option ids and quantities, and `lib/pricing.js` `priceBasket` is the ONE place a total is worked out. Checkout
22
+ refuses (409, with the new quote) when the basket changed (sold out, fewer left, a code refused) until the
23
+ shopper resubmits with `accept`, and when `expectTotal` differs from what it would charge.
24
+
25
+ ## Files
26
+
27
+ - `plugin.json`, `config.js` (every setting, documented), `plugin.js` (API, shortcodes, payments' return routes)
28
+ - `plugin.public.js` - storefront pages from ONE onRequest hook on the LIVE shop path (like the Blog), so
29
+ moving the shop needs no restart. `matchShopPath` is exported and tested.
30
+ - `public/lib/money.js`, `products.js`, `orders.js` - PURE, shared by server, admin and storefront (public/ is
31
+ served; lib/ is not). Product cleaning, stock (`available`, `totalStock`), order statuses and moves (`NEXT`).
32
+ - `lib/pricing.js` - the basket's price: lines (capped at stock), then stages discount -> shipping -> tax.
33
+ - `lib/extensions.js` - the registry extensions register into (globalThis, Symbol.for('domma.shopping-cart')).
34
+ - `lib/shop.js` - collections I/O, live settings (masking, cleaning), order numbers, `markPaid` / `moveOrder` /
35
+ refunds with stock, all order writes serialised by `withLock`. `shelfNow`/`settingsNow` for the shortcodes.
36
+ - `lib/payments.js` - Stripe and PayPal over fetch (no SDK). `stripeVerify` checks webhook signatures.
37
+ - `lib/render.js` - the storefront's HTML (server-rendered; everything escaped; descriptions via parseMarkdown).
38
+ - `lib/summary.js` - Overview figures and the badge. `lib/samples.js` + `public/samples/*.svg` - sample products.
39
+ - `public/cart.js`, `public/shop.css` - the browser half, injected on every page (`inject/body.html`).
40
+ - `templates/shop.html` - the fragment every storefront page renders in (`renderBlogPage`).
41
+ - `admin/views/shop.js` - the Tool: tab host (Overview, Orders, Products + extension tabs), banner cog/visit/+.
42
+ `overview.js`, `orders.js`, `products.js`, `settings.js` (the cog's slideover). `admin/lib/kit.js` - shared.
43
+ - `tests/pure.test.js` (money, products, pricing, registry, payments, paths, settings, summary),
44
+ `tests/api.test.js` (a whole sale against the real collection service; restores the checkout).
45
+
46
+ ## Data
47
+
48
+ Collections `shop-products` and `shop-orders` (made at every boot if missing; API access all off - orders hold
49
+ shoppers' details). Settings: `config/plugins.json` `shopping-cart.settings`; the order counter is plugin state
50
+ `shopping-cart.orderSeq` (never below the highest order number, starting 1001).
51
+
52
+ Order statuses: pending (checkout started) -> awaiting (offline) -> paid -> fulfilled; cancelled; refunded.
53
+ Stock is taken when an order becomes PAID (`stockTaken`), never at checkout, and can go back on a refund.
54
+ A paid or refunded order cannot be deleted. Each order has a random `token`: the receipt URL is
55
+ `<shop>/order/<id>?t=<token>`; without the token it is a 404.
56
+
57
+ ## Payments
58
+
59
+ - Stripe: `POST /checkout` makes a Checkout Session (itemised; ONE "Order #n" line when there is a discount,
60
+ since Stripe discounts only through its coupons), success_url = the receipt + `session_id`. The receipt page
61
+ (and `GET /order/:id`) asks Stripe and marks the order paid when `stripeSessionPaid` agrees on order id,
62
+ amount and currency. The webhook (`/stripe/webhook`, raw body, `stripeWebhookSecret`) catches a closed tab.
63
+ - PayPal: create order -> approve -> `GET /paypal/return` CAPTURES (ORDER_ALREADY_CAPTURED reads it instead)
64
+ and checks `paypalPaid`. Sandbox/live by setting.
65
+ - Offline: the order is `awaiting`; the receipt shows `offlineInstructions`. Mark paid from the admin.
66
+ - Refunds: `POST /orders/:id/refund` refunds through Stripe (payment_intent) or PayPal (capture id) FIRST,
67
+ and only then marks the order refunded. Full refunds only.
68
+ - Secrets never leave the server: GET /settings masks them (`••••••••`); saving the mask keeps the value.
69
+
70
+ ## Extensions (Shopping Cart Pro)
71
+
72
+ `cartRegistry().register(owner, {stages, on, tabs, checkout})` - see lib/extensions.js. A shipping handler may also
73
+ return `choices: [{id, label, amount}]` - the quote carries them as `shippingChoices` (+ `shippingChoice`, the
74
+ picked one's `ref`) and checkout offers them when there are two or more, sending `service` back. A stage handler that
75
+ throws falls back to the built-in rule (and an internal note), so an extension cannot take checkout down.
76
+ `emit()` never throws or waits. `registry.services` gives extensions the Cart's data functions. Extension tabs
77
+ are dynamic-imported by shop.js from `/plugins/<owner>/...` only, and get the same ctx as the built-in tabs.
78
+ An extension registers from `onReady`; a lapsed licence means it never registers and nothing else changes.
79
+
80
+ ## Shortcodes
81
+
82
+ `[shop-products category= tag= featured=true limit=8 columns=3 /]`, `[shop-product slug= /]`,
83
+ `[buy-button slug= label= /]`. Rendered from `shelfNow()` (products as last read - shortcodes are synchronous).
84
+ A cached page can show a stale stock flag until it is re-rendered; checkout re-prices regardless.
85
+
86
+ ## Gotchas
87
+
88
+ - Storefront CSS tokens are defined on `body`, not `:root`: themes set --dm-* on body's dm-theme-* class.
89
+ - The site's own input/link styles reach the storefront: see the "reach in here" block at the end of shop.css.
90
+ - `siteBase()` uses `request.host` (with the port); `request.hostname` drops it under Fastify 5.
91
+ - A test site on a non-standard port needs `site.baseUrl` for Stripe/PayPal return addresses behind a proxy.
92
+ - Admin views: item templates live in JS (a templateUrl goes through _.render); checkboxes are set after render
93
+ and listened to; slideovers via kit.openPanel (disposed and removed on close).
@@ -0,0 +1,224 @@
1
+ /* Shop admin - only --dm-* tokens, so it follows the admin theme (light, dark, custom). */
2
+ .shp, .shp-form, .shp-order { --shp-soft: color-mix(in srgb, var(--dm-text) 5%, transparent); --shp-line: var(--dm-border); }
3
+ .shp [hidden], .shp-form [hidden], .shp-order [hidden] { display: none !important; }
4
+ .shp { display: flex; flex-direction: column; gap: 1rem; }
5
+ .shp-body { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
6
+ .shp-error { color: var(--dm-danger); }
7
+ .shp-muted, .shp-note { color: var(--dm-text-muted); }
8
+ .shp-note { margin: 0; font-size: var(--dm-text-sm); }
9
+ .shp-note.is-bad { color: var(--dm-danger); }
10
+ .shp-spacer { flex: 1; }
11
+ .shp-loading { color: var(--dm-text-muted); padding: 2rem 0; }
12
+
13
+ /* Tabs under the banner */
14
+ .shp-nav { display: flex; flex-wrap: wrap; gap: .15rem; border-bottom: 1px solid var(--dm-border); }
15
+ .shp-nav a { display: inline-flex; align-items: center; gap: .4rem; padding: .5rem .85rem; color: var(--dm-text-muted); text-decoration: none;
16
+ font-size: var(--dm-text-sm); font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: var(--dm-radius-sm) var(--dm-radius-sm) 0 0; }
17
+ .shp-nav a:hover { color: var(--dm-text); background: var(--shp-soft); }
18
+ .shp-nav a.is-on { color: var(--dm-primary); border-bottom-color: var(--dm-primary); font-weight: 600; }
19
+ .shp-nav a.is-ext::after { content: 'Pro'; font-size: .62rem; font-weight: 700; letter-spacing: .04em; padding: .05rem .35rem; border-radius: 999px;
20
+ background: color-mix(in srgb, var(--dm-primary) 15%, transparent); color: var(--dm-primary); }
21
+
22
+ /* Cards, bars, chips */
23
+ .shp-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .9rem 1rem; display: flex; flex-direction: column; gap: .7rem;
24
+ background: var(--dm-surface, transparent); min-width: 0; }
25
+ .shp-card > header { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
26
+ .shp-card h3 { margin: 0; font-size: var(--dm-text-base, 1rem); font-weight: 600; display: flex; align-items: center; gap: .3rem; }
27
+ .shp-more { font-size: var(--dm-text-sm); color: var(--dm-primary); text-decoration: none; }
28
+ .shp-bar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
29
+ .shp-search { flex: 1; min-width: 10rem; max-width: 22rem; }
30
+ .shp-seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
31
+ .shp-seg button { border: 0; background: none; color: var(--dm-text); padding: .32rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm);
32
+ display: inline-flex; align-items: center; gap: .3rem; }
33
+ .shp-seg button + button { border-left: 1px solid var(--dm-border); }
34
+ .shp-seg button b { font-weight: 500; opacity: .6; }
35
+ .shp-seg button:hover { background: var(--shp-soft); }
36
+ .shp-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text, #fff); }
37
+ .shp-chip { display: inline-flex; align-items: center; gap: .25rem; font-size: var(--dm-text-xs); font-weight: 600; padding: .12rem .55rem; border-radius: 999px;
38
+ white-space: nowrap; background: color-mix(in srgb, var(--dm-text) 9%, transparent); color: var(--dm-text-muted); }
39
+ .shp-chip.is-primary { background: color-mix(in srgb, var(--dm-primary) 14%, transparent); color: var(--dm-primary); }
40
+ .shp-chip.is-success { background: color-mix(in srgb, var(--dm-success) 15%, transparent); color: var(--dm-success); }
41
+ .shp-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 16%, transparent); color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); }
42
+ .shp-chip.is-danger { background: color-mix(in srgb, var(--dm-danger) 14%, transparent); color: var(--dm-danger); }
43
+
44
+ /* Rows */
45
+ .shp-list { display: flex; flex-direction: column; }
46
+ .shp-row { display: flex; align-items: center; gap: .85rem; padding: .6rem .4rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; border-radius: var(--dm-radius-sm); }
47
+ .shp-row:last-child { border-bottom: 0; }
48
+ .shp-row:hover, .shp-row:focus-visible { background: var(--shp-soft); outline: none; }
49
+ .shp-num { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--dm-text-muted); min-width: 3.4rem; }
50
+ .shp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
51
+ .shp-main strong, .shp-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
52
+ .shp-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
53
+ .shp-amt { font-variant-numeric: tabular-nums; font-weight: 600; min-width: 5rem; text-align: right; }
54
+ .shp-kebab { border: 0; background: none; cursor: pointer; font-size: 1.15rem; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .35rem; line-height: 1.6; }
55
+ .shp-kebab:hover { color: var(--dm-primary); background: var(--shp-soft); }
56
+ .shp-thumb { width: 2.6rem; height: 2.6rem; border-radius: var(--dm-radius-sm); overflow: hidden; flex: none; background: var(--shp-soft); }
57
+ .shp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
58
+ .shp-star { color: var(--dm-warning); font-size: .85em; }
59
+ .shp-stock { font-size: var(--dm-text-xs); color: var(--dm-text-muted); min-width: 5.5rem; text-align: right; font-variant-numeric: tabular-nums; }
60
+ .shp-stock.is-warning { color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); font-weight: 600; }
61
+ .shp-stock.is-danger { color: var(--dm-danger); font-weight: 600; }
62
+ @media (max-width: 720px) { .shp-stock, .shp-num { display: none; } .shp-amt { min-width: 0; } }
63
+
64
+ /* Overview */
65
+ .shp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: .75rem; }
66
+ .shp-tile { position: relative; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .9rem 1rem; display: flex; flex-direction: column;
67
+ gap: .1rem; color: inherit; text-decoration: none; background: var(--dm-surface, transparent); overflow: hidden; }
68
+ a.shp-tile:hover { border-color: var(--dm-primary); }
69
+ .shp-tile-icon { position: absolute; top: .85rem; right: .9rem; color: var(--dm-text-muted); opacity: .7; }
70
+ .shp-tile-label { font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
71
+ .shp-tile-value { font-size: 1.65rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
72
+ .shp-tile-sub { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
73
+ .shp-tile.is-up .shp-tile-sub { color: var(--dm-success); font-weight: 600; }
74
+ .shp-tile.is-down .shp-tile-sub { color: var(--dm-danger); font-weight: 600; }
75
+ .shp-tile.is-attention { border-color: color-mix(in srgb, var(--dm-primary) 50%, var(--dm-border)); box-shadow: inset 3px 0 0 var(--dm-primary); }
76
+ .shp-grid2 { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 1rem; }
77
+ @media (max-width: 960px) { .shp-grid2 { grid-template-columns: 1fr; } }
78
+
79
+ .shp-chart { position: relative; }
80
+ .shp-chart svg { width: 100%; height: auto; display: block; overflow: visible; }
81
+ .shp-chart .shp-gridline { stroke: var(--dm-border); stroke-width: 1; }
82
+ .shp-chart text { fill: var(--dm-text-muted); font-size: 11px; font-family: inherit; }
83
+ .shp-bar path { fill: var(--dm-primary); opacity: .82; transition: opacity .15s; }
84
+ .shp-bar .shp-bar-zero { fill: var(--dm-border); }
85
+ .shp-bar.is-on path, .shp-bar:focus path { opacity: 1; }
86
+ .shp-bar:focus { outline: none; }
87
+ .shp-bar { cursor: default; }
88
+ .shp-tip { position: absolute; top: -.25rem; width: 120px; pointer-events: none; background: var(--dm-surface, #fff); color: var(--dm-text); border: 1px solid var(--dm-border);
89
+ border-radius: var(--dm-radius-sm); padding: .35rem .55rem; box-shadow: var(--dm-shadow-md, 0 6px 20px rgba(0,0,0,.15)); display: flex; flex-direction: column; font-size: var(--dm-text-xs); }
90
+ .shp-tip strong { font-size: var(--dm-text-sm); }
91
+ .shp-tip span { color: var(--dm-text-muted); }
92
+
93
+ .shp-todo { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .3rem; }
94
+ .shp-todo-item { width: 100%; display: flex; align-items: center; gap: .6rem; border: 0; background: var(--shp-soft); color: var(--dm-text); border-radius: var(--dm-radius-sm);
95
+ padding: .55rem .7rem; cursor: pointer; text-align: left; font: inherit; font-size: var(--dm-text-sm); }
96
+ .shp-todo-item:hover { background: color-mix(in srgb, var(--dm-primary) 10%, transparent); }
97
+ .shp-todo-item > span:nth-child(2) { flex: 1; }
98
+ .shp-todo-item.is-danger > span:first-child { color: var(--dm-danger); }
99
+ .shp-todo-item.is-warning > span:first-child { color: var(--dm-warning); }
100
+ .shp-todo-go { color: var(--dm-text-muted); }
101
+ .shp-all-clear { display: flex; align-items: center; gap: .5rem; color: var(--dm-success); padding: .5rem 0; }
102
+ .shp-mini-list { display: flex; flex-direction: column; }
103
+ .shp-mini { display: flex; align-items: center; gap: .75rem; padding: .5rem .3rem; border-bottom: 1px solid var(--dm-border); color: inherit; text-decoration: none; border-radius: var(--dm-radius-sm); }
104
+ .shp-mini:last-child { border-bottom: 0; }
105
+ .shp-mini:hover { background: var(--shp-soft); }
106
+ .shp-mini-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
107
+ .shp-mini-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
108
+ .shp-mini-amt { font-weight: 600; font-variant-numeric: tabular-nums; }
109
+ .shp-top { display: grid; grid-template-columns: minmax(0, 1fr) 30% auto; gap: .75rem; align-items: center; padding: .4rem 0; }
110
+ .shp-top-name { display: flex; flex-direction: column; min-width: 0; }
111
+ .shp-top-name small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
112
+ .shp-top-bar { height: 8px; border-radius: 999px; background: var(--shp-soft); overflow: hidden; }
113
+ .shp-top-bar span { display: block; height: 100%; background: var(--dm-primary); border-radius: 0 4px 4px 0; }
114
+
115
+ .shp-welcome { border: 1px dashed var(--dm-border); border-radius: var(--dm-radius-lg, 14px); padding: 2rem; text-align: center; max-width: 46rem; margin: 1rem auto; }
116
+ .shp-welcome h2 { margin: .5rem 0 .25rem; }
117
+ .shp-welcome-icon { display: inline-grid; place-items: center; width: 3.6rem; height: 3.6rem; border-radius: 50%; color: var(--dm-primary);
118
+ background: color-mix(in srgb, var(--dm-primary) 12%, transparent); }
119
+ .shp-steps { list-style: none; padding: 0; margin: 1.5rem 0 0; display: grid; gap: .6rem; text-align: left; counter-reset: st; }
120
+ .shp-steps li { counter-increment: st; display: grid; grid-template-columns: 2rem 1fr auto; grid-template-rows: auto auto; column-gap: .75rem; align-items: center;
121
+ border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem 1rem; }
122
+ .shp-steps li::before { content: counter(st); grid-row: 1 / 3; width: 2rem; height: 2rem; border-radius: 50%; display: grid; place-items: center;
123
+ background: var(--shp-soft); font-weight: 700; }
124
+ .shp-steps li.is-done::before { content: '✓'; background: var(--dm-success); color: #fff; }
125
+ .shp-steps li span { grid-column: 2; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
126
+ .shp-steps li .btn { grid-column: 3; grid-row: 1 / 3; }
127
+
128
+ /* Forms (slideovers) */
129
+ .shp-form { display: flex; flex-direction: column; gap: .9rem; }
130
+ .shp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem 1rem; }
131
+ .shp-wide { grid-column: 1 / -1; }
132
+ @media (max-width: 640px) { .shp-grid { grid-template-columns: 1fr; } }
133
+ .shp-form .form-label { display: flex; align-items: center; gap: .3rem; margin-bottom: .25rem; }
134
+ .shp-input-lg { font-size: 1.1rem; font-weight: 600; }
135
+ .shp-mono { font-family: var(--dm-font-mono, ui-monospace, monospace); font-size: .88rem; }
136
+ .shp-err { display: block; color: var(--dm-danger); font-size: var(--dm-text-xs); margin-top: .25rem; }
137
+ .shp-hint { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-xs); margin-top: .3rem; }
138
+ .shp-link { border: 0; background: none; padding: 0; color: var(--dm-primary); cursor: pointer; font: inherit; text-decoration: underline; }
139
+ .shp-check { display: inline-flex; align-items: center; gap: .45rem; cursor: pointer; font-size: var(--dm-text-sm); }
140
+ .shp-checks { display: flex; flex-direction: column; gap: .5rem; }
141
+ .shp-toggles { display: flex; align-items: flex-end; padding-bottom: .5rem; }
142
+ .shp-money { display: flex; align-items: stretch; }
143
+ .shp-money > span { display: grid; place-items: center; padding: 0 .6rem; border: 1px solid var(--dm-border); border-right: 0; border-radius: var(--dm-radius-sm) 0 0 var(--dm-radius-sm);
144
+ background: var(--shp-soft); color: var(--dm-text-muted); font-size: var(--dm-text-xs); font-weight: 600; }
145
+ .shp-money input { border-top-left-radius: 0; border-bottom-left-radius: 0; }
146
+ .shp-actions { display: flex; align-items: center; gap: .4rem; padding-top: .75rem; border-top: 1px solid var(--dm-border); position: sticky; bottom: 0;
147
+ background: var(--dm-surface, var(--dm-body-bg, #fff)); padding-bottom: .25rem; }
148
+ .shp-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
149
+ .shp-sect { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .6rem .9rem; }
150
+ .shp-sect[open] { padding-bottom: .9rem; }
151
+ .shp-sect > summary { cursor: pointer; font-weight: 600; padding: .2rem 0; }
152
+ .shp-sect[open] > summary { margin-bottom: .6rem; }
153
+ .shp-sect > * + * { margin-top: .6rem; }
154
+
155
+ .shp-pics { display: flex; flex-wrap: wrap; gap: .5rem; }
156
+ .shp-pic, .shp-pic-add { position: relative; width: 5.5rem; height: 5.5rem; border-radius: var(--dm-radius-md); overflow: hidden; }
157
+ .shp-pic { cursor: grab; background: var(--shp-soft); }
158
+ .shp-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
159
+ .shp-pic-x { position: absolute; top: .25rem; right: .25rem; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 0; background: rgba(0,0,0,.6); color: #fff;
160
+ cursor: pointer; line-height: 1; opacity: 0; transition: opacity .15s; }
161
+ .shp-pic:hover .shp-pic-x, .shp-pic-x:focus { opacity: 1; }
162
+ .shp-pic-main { position: absolute; left: .25rem; bottom: .25rem; font-size: .62rem; font-weight: 700; background: var(--dm-primary); color: var(--dm-primary-text, #fff);
163
+ padding: .05rem .35rem; border-radius: 999px; }
164
+ .shp-pic-add { border: 1.5px dashed var(--dm-border); background: none; color: var(--dm-text-muted); cursor: pointer; display: flex; flex-direction: column;
165
+ align-items: center; justify-content: center; gap: .2rem; font: inherit; font-size: var(--dm-text-xs); }
166
+ .shp-pic-add:hover { border-color: var(--dm-primary); color: var(--dm-primary); }
167
+ .shp-media { display: grid; grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: .5rem; }
168
+ .shp-media-tile { border: 2px solid transparent; border-radius: var(--dm-radius-md); padding: 0; background: var(--shp-soft); cursor: pointer; overflow: hidden;
169
+ display: flex; flex-direction: column; color: var(--dm-text); font: inherit; font-size: var(--dm-text-xs); }
170
+ .shp-media-tile img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
171
+ .shp-media-tile span { padding: .2rem .35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
172
+ .shp-media-tile.is-on { border-color: var(--dm-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dm-primary) 30%, transparent); }
173
+
174
+ .shp-variants { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
175
+ .shp-variants-head { display: flex; align-items: center; gap: .5rem; }
176
+ .shp-variants-head .form-label { margin: 0; white-space: nowrap; }
177
+ .shp-optname { max-width: 12rem; }
178
+ .shp-vtable { width: 100%; display: flex; flex-direction: column; gap: .35rem; }
179
+ .shp-vrow { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, .9fr) 1.8rem; gap: .35rem; align-items: center; }
180
+ .shp-vrow.is-head { font-size: var(--dm-text-xs); color: var(--dm-text-muted); font-weight: 600; }
181
+ .shp-vrow .form-input { padding: .35rem .5rem; font-size: var(--dm-text-sm); }
182
+
183
+ /* An order */
184
+ .shp-order { display: flex; flex-direction: column; gap: 1rem; }
185
+ .shp-order-head { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; }
186
+ .shp-order-acts { display: flex; gap: .35rem; }
187
+ .shp-order-acts .btn { display: inline-flex; align-items: center; gap: .3rem; }
188
+ .shp-lines { width: 100%; border-collapse: collapse; }
189
+ .shp-lines td { padding: .5rem .35rem; border-bottom: 1px solid var(--dm-border); vertical-align: middle; }
190
+ .shp-lines td small { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
191
+ .shp-lines-img { width: 3rem; }
192
+ .shp-lines-img img { width: 2.6rem; height: 2.6rem; border-radius: var(--dm-radius-sm); object-fit: cover; display: block; }
193
+ .shp-lines .shp-num { text-align: right; white-space: nowrap; color: var(--dm-text-muted); font-weight: 500; }
194
+ .shp-totals { margin: 0 0 0 auto; width: min(22rem, 100%); display: flex; flex-direction: column; gap: .3rem; font-variant-numeric: tabular-nums; }
195
+ .shp-totals > div { display: flex; justify-content: space-between; gap: 1rem; font-size: var(--dm-text-sm); }
196
+ .shp-totals dt { color: var(--dm-text-muted); } .shp-totals dd { margin: 0; font-weight: 600; }
197
+ .shp-totals .is-included { font-size: var(--dm-text-xs); } .shp-totals .is-included dd { font-weight: 400; color: var(--dm-text-muted); }
198
+ .shp-totals .is-total { border-top: 1px solid var(--dm-border); padding-top: .4rem; font-size: 1.05rem; }
199
+ .shp-totals .is-total dt { color: var(--dm-text); font-weight: 700; } .shp-totals .is-total dd { font-weight: 800; }
200
+ .shp-order-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
201
+ @media (max-width: 640px) { .shp-order-cols { grid-template-columns: 1fr; } }
202
+ .shp-order h4 { margin: 0 0 .35rem; font-size: var(--dm-text-xs); text-transform: uppercase; letter-spacing: .06em; color: var(--dm-text-muted); }
203
+ .shp-order p { margin: 0; }
204
+ .shp-order a { color: var(--dm-primary); }
205
+ .shp-callout { border-left: 3px solid var(--dm-warning); background: color-mix(in srgb, var(--dm-warning) 9%, transparent); padding: .6rem .8rem; border-radius: var(--dm-radius-sm); }
206
+ .shp-callout p { white-space: pre-wrap; margin-top: .25rem; }
207
+ .shp-ref { font-size: var(--dm-text-xs); }
208
+ .shp-ext { display: flex; flex-wrap: wrap; gap: .3rem; }
209
+ .shp-history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; border-left: 2px solid var(--dm-border); padding-left: .8rem; }
210
+ .shp-history li { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; font-size: var(--dm-text-sm); }
211
+ .shp-history li > span:last-child { color: var(--dm-text-muted); }
212
+
213
+ /* Settings */
214
+ .shp-settings-nav { align-self: flex-start; }
215
+ .shp-countries { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .45rem; }
216
+ .shp-country button { border: 0; background: none; color: inherit; cursor: pointer; padding: 0 0 0 .2rem; font-size: 1rem; line-height: 1; }
217
+ .shp-add-country { max-width: 16rem; }
218
+ .shp-pay { display: flex; flex-direction: column; gap: .75rem; }
219
+ .shp-provider { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .75rem; }
220
+ .shp-provider-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
221
+ .shp-copy { display: flex; align-items: center; gap: .3rem; background: var(--shp-soft); border-radius: var(--dm-radius-sm); padding: .3rem .5rem; }
222
+ .shp-copy code { flex: 1; overflow-wrap: anywhere; font-size: var(--dm-text-xs); }
223
+ .shp-steps li .shp-step-btns { grid-column: 3; grid-row: 1 / 3; display: flex; gap: .3rem; color: inherit; font-size: inherit; }
224
+ .shp-steps li .shp-step-btns .btn { grid-column: auto; grid-row: auto; }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Shop admin - what every screen shares: the admin's helpers (loaded at run
3
+ * time; a plugin never imports admin/ statically - the updater replaces it),
4
+ * the API, escaping, money in and out, slideover forms, and rows with a
5
+ * right-click / ⋮ / Shift+F10 menu.
6
+ *
7
+ * @module shopping-cart/admin/lib/kit
8
+ */
9
+
10
+ import {formatMoney, fromMinor, toMinor} from '/plugins/shopping-cart/public/lib/money.js';
11
+
12
+ export const SIDEBAR_URL = '#/plugins/shopping-cart';
13
+ export const BASE = '/api/plugins/shopping-cart';
14
+
15
+ let kit = null;
16
+
17
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
18
+ export function loadKit() {
19
+ if (!kit) {
20
+ kit = Promise.all([
21
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
22
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
23
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
24
+ ]).then(([tk, chrome, help]) => ({
25
+ onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
26
+ openMenuAt: tk.openMenuAt || ((el) => {
27
+ const r = el.getBoundingClientRect();
28
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
29
+ }),
30
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
31
+ addBannerAction: chrome.addBannerAction || (() => null),
32
+ helpPopovers: help.helpPopovers || (() => () => {})
33
+ }));
34
+ }
35
+ return kit;
36
+ }
37
+
38
+ /** The Shop's API. H attaches the token and refreshes it on a 401; it throws with the server's `error`. */
39
+ const seg = (id) => encodeURIComponent(String(id));
40
+ export const api = {
41
+ summary: () => H.get(`${BASE}/summary`),
42
+ extensions: () => H.get(`${BASE}/extensions`),
43
+ products: {
44
+ list: () => H.get(`${BASE}/products`),
45
+ create: (d) => H.post(`${BASE}/products`, d),
46
+ update: (id, d) => H.put(`${BASE}/products/${seg(id)}`, d),
47
+ patch: (id, d) => H.patch(`${BASE}/products/${seg(id)}`, d),
48
+ duplicate: (id) => H.post(`${BASE}/products/${seg(id)}/duplicate`, {}),
49
+ remove: (id) => H.delete(`${BASE}/products/${seg(id)}`),
50
+ samples: () => H.post(`${BASE}/samples`, {live: true})
51
+ },
52
+ orders: {
53
+ list: () => H.get(`${BASE}/orders`),
54
+ get: (id) => H.get(`${BASE}/orders/${seg(id)}`),
55
+ move: (id, body) => H.put(`${BASE}/orders/${seg(id)}/status`, body),
56
+ note: (id, body) => H.put(`${BASE}/orders/${seg(id)}/note`, body),
57
+ refund: (id, body) => H.post(`${BASE}/orders/${seg(id)}/refund`, body),
58
+ remove: (id) => H.delete(`${BASE}/orders/${seg(id)}`)
59
+ },
60
+ settings: {
61
+ get: () => H.get(`${BASE}/settings`),
62
+ save: (d) => H.put(`${BASE}/settings`, d),
63
+ test: (provider, d) => H.post(`${BASE}/settings/test/${seg(provider)}`, d)
64
+ }
65
+ };
66
+
67
+ /** A list route's entries, whatever shape they came back in. */
68
+ export const entriesOf = (res) => (Array.isArray(res) ? res : res?.entries ?? res?.data ?? []);
69
+
70
+ /** E.confirm / titles render HTML: user text goes in escaped. */
71
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
72
+
73
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
74
+ export function fragment(html) {
75
+ const t = document.createElement('template');
76
+ t.innerHTML = html.trim();
77
+ return t.content.firstElementChild;
78
+ }
79
+
80
+ export const store = {
81
+ get(k) { try { return S.get(k); } catch { return null; } },
82
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
83
+ };
84
+
85
+ /** The sidebar re-asks the Shop's badge. */
86
+ export function refreshBadge() {
87
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
88
+ }
89
+
90
+ // ---- Money ------------------------------------------------------------------------
91
+
92
+ let currency = 'GBP';
93
+ export const setCurrency = (c) => { currency = c || 'GBP'; };
94
+ export const getCurrency = () => currency;
95
+ export const money = (n, opts) => formatMoney(n, currency, opts);
96
+ /** For an input: "12.50". Blank for null. */
97
+ export const moneyIn = (n) => (n === null || n === undefined || n === '' ? '' : fromMinor(n, currency));
98
+ /** From an input: 1250, null when blank, NaN when not an amount. */
99
+ export const moneyOut = (text) => {
100
+ if (String(text ?? '').trim() === '') return null;
101
+ const m = toMinor(text, currency);
102
+ return m === null ? NaN : m;
103
+ };
104
+
105
+ /** "3 min ago", "26 Sep". */
106
+ export function when(iso) {
107
+ const t = new Date(iso).getTime();
108
+ if (!Number.isFinite(t)) return '';
109
+ const s = (Date.now() - t) / 1000;
110
+ if (s < 60) return 'just now';
111
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
112
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
113
+ if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
114
+ return new Date(t).toLocaleDateString(undefined, {day: 'numeric', month: 'short', ...(s > 86400 * 300 ? {year: 'numeric'} : {})});
115
+ }
116
+
117
+ // ---- Slideovers ---------------------------------------------------------------------
118
+
119
+ /**
120
+ * Open a slideover holding `html`, bound to `model`. Closing with something
121
+ * changed asks first (once). Everything is disposed and removed on close.
122
+ *
123
+ * @returns {Promise<{so, panel, close: (force?: boolean) => void}>}
124
+ */
125
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before}) {
126
+ const {onceClosed, helpPopovers} = await loadKit();
127
+ const so = E.slideover({title: esc(title), size, position: 'right'});
128
+ const panel = fragment(html);
129
+ so.element.appendChild(panel);
130
+ before?.(panel);
131
+ const disposeHelp = helpPopovers(panel);
132
+ const handle = M.applyBindings(model, panel, {methods});
133
+ onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
134
+ const close = so.close.bind(so);
135
+ let closed = false;
136
+ let asking = false;
137
+ so.close = async (force) => {
138
+ if (!closed && force !== true && dirty?.()) {
139
+ if (asking) return undefined;
140
+ asking = true;
141
+ const ok = await E.confirm('Discard your changes?');
142
+ asking = false;
143
+ if (!ok) return undefined;
144
+ }
145
+ closed = true;
146
+ return close();
147
+ };
148
+ panel.addEventListener('keydown', (e) => {
149
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && methods.save) { e.preventDefault(); methods.save(); }
150
+ });
151
+ I.scan(panel);
152
+ so.open();
153
+ return {so, panel, close: (force) => so.close(force)};
154
+ }
155
+
156
+ /**
157
+ * Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
158
+ * menu. `scope` receives the teardown.
159
+ */
160
+ export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-row'}) {
161
+ const menu = E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact',
162
+ items: (row) => items(row.dataset.id)});
163
+ const onClick = (e) => {
164
+ const row = e.target.closest(match);
165
+ if (!row) return;
166
+ if (e.target.closest('[data-stop]')) return;
167
+ const kebab = e.target.closest('.shp-kebab');
168
+ if (kebab) { openMenuAt(kebab); return; }
169
+ main(row.dataset.id);
170
+ };
171
+ const onKey = (e) => {
172
+ const row = e.target.closest(match);
173
+ if (!row) return;
174
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
175
+ else if (e.key === 'Enter' && e.target === row) main(row.dataset.id);
176
+ else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
177
+ e.preventDefault();
178
+ const rows = [...list.querySelectorAll(match)].filter(r => r.offsetParent);
179
+ rows[rows.indexOf(row) + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
180
+ }
181
+ };
182
+ list.addEventListener('click', onClick);
183
+ list.addEventListener('keydown', onKey);
184
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
185
+ }
186
+
187
+ /** Copy text, and say so. */
188
+ export function copy(text, what = 'Copied') {
189
+ navigator.clipboard?.writeText(text).then(() => E.toast(`${what}.`, {type: 'success'}), () => E.toast(text));
190
+ }
@@ -0,0 +1 @@
1
+ <div class="shp" data-shop-admin></div>