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,13 @@
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="#dfe8d2"/><stop offset="1" stop-color="#a9c29b"/></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 360c0-110 40-170 100-170s100 60 100 170" fill="none" stroke="#e8dcc4" stroke-width="26"/>
8
+ <path d="M300 360c0-110 40-170 100-170s100 60 100 170" fill="none" stroke="#cdbd9c" stroke-width="26" stroke-dasharray="4 18"/>
9
+ <path d="M220 360h360l30 440H190z" fill="#efe4cc"/>
10
+ <path d="M220 360h360l4 50H216z" fill="#e2d4b6"/>
11
+ <circle cx="400" cy="590" r="95" fill="none" stroke="#4f6b45" stroke-width="14"/>
12
+ <path d="M400 510c-30 40-30 120 0 160M400 510c30 40 30 120 0 160M320 590h160" fill="none" stroke="#4f6b45" stroke-width="10" stroke-linecap="round"/></g>
13
+ </svg>
@@ -0,0 +1,13 @@
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="#dfe8d2"/><stop offset="1" stop-color="#a9c29b"/></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 360c0-110 40-170 100-170s100 60 100 170" fill="none" stroke="#e8dcc4" stroke-width="26"/>
8
+ <path d="M300 360c0-110 40-170 100-170s100 60 100 170" fill="none" stroke="#cdbd9c" stroke-width="26" stroke-dasharray="4 18"/>
9
+ <path d="M220 360h360l30 440H190z" fill="#efe4cc"/>
10
+ <path d="M220 360h360l4 50H216z" fill="#e2d4b6"/>
11
+ <circle cx="400" cy="590" r="95" fill="none" stroke="#4f6b45" stroke-width="14"/>
12
+ <path d="M400 510c-30 40-30 120 0 160M400 510c30 40 30 120 0 160M320 590h160" fill="none" stroke="#4f6b45" stroke-width="10" stroke-linecap="round"/>
13
+ </svg>
@@ -0,0 +1,415 @@
1
+ /*
2
+ * Shopping Cart - the storefront, the basket drawer and checkout.
3
+ *
4
+ * Built on the site's theme tokens (--dm-*), with a fallback for each, so the
5
+ * shop wears whatever theme the site wears - light, dark or custom. Loaded on
6
+ * every public page (the drawer and the [shop-*] shortcodes can be anywhere),
7
+ * so it is kept to the storefront's own classes: nothing here styles a bare
8
+ * element outside .shop-*.
9
+ *
10
+ * The tokens are resolved on body, not :root: a theme sets its --dm-* on the
11
+ * body's dm-theme-* class, and a custom property defined on :root would
12
+ * freeze the root's (light) values for every descendant.
13
+ */
14
+
15
+ body {
16
+ --shop-accent: var(--dm-primary, #4f46e5);
17
+ --shop-accent-ink: #fff;
18
+ --shop-bg: var(--dm-surface, #fff);
19
+ --shop-page: var(--dm-background, var(--shop-bg));
20
+ --shop-text: var(--dm-text, #16181d);
21
+ --shop-muted: var(--dm-text-muted, #6b7280);
22
+ --shop-line: var(--dm-border, rgba(127, 127, 127, .22));
23
+ --shop-soft: color-mix(in srgb, var(--shop-text) 5%, transparent);
24
+ --shop-softer: color-mix(in srgb, var(--shop-text) 3%, transparent);
25
+ --shop-sale: var(--dm-danger, #e11d48);
26
+ --shop-good: var(--dm-success, #16a34a);
27
+ --shop-warn: var(--dm-warning, #d97706);
28
+ --shop-radius: var(--dm-radius-lg, 14px);
29
+ --shop-radius-sm: var(--dm-radius-md, 10px);
30
+ --shop-shadow: 0 1px 2px rgba(0, 0, 0, .06), 0 8px 24px -12px rgba(0, 0, 0, .25);
31
+ --shop-shadow-lift: 0 2px 4px rgba(0, 0, 0, .06), 0 18px 40px -16px rgba(0, 0, 0, .35);
32
+ --shop-ease: cubic-bezier(.2, .8, .2, 1);
33
+ }
34
+
35
+ .shop-page { padding-top: 1.5rem; padding-bottom: 4rem; color: var(--shop-text); }
36
+ .shop-page h1, .shop-page h2, .shop-page h3 { color: var(--shop-text); }
37
+ .shop-muted { color: var(--shop-muted); }
38
+ .shop-page [hidden], .shop-drawer [hidden] { display: none !important; }
39
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
40
+
41
+ /* ---- Storefront header and toolbar ------------------------------------------- */
42
+
43
+ .shop-hero { margin: .5rem 0 1.75rem; }
44
+ .shop-hero h1 { font-size: clamp(2rem, 4vw, 3rem); letter-spacing: -.02em; line-height: 1.05; margin: 0 0 .5rem; }
45
+ .shop-intro { max-width: 60ch; font-size: 1.1rem; color: var(--shop-muted); margin: 0; }
46
+
47
+ .shop-toolbar { display: flex; flex-wrap: wrap; gap: .75rem 1rem; align-items: center; justify-content: space-between;
48
+ margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--shop-line); }
49
+ .shop-tools { display: flex; gap: .5rem; align-items: center; margin-left: auto; }
50
+ .shop-chips { display: flex; flex-wrap: wrap; gap: .4rem; }
51
+ .shop-chip { border: 1px solid var(--shop-line); background: transparent; color: var(--shop-text); border-radius: 999px;
52
+ padding: .38rem .9rem; font: inherit; font-size: .9rem; cursor: pointer; transition: all .2s var(--shop-ease); }
53
+ .shop-chip:hover { border-color: var(--shop-accent); color: var(--shop-accent); }
54
+ .shop-chip.is-on { background: var(--shop-text); border-color: var(--shop-text); color: var(--shop-bg); }
55
+ .shop-search { display: flex; align-items: center; gap: .4rem; border: 1px solid var(--shop-line); border-radius: 999px;
56
+ padding: 0 .8rem; color: var(--shop-muted); background: var(--shop-bg); }
57
+ .shop-search:focus-within { border-color: var(--shop-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--shop-accent) 20%, transparent); }
58
+ .shop-search input { border: 0; outline: 0; background: transparent; color: var(--shop-text); font: inherit; padding: .45rem 0; width: 12rem; max-width: 40vw; }
59
+ .shop-sort { background-repeat: no-repeat; border: 1px solid var(--shop-line); border-radius: 999px; padding: .45rem 2rem .45rem .9rem; background-color: var(--shop-bg);
60
+ color: var(--shop-text); font: inherit; font-size: .9rem; cursor: pointer; }
61
+ .shop-empty, .shop-none { text-align: center; padding: 4rem 1rem; color: var(--shop-muted); }
62
+ .shop-linkbtn { background: none; border: 0; padding: 0; color: var(--shop-accent); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
63
+
64
+ /* ---- Product cards ------------------------------------------------------------- */
65
+
66
+ .shop-grid { display: grid; grid-template-columns: repeat(var(--shop-cols, 3), minmax(0, 1fr)); gap: 1.75rem 1.25rem; }
67
+ @media (max-width: 960px) { .shop-grid { grid-template-columns: repeat(min(var(--shop-cols, 3), 3), minmax(0, 1fr)); } }
68
+ @media (max-width: 720px) { .shop-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem .85rem; } }
69
+ .shop-embed { margin: 1.5rem 0; }
70
+ .shop-embed.is-single { max-width: 340px; }
71
+
72
+ .shop-card { position: relative; display: flex; flex-direction: column; min-width: 0; animation: shop-rise .5s var(--shop-ease) both; }
73
+ .shop-card:nth-child(2) { animation-delay: .04s; } .shop-card:nth-child(3) { animation-delay: .08s; }
74
+ .shop-card:nth-child(4) { animation-delay: .12s; } .shop-card:nth-child(5) { animation-delay: .16s; }
75
+ .shop-card:nth-child(n+6) { animation-delay: .2s; }
76
+ @keyframes shop-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
77
+
78
+ .shop-card-media { position: relative; display: block; aspect-ratio: 4 / 5; border-radius: var(--shop-radius); overflow: hidden;
79
+ background: var(--shop-soft); isolation: isolate; }
80
+ .shop-card-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
81
+ transition: transform .7s var(--shop-ease), opacity .45s ease; }
82
+ .shop-card-media .shop-card-alt { opacity: 0; }
83
+ .shop-card:hover .shop-card-media img { transform: scale(1.045); }
84
+ .shop-card:hover .shop-card-media .shop-card-alt { opacity: 1; }
85
+ .shop-card-noimg { position: absolute; inset: 0; display: grid; place-items: center; color: var(--shop-muted); opacity: .5; }
86
+ .shop-card-noimg svg { width: 42px; height: 42px; }
87
+ .shop-card.is-soldout .shop-card-media img { filter: grayscale(.7); opacity: .65; }
88
+
89
+ .shop-flags { position: absolute; top: .7rem; left: .7rem; display: flex; flex-direction: column; gap: .3rem; z-index: 1; align-items: flex-start; }
90
+ .shop-flag { font-size: .72rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: .28rem .55rem;
91
+ border-radius: 999px; background: var(--shop-bg); color: var(--shop-text); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
92
+ .shop-flag.is-sale { background: var(--shop-sale); color: #fff; }
93
+ .shop-flag.is-soldout { background: var(--shop-text); color: var(--shop-bg); }
94
+ .shop-flag.is-low { background: color-mix(in srgb, var(--shop-warn) 18%, var(--shop-bg)); color: var(--shop-text); }
95
+ .shop-flag.is-featured { background: var(--shop-accent); color: var(--shop-accent-ink); }
96
+
97
+ .shop-card-body { padding: .85rem .15rem 0; display: flex; flex-direction: column; gap: .2rem; flex: 1; }
98
+ .shop-card-cat { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--shop-muted); }
99
+ .shop-card-title { font-size: 1.02rem; font-weight: 600; margin: 0; line-height: 1.3; }
100
+ .shop-card-title a { color: inherit; text-decoration: none; }
101
+ .shop-card-title a::after { content: ''; position: absolute; inset: 0; } /* the whole card is the link */
102
+ .shop-card-summary { margin: .1rem 0 0; font-size: .88rem; color: var(--shop-muted); display: -webkit-box; -webkit-line-clamp: 2;
103
+ -webkit-box-orient: vertical; overflow: hidden; }
104
+ .shop-card-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-top: auto; padding-top: .55rem; }
105
+ .shop-price { font-weight: 700; font-size: 1.02rem; font-variant-numeric: tabular-nums; }
106
+ .shop-from { font-weight: 500; font-size: .82rem; color: var(--shop-muted); }
107
+ .shop-was { color: var(--shop-muted); font-weight: 500; font-size: .88em; margin-left: .25rem; }
108
+ .shop-card .shop-was + *, .shop-card:has(.shop-was) .shop-now { color: var(--shop-sale); }
109
+
110
+ .shop-quick { position: relative; z-index: 2; display: inline-flex; align-items: center; gap: .3rem; border: 1px solid var(--shop-line);
111
+ background: var(--shop-bg); color: var(--shop-text); border-radius: 999px; padding: .38rem .8rem .38rem .6rem; font: inherit;
112
+ font-size: .85rem; font-weight: 600; cursor: pointer; text-decoration: none; white-space: nowrap;
113
+ transition: background .2s, color .2s, border-color .2s, transform .15s; }
114
+ .shop-quick.is-link { padding: .38rem .85rem; }
115
+ .shop-quick:hover, .shop-quick:focus-visible { background: var(--shop-accent); border-color: var(--shop-accent); color: var(--shop-accent-ink); }
116
+ .shop-quick:active { transform: scale(.95); }
117
+ .shop-quick.is-added { background: var(--shop-good); border-color: var(--shop-good); color: #fff; }
118
+ @media (max-width: 480px) { .shop-quick span { display: none; } .shop-quick { padding: .45rem; } .shop-quick.is-link { padding: .38rem .7rem; } }
119
+
120
+ .shop-buy-button { display: inline-flex; align-items: center; gap: .4rem; border: 0; border-radius: 999px; padding: .75rem 1.4rem;
121
+ background: var(--shop-accent); color: var(--shop-accent-ink) !important; font: inherit; font-weight: 600; cursor: pointer;
122
+ text-decoration: none !important; box-shadow: var(--shop-shadow); transition: transform .15s, box-shadow .2s; }
123
+ .shop-buy-button:hover { transform: translateY(-1px); box-shadow: var(--shop-shadow-lift); }
124
+
125
+ /* ---- A product's page ------------------------------------------------------------- */
126
+
127
+ .shop-crumbs { display: flex; align-items: center; gap: .4rem; font-size: .9rem; margin-bottom: 1.25rem; color: var(--shop-muted); }
128
+ .shop-crumbs a { display: inline-flex; align-items: center; gap: .2rem; color: var(--shop-muted); text-decoration: none; }
129
+ .shop-crumbs a:hover { color: var(--shop-accent); }
130
+
131
+ .shop-product { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem); align-items: start; }
132
+ @media (max-width: 820px) { .shop-product { grid-template-columns: 1fr; } }
133
+ .shop-gallery { position: sticky; top: 5rem; }
134
+ @media (max-width: 820px) { .shop-gallery { position: static; } }
135
+ .shop-gallery-main { position: relative; aspect-ratio: 1; border-radius: var(--shop-radius); overflow: hidden; background: var(--shop-soft); }
136
+ .shop-gallery-main img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .25s; }
137
+ .shop-gallery-main.is-empty { display: grid; place-items: center; color: var(--shop-muted); }
138
+ .shop-gallery-main.is-empty svg { width: 64px; height: 64px; opacity: .4; }
139
+ .shop-thumbs { display: flex; gap: .5rem; margin-top: .75rem; overflow-x: auto; padding-bottom: .25rem; }
140
+ .shop-thumb { flex: 0 0 4.5rem; aspect-ratio: 1; border-radius: var(--shop-radius-sm); overflow: hidden; padding: 0; cursor: pointer;
141
+ border: 2px solid transparent; background: var(--shop-soft); opacity: .7; transition: opacity .2s, border-color .2s; }
142
+ .shop-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
143
+ .shop-thumb.is-on, .shop-thumb:hover { opacity: 1; border-color: var(--shop-accent); }
144
+
145
+ .shop-buy { display: flex; flex-direction: column; gap: .6rem; }
146
+ .shop-product-title { font-size: clamp(1.7rem, 3vw, 2.4rem); letter-spacing: -.02em; line-height: 1.1; margin: 0; }
147
+ .shop-product-price { font-size: 1.5rem; font-weight: 700; margin: 0; font-variant-numeric: tabular-nums; }
148
+ .shop-product-price:has(.shop-was) .shop-now { color: var(--shop-sale); }
149
+ .shop-product-summary { font-size: 1.05rem; color: var(--shop-muted); margin: 0; }
150
+ .shop-buy-form { display: flex; flex-direction: column; gap: 1rem; margin: .75rem 0 .25rem; }
151
+ .shop-options { border: 0; padding: 0; margin: 0; }
152
+ .shop-options legend { font-weight: 600; font-size: .9rem; margin-bottom: .5rem; }
153
+ .shop-option-list { display: flex; flex-wrap: wrap; gap: .45rem; }
154
+ .shop-option { position: relative; cursor: pointer; }
155
+ .shop-option input { position: absolute; opacity: 0; pointer-events: none; }
156
+ .shop-option span { display: inline-block; border: 1.5px solid var(--shop-line); border-radius: var(--shop-radius-sm);
157
+ padding: .55rem 1rem; font-weight: 500; transition: all .15s; background: var(--shop-bg); }
158
+ .shop-option span small { color: var(--shop-muted); font-weight: 500; }
159
+ .shop-option:hover span { border-color: var(--shop-text); }
160
+ .shop-option input:checked + span { border-color: var(--shop-accent); background: color-mix(in srgb, var(--shop-accent) 10%, var(--shop-bg));
161
+ box-shadow: 0 0 0 1px var(--shop-accent); }
162
+ .shop-option input:focus-visible + span { outline: 2px solid var(--shop-accent); outline-offset: 2px; }
163
+ .shop-option.is-out span { opacity: .45; text-decoration: line-through; cursor: not-allowed; }
164
+
165
+ .shop-buy-row { display: flex; gap: .6rem; }
166
+ .shop-qty { display: inline-flex; align-items: center; border: 1.5px solid var(--shop-line); border-radius: 999px; overflow: hidden; flex: none; }
167
+ .shop-qty button { width: 2.4rem; height: 2.9rem; border: 0; background: transparent; color: var(--shop-text); font-size: 1.15rem; cursor: pointer; }
168
+ .shop-qty button:hover { background: var(--shop-soft); }
169
+ .shop-qty input { width: 2.6rem; text-align: center; border: 0; background: transparent; color: var(--shop-text); font: inherit;
170
+ font-weight: 600; -moz-appearance: textfield; }
171
+ .shop-qty input::-webkit-outer-spin-button, .shop-qty input::-webkit-inner-spin-button { -webkit-appearance: none; }
172
+ .shop-qty.is-small button { width: 1.9rem; height: 2rem; font-size: 1rem; }
173
+ .shop-qty.is-small input { width: 2rem; font-size: .9rem; }
174
+ .shop-add-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: .5rem; border: 0; border-radius: 999px;
175
+ background: var(--shop-accent); color: var(--shop-accent-ink); font: inherit; font-weight: 650; font-size: 1.02rem;
176
+ min-height: 2.9rem; padding: 0 1.4rem; cursor: pointer; box-shadow: var(--shop-shadow); transition: transform .15s, box-shadow .2s, background .2s; }
177
+ .shop-add-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shop-shadow-lift); }
178
+ .shop-add-btn:active:not(:disabled) { transform: scale(.98); }
179
+ .shop-add-btn:disabled { background: var(--shop-soft); color: var(--shop-muted); box-shadow: none; cursor: not-allowed; }
180
+ .shop-add-btn.is-added { background: var(--shop-good); }
181
+ .shop-stock { margin: 0; min-height: 1.2em; font-size: .9rem; font-weight: 600; color: var(--shop-warn); }
182
+ .shop-assure { list-style: none; padding: 1rem 0 0; margin: .25rem 0 0; border-top: 1px solid var(--shop-line); display: grid; gap: .5rem;
183
+ font-size: .92rem; color: var(--shop-muted); }
184
+ .shop-assure li { display: flex; gap: .55rem; align-items: center; }
185
+ .shop-description { margin-top: 1rem; padding-top: 1.25rem; border-top: 1px solid var(--shop-line); line-height: 1.7; }
186
+ .shop-description img { max-width: 100%; border-radius: var(--shop-radius-sm); }
187
+ .shop-related { margin-top: 4.5rem; }
188
+ .shop-related h2 { font-size: 1.4rem; margin-bottom: 1.25rem; }
189
+
190
+ /* ---- Basket and checkout ------------------------------------------------------------ */
191
+
192
+ .shop-checkout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: clamp(1.5rem, 4vw, 3rem); align-items: start; }
193
+ @media (max-width: 880px) { .shop-checkout { grid-template-columns: 1fr; } .shop-co-summary { order: -1; } }
194
+ .shop-co-main h1 { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.02em; margin: 0 0 1.25rem; }
195
+ .shop-co-lines { display: flex; flex-direction: column; margin-bottom: 2rem; }
196
+ .shop-line { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) auto; gap: .5rem 1rem; align-items: center;
197
+ padding: 1rem 0; border-bottom: 1px solid var(--shop-line); animation: shop-rise .35s var(--shop-ease) both; }
198
+ .shop-line:first-child { border-top: 1px solid var(--shop-line); }
199
+ .shop-line-img { width: 4.5rem; aspect-ratio: 1; border-radius: var(--shop-radius-sm); overflow: hidden; background: var(--shop-soft);
200
+ display: grid; place-items: center; color: var(--shop-muted); }
201
+ .shop-line-img img { width: 100%; height: 100%; object-fit: cover; }
202
+ .shop-line-name { font-weight: 600; color: var(--shop-text); text-decoration: none; }
203
+ .shop-line-name:hover { color: var(--shop-accent); }
204
+ .shop-line-meta { font-size: .85rem; color: var(--shop-muted); }
205
+ .shop-line-controls { display: flex; align-items: center; gap: .6rem; margin-top: .45rem; }
206
+ .shop-line-amt { font-weight: 650; font-variant-numeric: tabular-nums; text-align: right; }
207
+ .shop-remove { border: 0; background: none; color: var(--shop-muted); font: inherit; font-size: .83rem; cursor: pointer; padding: .2rem; }
208
+ .shop-remove:hover { color: var(--shop-sale); }
209
+ .shop-line.is-leaving { opacity: 0; transform: translateX(12px); transition: all .25s ease; }
210
+
211
+ .shop-co-empty { text-align: center; padding: 3rem 1rem; border: 1px dashed var(--shop-line); border-radius: var(--shop-radius); }
212
+ .shop-co-empty .shop-bigicon { display: inline-grid; place-items: center; width: 4rem; height: 4rem; border-radius: 50%;
213
+ background: var(--shop-soft); color: var(--shop-muted); margin-bottom: .75rem; }
214
+ .shop-co-empty .shop-bigicon svg { width: 28px; height: 28px; }
215
+
216
+ .shop-co-form { display: flex; flex-direction: column; gap: 2rem; }
217
+ .shop-co-step h2 { display: flex; align-items: center; gap: .6rem; font-size: 1.15rem; margin: 0 0 1rem; }
218
+ .shop-step { display: inline-grid; place-items: center; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: var(--shop-text);
219
+ color: var(--shop-bg); font-size: .85rem; font-weight: 700; }
220
+ .shop-fields { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
221
+ @media (max-width: 560px) { .shop-fields { grid-template-columns: 1fr; } }
222
+ .shop-field { display: flex; flex-direction: column; gap: .3rem; font-size: .88rem; font-weight: 600; }
223
+ .shop-field.is-wide { grid-column: 1 / -1; }
224
+ .shop-field em { font-style: normal; font-weight: 400; color: var(--shop-muted); }
225
+ .shop-field input, .shop-field select, .shop-field textarea, .shop-code input {
226
+ border: 1.5px solid var(--shop-line); border-radius: var(--shop-radius-sm); padding: .7rem .85rem; font: inherit; font-weight: 400;
227
+ background-color: var(--shop-bg); background-repeat: no-repeat; color: var(--shop-text); transition: border-color .15s, box-shadow .15s; width: 100%; box-sizing: border-box; }
228
+ .shop-field input:focus, .shop-field select:focus, .shop-field textarea:focus, .shop-code input:focus {
229
+ outline: 0; border-color: var(--shop-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--shop-accent) 18%, transparent); }
230
+ .shop-field.is-bad input, .shop-field.is-bad select { border-color: var(--shop-sale); }
231
+ .shop-field-err { color: var(--shop-sale); font-weight: 500; font-size: .8rem; }
232
+
233
+ .shop-choice-row, .shop-methods { display: grid; gap: .6rem; margin-bottom: 1rem; }
234
+ .shop-choice-row { grid-template-columns: 1fr 1fr; }
235
+ @media (max-width: 560px) { .shop-choice-row { grid-template-columns: 1fr; } }
236
+ .shop-choice { position: relative; cursor: pointer; }
237
+ .shop-choice input { position: absolute; opacity: 0; }
238
+ .shop-choice > span { display: flex; align-items: center; gap: .65rem; border: 1.5px solid var(--shop-line); border-radius: var(--shop-radius-sm);
239
+ padding: .9rem 1rem; font-weight: 600; transition: all .15s; background: var(--shop-bg); }
240
+ .shop-choice > span small { display: block; font-weight: 400; color: var(--shop-muted); }
241
+ .shop-choice > span .shop-choice-end { margin-left: auto; color: var(--shop-muted); display: flex; gap: .3rem; }
242
+ .shop-choice:hover > span { border-color: var(--shop-text); }
243
+ .shop-choice input:checked + span { border-color: var(--shop-accent); box-shadow: 0 0 0 1px var(--shop-accent);
244
+ background: color-mix(in srgb, var(--shop-accent) 7%, var(--shop-bg)); }
245
+ .shop-choice input:focus-visible + span { outline: 2px solid var(--shop-accent); outline-offset: 2px; }
246
+ .shop-radio { width: 1.1rem; height: 1.1rem; border-radius: 50%; border: 2px solid var(--shop-line); flex: none; transition: all .15s; }
247
+ .shop-choice input:checked + span .shop-radio { border: 5px solid var(--shop-accent); }
248
+ .shop-paymark { font-size: .7rem; font-weight: 800; letter-spacing: .02em; border: 1px solid var(--shop-line); border-radius: 4px; padding: .1rem .3rem; color: var(--shop-muted); }
249
+
250
+ .shop-terms { display: flex; gap: .5rem; align-items: flex-start; font-size: .9rem; margin: .5rem 0 0; }
251
+ .shop-co-error { background: color-mix(in srgb, var(--shop-sale) 10%, var(--shop-bg)); color: var(--shop-text);
252
+ border: 1px solid color-mix(in srgb, var(--shop-sale) 40%, transparent); border-radius: var(--shop-radius-sm); padding: .75rem 1rem; font-size: .92rem; margin: 1rem 0 0; }
253
+ .shop-pay-btn { width: 100%; margin-top: 1rem; display: inline-flex; align-items: center; justify-content: center; gap: .55rem; border: 0;
254
+ border-radius: 999px; background: var(--shop-accent); color: var(--shop-accent-ink); font: inherit; font-weight: 700; font-size: 1.08rem;
255
+ min-height: 3.3rem; cursor: pointer; box-shadow: var(--shop-shadow); transition: transform .15s, box-shadow .2s, opacity .2s; }
256
+ .shop-pay-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shop-shadow-lift); }
257
+ .shop-pay-btn:disabled { opacity: .6; cursor: progress; }
258
+ .shop-secure { display: flex; align-items: center; justify-content: center; gap: .35rem; font-size: .8rem; color: var(--shop-muted); margin: .75rem 0 0; text-align: center; }
259
+
260
+ .shop-co-summary { position: sticky; top: 5rem; border-radius: var(--shop-radius); background: var(--shop-softer);
261
+ border: 1px solid var(--shop-line); padding: 1.35rem 1.35rem 1.1rem; }
262
+ @media (max-width: 880px) { .shop-co-summary { position: static; } }
263
+ .shop-co-summary h2 { font-size: 1.1rem; margin: 0 0 1rem; }
264
+ .shop-totals { margin: 0; display: flex; flex-direction: column; gap: .55rem; font-variant-numeric: tabular-nums; }
265
+ .shop-totals > div { display: flex; justify-content: space-between; gap: 1rem; font-size: .95rem; }
266
+ .shop-totals dt { color: var(--shop-muted); } .shop-totals dd { margin: 0; font-weight: 600; }
267
+ .shop-totals .is-discount dd { color: var(--shop-good); }
268
+ .shop-totals .is-included { font-size: .82rem; } .shop-totals .is-included dd { font-weight: 400; color: var(--shop-muted); }
269
+ .shop-totals .is-total { border-top: 1px solid var(--shop-line); margin-top: .35rem; padding-top: .8rem; font-size: 1.2rem; }
270
+ .shop-totals .is-total dt { color: var(--shop-text); font-weight: 700; } .shop-totals .is-total dd { font-weight: 800; }
271
+ .shop-code { display: flex; gap: .4rem; margin-top: 1rem; }
272
+ .shop-code button { border: 1.5px solid var(--shop-text); border-radius: var(--shop-radius-sm); background: transparent; color: var(--shop-text);
273
+ font: inherit; font-weight: 600; padding: 0 1rem; cursor: pointer; }
274
+ .shop-code button:hover { background: var(--shop-text); color: var(--shop-bg); }
275
+ .shop-notes { list-style: none; padding: 0 !important; margin: .9rem 0 0; font-size: .85rem; display: grid; gap: .35rem; }
276
+ .shop-notes li { padding: .5rem .7rem; border-radius: var(--shop-radius-sm); background: color-mix(in srgb, var(--shop-warn) 12%, transparent); }
277
+ .shop-notes li.is-good { background: color-mix(in srgb, var(--shop-good) 12%, transparent); }
278
+
279
+ /* The road to free delivery. */
280
+ .shop-freebar { margin: 0 0 1rem; font-size: .88rem; }
281
+ .shop-freebar p { margin: 0 0 .45rem; }
282
+ .shop-freebar-track { height: 6px; border-radius: 999px; background: var(--shop-soft); overflow: hidden; }
283
+ .shop-freebar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--shop-accent), var(--shop-good));
284
+ transition: width .6s var(--shop-ease); }
285
+
286
+ /* ---- Receipt ------------------------------------------------------------------------ */
287
+
288
+ .shop-receipt { max-width: 900px; margin: 0 auto; }
289
+ .shop-r-head { display: flex; gap: 1.25rem; align-items: flex-start; margin: 1rem 0 1.5rem; }
290
+ .shop-r-head h1 { font-size: clamp(1.7rem, 3vw, 2.3rem); letter-spacing: -.02em; margin: .1rem 0 .35rem; }
291
+ .shop-r-head p { margin: 0; }
292
+ .shop-r-badge { flex: none; width: 3.6rem; height: 3.6rem; border-radius: 50%; display: grid; place-items: center; color: #fff;
293
+ background: var(--shop-good); box-shadow: 0 0 0 8px color-mix(in srgb, var(--shop-good) 15%, transparent); animation: shop-pop .6s var(--shop-ease) both; }
294
+ .shop-r-badge svg { width: 1.7rem; height: 1.7rem; stroke-dasharray: 30; stroke-dashoffset: 30; animation: shop-draw .5s .3s ease forwards; }
295
+ .shop-r-badge.is-waiting { background: var(--shop-soft); box-shadow: none; }
296
+ .shop-receipt.is-closed .shop-r-badge { background: var(--shop-muted); box-shadow: none; }
297
+ @keyframes shop-pop { from { transform: scale(.4); opacity: 0; } to { transform: none; opacity: 1; } }
298
+ @keyframes shop-draw { to { stroke-dashoffset: 0; } }
299
+ .shop-spinner { width: 1.5rem; height: 1.5rem; border-radius: 50%; border: 3px solid var(--shop-line); border-top-color: var(--shop-accent); animation: shop-spin .8s linear infinite; }
300
+ @keyframes shop-spin { to { transform: rotate(360deg); } }
301
+ .shop-r-note { border-radius: var(--shop-radius-sm); padding: 1rem 1.2rem; background: var(--shop-softer); border: 1px solid var(--shop-line); margin: 0 0 1.25rem; }
302
+ .shop-r-note p { margin: .35rem 0 0; }
303
+ .shop-r-steps { list-style: none; padding: 0; margin: 0 0 2rem; display: grid; grid-template-columns: repeat(3, 1fr); counter-reset: s; }
304
+ .shop-r-steps li { position: relative; padding-top: 1.6rem; font-size: .88rem; color: var(--shop-muted); text-align: center; }
305
+ .shop-r-steps li span { position: absolute; top: .3rem; left: 50%; width: .9rem; height: .9rem; margin-left: -.45rem; border-radius: 50%;
306
+ background: var(--shop-bg); border: 2px solid var(--shop-line); z-index: 1; }
307
+ .shop-r-steps li::before { content: ''; position: absolute; top: .7rem; left: -50%; right: 50%; height: 2px; background: var(--shop-line); }
308
+ .shop-r-steps li:first-child::before { display: none; }
309
+ .shop-r-steps li.is-done { color: var(--shop-text); font-weight: 600; }
310
+ .shop-r-steps li.is-done span { background: var(--shop-good); border-color: var(--shop-good); }
311
+ .shop-r-steps li.is-done::before { background: var(--shop-good); }
312
+ .shop-r-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 2rem; }
313
+ @media (max-width: 720px) { .shop-r-grid { grid-template-columns: 1fr; } }
314
+ .shop-r-grid h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--shop-muted); margin: 0 0 .75rem; }
315
+ .shop-r-side h2:not(:first-child) { margin-top: 1.25rem; }
316
+ .shop-r-side address { font-style: normal; line-height: 1.6; }
317
+ .shop-r-lines { list-style: none; padding: 0; margin: 0 0 1rem; }
318
+ .shop-r-line { display: grid; grid-template-columns: 3rem minmax(0, 1fr) auto auto; gap: .9rem; align-items: center; padding: .6rem 0; border-bottom: 1px solid var(--shop-line); }
319
+ .shop-r-line img, .shop-r-noimg { width: 3rem; height: 3rem; border-radius: 8px; object-fit: cover; background: var(--shop-soft); display: grid; place-items: center; color: var(--shop-muted); }
320
+ .shop-r-name small { display: block; color: var(--shop-muted); font-size: .82rem; }
321
+ .shop-r-qty { color: var(--shop-muted); font-size: .9rem; } .shop-r-amt { font-weight: 600; font-variant-numeric: tabular-nums; }
322
+ @media print {
323
+ .shop-crumbs, .shop-r-side p:last-child, .shop-fab, .shop-drawer { display: none !important; }
324
+ .shop-r-badge { box-shadow: none; animation: none; } .shop-r-badge svg { stroke-dashoffset: 0; animation: none; }
325
+ }
326
+
327
+ /* ---- The basket button and drawer (every page) ------------------------------------------ */
328
+
329
+ .shop-fab { position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 1040; display: inline-flex; align-items: center; gap: .55rem;
330
+ border: 0; border-radius: 999px; padding: .85rem 1.15rem .85rem 1rem; background: var(--shop-text); color: var(--shop-bg);
331
+ font: inherit; font-weight: 650; cursor: pointer; box-shadow: 0 10px 30px -8px rgba(0, 0, 0, .45);
332
+ transform: translateY(0); transition: transform .35s var(--shop-ease), opacity .3s; }
333
+ .shop-fab.is-left { right: auto; left: 1.25rem; }
334
+ .shop-fab.is-hidden { transform: translateY(160%); opacity: 0; pointer-events: none; }
335
+ .shop-fab:hover { transform: translateY(-2px); }
336
+ .shop-fab-count { min-width: 1.45rem; height: 1.45rem; border-radius: 999px; background: var(--shop-accent); color: var(--shop-accent-ink);
337
+ display: inline-grid; place-items: center; font-size: .78rem; font-weight: 800; padding: 0 .35rem; }
338
+ .shop-fab.is-bump .shop-fab-count { animation: shop-bump .5s var(--shop-ease); }
339
+ @keyframes shop-bump { 0% { transform: scale(1); } 35% { transform: scale(1.45); } 100% { transform: scale(1); } }
340
+ @media (max-width: 560px) { .shop-fab-label { display: none; } .shop-fab { padding: .9rem; } }
341
+
342
+ .shop-drawer { position: fixed; inset: 0; z-index: 1050; visibility: hidden; }
343
+ .shop-drawer.is-open { visibility: visible; }
344
+ .shop-drawer-backdrop { position: absolute; inset: 0; background: rgba(10, 12, 16, .45); opacity: 0; transition: opacity .3s;
345
+ backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
346
+ .shop-drawer.is-open .shop-drawer-backdrop { opacity: 1; }
347
+ .shop-drawer-panel { position: absolute; top: 0; bottom: 0; right: 0; width: min(430px, 100vw); background: var(--shop-bg); color: var(--shop-text);
348
+ display: flex; flex-direction: column; transform: translateX(100%); transition: transform .38s var(--shop-ease);
349
+ box-shadow: -20px 0 50px -20px rgba(0, 0, 0, .4); }
350
+ .shop-drawer.is-left .shop-drawer-panel { right: auto; left: 0; transform: translateX(-100%); }
351
+ .shop-drawer.is-open .shop-drawer-panel { transform: none; }
352
+ .shop-drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 1.15rem 1.25rem; border-bottom: 1px solid var(--shop-line); }
353
+ .shop-drawer-head h2 { font-size: 1.15rem; margin: 0; display: flex; align-items: center; gap: .5rem; color: var(--shop-text); }
354
+ .shop-drawer-head h2 small { color: var(--shop-muted); font-weight: 500; font-size: .9rem; }
355
+ .shop-x { border: 0; background: var(--shop-soft); color: var(--shop-text); width: 2.2rem; height: 2.2rem; border-radius: 50%;
356
+ cursor: pointer; font-size: 1.3rem; line-height: 1; display: grid; place-items: center; }
357
+ .shop-x:hover { background: var(--shop-line); }
358
+ .shop-drawer-body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; overscroll-behavior: contain; }
359
+ .shop-drawer-body .shop-line { grid-template-columns: 4rem minmax(0, 1fr) auto; padding: .85rem 0; }
360
+ .shop-drawer-body .shop-line-img { width: 4rem; }
361
+ .shop-drawer-body .shop-line:first-child { border-top: 0; }
362
+ .shop-drawer-foot { border-top: 1px solid var(--shop-line); padding: 1rem 1.25rem 1.25rem; background: var(--shop-softer); }
363
+ .shop-drawer-sub { display: flex; justify-content: space-between; font-weight: 700; font-size: 1.1rem; margin-bottom: .2rem; font-variant-numeric: tabular-nums; }
364
+ .shop-drawer-hint { font-size: .8rem; color: var(--shop-muted); margin: 0 0 .9rem; }
365
+ .shop-drawer-go { display: flex; width: 100%; align-items: center; justify-content: center; gap: .5rem; border: 0; border-radius: 999px;
366
+ background: var(--shop-accent); color: var(--shop-accent-ink) !important; font: inherit; font-weight: 700; min-height: 3rem;
367
+ text-decoration: none !important; cursor: pointer; box-shadow: var(--shop-shadow); }
368
+ .shop-drawer-go:hover { filter: brightness(1.05); }
369
+ .shop-drawer-more { display: block; width: 100%; margin-top: .5rem; text-align: center; background: none; border: 0; color: var(--shop-muted);
370
+ font: inherit; font-size: .9rem; cursor: pointer; padding: .4rem; }
371
+ .shop-drawer-more:hover { color: var(--shop-text); }
372
+ .shop-drawer-empty { text-align: center; padding: 3rem 1rem; color: var(--shop-muted); }
373
+ .shop-drawer-empty .shop-bigicon { display: inline-grid; place-items: center; width: 4rem; height: 4rem; border-radius: 50%; background: var(--shop-soft); margin-bottom: .75rem; }
374
+ .shop-drawer-empty .shop-bigicon svg { width: 28px; height: 28px; }
375
+ html.shop-locked { overflow: hidden; }
376
+
377
+ /* "Added" - a small card that rises and goes. */
378
+ .shop-toast { position: fixed; z-index: 1060; bottom: 5.5rem; right: 1.25rem; display: flex; align-items: center; gap: .75rem;
379
+ background: var(--shop-bg); color: var(--shop-text); border: 1px solid var(--shop-line); border-radius: var(--shop-radius);
380
+ padding: .65rem .8rem .65rem .65rem; box-shadow: var(--shop-shadow-lift); max-width: min(360px, calc(100vw - 2.5rem));
381
+ animation: shop-toast-in .4s var(--shop-ease) both; }
382
+ .shop-toast.is-left { right: auto; left: 1.25rem; }
383
+ .shop-toast.is-out { animation: shop-toast-out .3s ease forwards; }
384
+ .shop-toast img, .shop-toast .shop-toast-img { width: 3rem; height: 3rem; border-radius: 8px; object-fit: cover; flex: none; background: var(--shop-soft);
385
+ display: grid; place-items: center; color: var(--shop-muted); }
386
+ .shop-toast strong { display: block; font-size: .92rem; }
387
+ .shop-toast small { color: var(--shop-muted); font-size: .8rem; }
388
+ .shop-toast .shop-toast-check { color: var(--shop-good); display: inline-flex; vertical-align: -3px; margin-right: .2rem; }
389
+ .shop-toast a { margin-left: auto; font-weight: 700; font-size: .85rem; color: var(--shop-accent); white-space: nowrap; text-decoration: none; }
390
+ @keyframes shop-toast-in { from { opacity: 0; transform: translateY(14px) scale(.97); } to { opacity: 1; transform: none; } }
391
+ @keyframes shop-toast-out { to { opacity: 0; transform: translateY(8px); } }
392
+
393
+ @media (prefers-reduced-motion: reduce) {
394
+ .shop-card, .shop-line, .shop-r-badge, .shop-toast { animation: none !important; }
395
+ .shop-card-media img, .shop-drawer-panel, .shop-fab, .shop-freebar-fill { transition: none !important; }
396
+ .shop-r-badge svg { animation: none; stroke-dashoffset: 0; }
397
+ }
398
+
399
+ /* The site's own form and link styles reach in here; these inputs sit inside a drawn control. */
400
+ .shop-page .shop-qty input, .shop-drawer .shop-qty input, .shop-search input {
401
+ background: transparent !important; border: 0 !important; box-shadow: none !important; outline: 0; padding-left: 0; padding-right: 0; margin: 0; min-height: 0; }
402
+ .shop-card-title a, .shop-card-title a:hover, .shop-line-name, .shop-crumbs a, .shop-quick { text-decoration: none !important; }
403
+ .shop-card-title a:hover { color: var(--shop-accent); }
404
+
405
+ @media (max-width: 640px) {
406
+ .shop-toolbar { flex-direction: column; align-items: stretch; }
407
+ .shop-tools { margin-left: 0; width: 100%; }
408
+ .shop-search { flex: 1; min-width: 0; }
409
+ .shop-search input { width: 100%; max-width: none; }
410
+ .shop-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -1rem; padding: 0 1rem .15rem; }
411
+ .shop-chips::-webkit-scrollbar { display: none; }
412
+ .shop-chip { flex: none; }
413
+ .shop-line-controls { flex-wrap: wrap; }
414
+ }
415
+ .shop-co-title { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.02em; margin: 0 0 1.25rem; }
@@ -0,0 +1,4 @@
1
+ <main class="container shop-page" id="dm-shop" data-shop-page="{{page}}">
2
+ {{body}}
3
+ </main>
4
+ <script type="application/json" id="dm-shop-config">{{config}}</script>
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The Cart's API against the real collection service, inside the domma-cms
3
+ * checkout `make test` runs in. Its two collections are made for the test and
4
+ * removed after; config/plugins.json is restored byte for byte.
5
+ */
6
+ import {test, before, after} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import Fastify from 'fastify';
11
+ import shoppingCart from '../plugin.js';
12
+
13
+ const PLUGINS = path.resolve('config/plugins.json');
14
+ const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
15
+ let savedPlugins;
16
+ let app;
17
+ const notices = [];
18
+
19
+ before(async () => {
20
+ savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
21
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
22
+ app = Fastify();
23
+ const authenticate = async (req) => { req.user = {id: 'u1', name: 'Sam', role: 'admin'}; };
24
+ await app.register(shoppingCart, {
25
+ auth: {authenticate, requirePermission: () => async () => {}},
26
+ settings: {},
27
+ hooks: {notify: (n) => notices.push(n), registerShortcode() {}}
28
+ });
29
+ });
30
+
31
+ after(async () => {
32
+ await app?.close();
33
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
34
+ if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
35
+ else await fs.writeFile(PLUGINS, savedPlugins);
36
+ });
37
+
38
+ const call = async (method, url, body) => {
39
+ const res = await app.inject({method, url, payload: body});
40
+ let json = null;
41
+ try { json = res.json(); } catch { /* not JSON */ }
42
+ return {status: res.statusCode, body: json};
43
+ };
44
+
45
+ test('a sale, from product to refund', async () => {
46
+ // Settings: flat delivery, VAT inclusive, offline payment on.
47
+ let r = await call('PUT', '/settings', {shippingFlat: 300, taxRate: 20, pricesIncludeTax: true, offlineEnabled: true, countries: ['GB']});
48
+ assert.equal(r.status, 200, JSON.stringify(r.body));
49
+
50
+ r = await call('POST', '/products', {name: 'Mug', price: 1200, status: 'active', trackStock: true, stock: 2});
51
+ assert.equal(r.status, 201, JSON.stringify(r.body));
52
+ const mug = r.body;
53
+ r = await call('POST', '/products', {name: 'Mug', price: 900, status: 'draft'});
54
+ assert.equal(r.body.data.slug, 'mug-2', 'slugs stay unique');
55
+
56
+ r = await call('GET', '/catalogue');
57
+ assert.deepEqual(r.body.products.map(p => p.name), ['Mug'], 'drafts are not for sale');
58
+
59
+ r = await call('POST', '/quote', {lines: [{productId: mug.id, qty: 5}]});
60
+ assert.equal(r.body.lines[0].qty, 2);
61
+ assert.equal(r.body.total, 2700);
62
+ assert.ok(r.body.methods.some(m => m.id === 'offline'));
63
+
64
+ // Checkout refuses a basket that changed until the shopper has seen it.
65
+ r = await call('POST', '/checkout', {lines: [{productId: mug.id, qty: 5}], provider: 'offline',
66
+ customer: {name: 'Ann', email: 'ann@example.com'}, address: {line1: '1 High St', city: 'York', postcode: 'YO1 1AA', country: 'GB'}});
67
+ assert.equal(r.status, 409);
68
+
69
+ r = await call('POST', '/checkout', {lines: [{productId: mug.id, qty: 2}], provider: 'offline', expectTotal: 2700,
70
+ customer: {name: 'Ann', email: 'ann@example.com'}, address: {line1: '1 High St', city: 'York', postcode: 'YO1 1AA', country: 'GB'}});
71
+ assert.equal(r.status, 200, JSON.stringify(r.body));
72
+ const orderId = r.body.orderId;
73
+ assert.match(r.body.redirect, /\/shop\/order\/.+\?t=[a-f0-9]{36}$/);
74
+
75
+ let o = (await call('GET', `/orders/${orderId}`)).body;
76
+ assert.equal(o.data.status, 'awaiting');
77
+ assert.equal(o.data.number, 1001);
78
+
79
+ // The receipt needs the token.
80
+ assert.equal((await call('GET', `/order/${orderId}?t=nope`)).status, 404);
81
+ const token = o.data.token;
82
+ assert.equal((await call('GET', `/order/${orderId}?t=${token}`)).body.status, 'awaiting');
83
+
84
+ // Money arrives: paid, stock taken, the owner told, the badge counts it.
85
+ r = await call('PUT', `/orders/${orderId}/status`, {status: 'paid', note: 'Bank transfer seen'});
86
+ assert.equal(r.status, 200, JSON.stringify(r.body));
87
+ assert.equal((await call('GET', '/products')).body.find(p => p.id === mug.id).data.stock, 0);
88
+ assert.ok(notices.some(n => n.title.includes('#1001')));
89
+ assert.equal((await call('GET', '/count')).body.count, 1);
90
+
91
+ // Sold out now.
92
+ r = await call('POST', '/quote', {lines: [{productId: mug.id, qty: 1}]});
93
+ assert.equal(r.body.lines.length, 0);
94
+
95
+ // A move the table does not allow.
96
+ assert.equal((await call('PUT', `/orders/${orderId}/status`, {status: 'awaiting'})).status, 409);
97
+
98
+ r = await call('PUT', `/orders/${orderId}/status`, {status: 'fulfilled', tracking: {carrier: 'Royal Mail', number: 'AB123', url: 'javascript:x'}});
99
+ assert.equal(r.body.data.tracking.url, '', 'only https tracking links');
100
+ assert.equal((await call('GET', '/count')).body.count, 0);
101
+
102
+ // A paid order cannot be deleted; it can be refunded (offline: marked), with the stock back.
103
+ assert.equal((await call('DELETE', `/orders/${orderId}`)).status, 409);
104
+ r = await call('POST', `/orders/${orderId}/refund`, {restock: true});
105
+ assert.equal(r.status, 200, JSON.stringify(r.body));
106
+ assert.equal(r.body.order.data.status, 'refunded');
107
+ assert.equal((await call('GET', '/products')).body.find(p => p.id === mug.id).data.stock, 2);
108
+
109
+ const sum = (await call('GET', '/summary')).body;
110
+ assert.equal(sum.days30.orders, 0, 'a refunded sale is not revenue');
111
+ });
112
+
113
+ test('free orders are paid at once; a provider that is off is refused', async () => {
114
+ let r = await call('POST', '/products', {name: 'Free guide', price: 0, status: 'active', shippable: false});
115
+ const guide = r.body;
116
+ r = await call('POST', '/checkout', {lines: [{productId: guide.id, qty: 1}], customer: {name: 'Bo', email: 'bo@example.com'}});
117
+ assert.equal(r.status, 200, JSON.stringify(r.body));
118
+ assert.equal((await call('GET', `/orders/${r.body.orderId}`)).body.data.status, 'paid');
119
+
120
+ r = await call('POST', '/products', {name: 'Print', price: 500, status: 'active', shippable: false});
121
+ r = await call('POST', '/checkout', {lines: [{productId: r.body.id, qty: 1}], provider: 'stripe', customer: {name: 'Bo', email: 'bo@example.com'}});
122
+ assert.equal(r.status, 400);
123
+ assert.match(r.body.error, /Choose how to pay/);
124
+ });
125
+
126
+ test('settings never send a secret back', async () => {
127
+ let r = await call('PUT', '/settings', {stripeSecretKey: 'sk_test_1234567890abcdef', stripeEnabled: true});
128
+ assert.equal(r.status, 200, JSON.stringify(r.body));
129
+ r = await call('GET', '/settings');
130
+ assert.equal(r.body.settings.stripeSecretKey, '••••••••');
131
+ assert.match(r.body.webhookUrl, /\/api\/plugins\/shopping-cart\/stripe\/webhook$/);
132
+ r = await call('PUT', '/settings', {stripeSecretKey: '••••••••', shopTitle: 'Emporium'});
133
+ assert.equal(r.status, 200);
134
+ const stored = JSON.parse(await fs.readFile(PLUGINS, 'utf8'))['shopping-cart'].settings;
135
+ assert.equal(stored.stripeSecretKey, 'sk_test_1234567890abcdef');
136
+ assert.equal(stored.shopTitle, 'Emporium');
137
+ });
138
+
139
+ test('the Stripe webhook refuses an unsigned body', async () => {
140
+ await call('PUT', '/settings', {stripeWebhookSecret: 'whsec_abcdefghijklmnop'});
141
+ const res = await app.inject({method: 'POST', url: '/stripe/webhook', payload: '{"type":"checkout.session.completed"}',
142
+ headers: {'content-type': 'application/json', 'stripe-signature': 't=1,v1=00'}});
143
+ assert.equal(res.statusCode, 400);
144
+ });