@usequeek/theme-kit 0.1.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 (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
package/utils/brand.ts ADDED
@@ -0,0 +1,518 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type {
3
+ Brand,
4
+ DesignBorderWeight,
5
+ DesignDensity,
6
+ DesignElevation,
7
+ DesignHeadingCase,
8
+ DesignImageFilter,
9
+ DesignImageRadius,
10
+ DesignMotion,
11
+ DesignRadius,
12
+ DesignTokenColor,
13
+ DesignTokenImage,
14
+ DesignTokenShape,
15
+ DesignTokenSpace,
16
+ DesignTokenType,
17
+ DesignTokens,
18
+ StorefrontConfig,
19
+ } from '../types/vendor';
20
+
21
+ /**
22
+ * Storefront design-token injector — turns the resolved semantic `tokens`
23
+ * object (design-token-contract.md, queek_backend `.agent/.tmp/`) into the
24
+ * full CSS custom-property set every theme reads. Replaces the old
25
+ * `getBrandCssVariables`, which only ever emitted ~12 of the 24 color roles
26
+ * (the bug this fixes).
27
+ *
28
+ * `expandDesignTokens` is the pure, unit-tested core: given ANY partial (or
29
+ * absent) token object it fills every gap with a sane default and always
30
+ * emits every var — it can never crash and never omit a role.
31
+ *
32
+ * `getBrandCssVariables` is the compatible entry point still called from
33
+ * `provider.tsx`. It resolves the effective tokens (new `config.tokens` ←
34
+ * legacy `config.colors` / `brand.colors` / `brand.font` ← defaults) and
35
+ * hands them to `expandDesignTokens`, then layers on the handful of
36
+ * chrome vars (`--brand-footer-*`, explicit legacy button overrides) that
37
+ * live outside the token contract so existing stores render identically.
38
+ */
39
+
40
+ const DEFAULT_COLOR: Required<DesignTokenColor> = {
41
+ scheme: 'light',
42
+ bg: '#ffffff',
43
+ surface: '#f9f9f9',
44
+ surface_muted: '#f2f2f2',
45
+ text: '#111111',
46
+ text_muted: '#6b7280',
47
+ primary: '#111111',
48
+ accent: '#111111',
49
+ on_accent: '#ffffff',
50
+ border: '#e5e5e5',
51
+ success: '#16a34a',
52
+ warning: '#d97706',
53
+ danger: '#dc2626',
54
+ };
55
+
56
+ const DEFAULT_TYPE: Required<DesignTokenType> = {
57
+ heading_font: 'Georgia',
58
+ body_font: 'Helvetica Neue',
59
+ scale_ratio: 1.25,
60
+ heading_weight: 600,
61
+ body_weight: 400,
62
+ heading_case: 'none',
63
+ heading_tracking: 0,
64
+ body_tracking: 0,
65
+ };
66
+
67
+ const DEFAULT_SPACE: Required<DesignTokenSpace> = { density: 'cozy' };
68
+ const DEFAULT_SHAPE: Required<DesignTokenShape> = { radius: 'soft', border_weight: 'hairline' };
69
+ const DEFAULT_ELEVATION: DesignElevation = 'subtle';
70
+ const DEFAULT_MOTION: DesignMotion = 'subtle';
71
+ const DEFAULT_IMAGE: Required<DesignTokenImage> = { radius: 'soft', fit: 'cover', filter: 'none' };
72
+
73
+ const DENSITY_UNIT_REM: Record<DesignDensity, number> = {
74
+ compact: 0.8,
75
+ cozy: 1,
76
+ comfortable: 1.25,
77
+ };
78
+
79
+ /** Fixed multiplier ladder for --space-1..--space-8, scaled by the density unit. */
80
+ const SPACE_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
81
+
82
+ const RADIUS_SCALE: Record<DesignRadius, { sm: string; md: string; lg: string }> = {
83
+ sharp: { sm: '0px', md: '0px', lg: '0px' },
84
+ soft: { sm: '6px', md: '10px', lg: '16px' },
85
+ rounded: { sm: '12px', md: '20px', lg: '28px' },
86
+ pill: { sm: '9999px', md: '9999px', lg: '9999px' },
87
+ };
88
+
89
+ const BORDER_WEIGHT_PX: Record<DesignBorderWeight, string> = {
90
+ hairline: '1px',
91
+ medium: '2px',
92
+ bold: '3px',
93
+ };
94
+
95
+ const ELEVATION_SHADOWS: Record<DesignElevation, [string, string, string]> = {
96
+ flat: ['none', 'none', 'none'],
97
+ subtle: [
98
+ '0 1px 2px rgba(15, 15, 15, 0.06)',
99
+ '0 4px 10px rgba(15, 15, 15, 0.08)',
100
+ '0 10px 24px rgba(15, 15, 15, 0.10)',
101
+ ],
102
+ lifted: [
103
+ '0 2px 6px rgba(15, 15, 15, 0.10)',
104
+ '0 8px 20px rgba(15, 15, 15, 0.14)',
105
+ '0 20px 40px rgba(15, 15, 15, 0.18)',
106
+ ],
107
+ };
108
+
109
+ const MOTION_TIMING: Record<DesignMotion, { fast: string; base: string; slow: string; ease: string }> = {
110
+ none: { fast: '0ms', base: '0ms', slow: '0ms', ease: 'linear' },
111
+ subtle: { fast: '150ms', base: '250ms', slow: '400ms', ease: 'ease' },
112
+ lively: { fast: '100ms', base: '180ms', slow: '280ms', ease: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
113
+ };
114
+
115
+ const CASE_MAP: Record<DesignHeadingCase, string> = {
116
+ none: 'none',
117
+ upper: 'uppercase',
118
+ 'small-caps': 'small-caps',
119
+ };
120
+
121
+ /**
122
+ * Deliberately its OWN fixed scale, not a reference into RADIUS_SCALE/
123
+ * shape.radius. Live-caught (15/8/26): when `--image-radius` pointed at
124
+ * `var(--radius-sm/md/lg)`, a vendor on the (very common) 'sharp' global
125
+ * Shape preset saw EVERY image-radius choice — sharp, soft, AND rounded —
126
+ * collapse to the same 0px, since 'sharp' flattens the whole sm/md/lg
127
+ * ladder to zero. A vendor's image-radius pick must always visibly do
128
+ * something, independent of their unrelated global Shape choice.
129
+ */
130
+ const IMAGE_RADIUS_MAP: Record<DesignImageRadius, string> = {
131
+ sharp: '0px',
132
+ soft: '10px',
133
+ rounded: '20px',
134
+ };
135
+
136
+ const IMAGE_FILTER_MAP: Record<DesignImageFilter, string> = {
137
+ none: 'none',
138
+ mono: 'grayscale(1)',
139
+ warm: 'sepia(0.35) saturate(1.1) contrast(0.98)',
140
+ };
141
+
142
+ /** n exponents for the --fs-xs..--fs-3xl ladder, base*ratio^n. */
143
+ const FONT_SIZE_STEPS: Array<[string, number]> = [
144
+ ['xs', -2],
145
+ ['sm', -1],
146
+ ['base', 0],
147
+ ['lg', 1],
148
+ ['xl', 2],
149
+ ['2xl', 3],
150
+ ['3xl', 4],
151
+ ];
152
+
153
+ const FONT_SIZE_BASE_PX = 16;
154
+
155
+ function hexToRgb(hex: string): [number, number, number] | null {
156
+ const cleaned = hex.trim().replace('#', '');
157
+ if (cleaned.length !== 3 && cleaned.length !== 6) return null;
158
+
159
+ const expanded =
160
+ cleaned.length === 3
161
+ ? cleaned
162
+ .split('')
163
+ .map((c) => c + c)
164
+ .join('')
165
+ : cleaned;
166
+
167
+ const r = parseInt(expanded.slice(0, 2), 16);
168
+ const g = parseInt(expanded.slice(2, 4), 16);
169
+ const b = parseInt(expanded.slice(4, 6), 16);
170
+
171
+ if ([r, g, b].some((value) => Number.isNaN(value))) return null;
172
+ return [r, g, b];
173
+ }
174
+
175
+ /** Darken (positive `amount`) or lighten (negative) a hex color by `amount` (0..1). Returns the input unchanged if unparsable. */
176
+ function shade(hex: string, amount: number): string {
177
+ const rgb = hexToRgb(hex);
178
+ if (!rgb) return hex;
179
+
180
+ const clamp = (value: number): number => Math.max(0, Math.min(255, Math.round(value)));
181
+ const toHex = (value: number): string => clamp(value).toString(16).padStart(2, '0');
182
+
183
+ const [r, g, b] = rgb;
184
+ const delta = 255 * amount;
185
+ return `#${toHex(r - delta)}${toHex(g - delta)}${toHex(b - delta)}`;
186
+ }
187
+
188
+ /** Low-alpha tint of a hex color, e.g. for --brand-accent-soft. Returns the input unchanged if unparsable. */
189
+ function tint(hex: string, alpha: number): string {
190
+ const rgb = hexToRgb(hex);
191
+ if (!rgb) return hex;
192
+ const [r, g, b] = rgb;
193
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
194
+ }
195
+
196
+ /** WCAG relative luminance (0=black..1=white). Returns null if unparsable. */
197
+ function relativeLuminance(hex: string): number | null {
198
+ const rgb = hexToRgb(hex);
199
+ if (!rgb) return null;
200
+ const [r, g, b] = rgb.map((channel) => {
201
+ const s = channel / 255;
202
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
203
+ });
204
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
205
+ }
206
+
207
+ /** WCAG contrast ratio (1..21) between two hex colors. Returns null if either is unparsable. */
208
+ function contrastRatio(hexA: string, hexB: string): number | null {
209
+ const lumA = relativeLuminance(hexA);
210
+ const lumB = relativeLuminance(hexB);
211
+ if (lumA === null || lumB === null) return null;
212
+ const lighter = Math.max(lumA, lumB);
213
+ const darker = Math.min(lumA, lumB);
214
+ return (lighter + 0.05) / (darker + 0.05);
215
+ }
216
+
217
+ /** WCAG AA body-text minimum. Below this, `accent`/`on_accent` reads as
218
+ * the same color rather than text-on-a-surface (the Taylor/glow incident:
219
+ * a vendor's legacy `colors.accent` override went to near-black while
220
+ * `on_accent` silently kept the theme's own default — also near-black,
221
+ * since it was tuned to pair with THAT theme's accent, not the vendor's). */
222
+ const MIN_ON_ACCENT_CONTRAST = 4.5;
223
+
224
+ /** Whichever of pure black/white reads better on `hex` — the standard
225
+ * "pick a readable label color" fallback used when a real design system
226
+ * value isn't available. */
227
+ function pickReadableTextColor(hex: string): string {
228
+ const onBlack = contrastRatio(hex, '#000000') ?? 0;
229
+ const onWhite = contrastRatio(hex, '#ffffff') ?? 0;
230
+ return onWhite >= onBlack ? '#ffffff' : '#000000';
231
+ }
232
+
233
+ /**
234
+ * Guards against `on_accent` silently pairing with an `accent` it was never
235
+ * designed for. `accent` and `on_accent` are independent fields (see
236
+ * `emitColorVars`) that can arrive from different layers of the merge in
237
+ * `resolveEffectiveTokens` — a vendor's legacy `config.colors.accent`
238
+ * override, for instance, has no `on_accent` counterpart in that legacy
239
+ * shape at all, so `on_accent` falls through to the THEME's own default,
240
+ * tuned for the theme's own accent color, not the vendor's. Only corrects
241
+ * when the pairing actually fails contrast; a theme's own (already-tuned)
242
+ * accent/on_accent pair is never touched. */
243
+ function ensureReadableOnAccent(color: DesignTokenColor & { accent: string; on_accent: string }): void {
244
+ const ratio = contrastRatio(color.accent, color.on_accent);
245
+ if (ratio !== null && ratio < MIN_ON_ACCENT_CONTRAST) {
246
+ color.on_accent = pickReadableTextColor(color.accent);
247
+ }
248
+ }
249
+
250
+ function px(value: number): string {
251
+ return `${Math.round(value * 100) / 100}px`;
252
+ }
253
+
254
+ function rem(value: number): string {
255
+ return `${Math.round(value * 1000) / 1000}rem`;
256
+ }
257
+
258
+ /**
259
+ * Per-dimension emitters — the single source of truth for each enum→CSS-value
260
+ * lookup. Each emitter only writes the vars derived from the keys actually
261
+ * present (`!== undefined`) on the object it's given, so the SAME function
262
+ * powers both:
263
+ * - `expandDesignTokens` (called with a group merged over its `DEFAULT_*`
264
+ * constant, so every key is always present → every var always emitted,
265
+ * identical to the pre-refactor behavior), and
266
+ * - `expandDesignTokensPartial` (called with the raw partial group, so only
267
+ * explicitly-set keys emit vars, nothing is filled from defaults).
268
+ * A few vars are constants unrelated to any input key (`--leading-*`,
269
+ * `--radius-pill`) — those stay inline in `expandDesignTokens` only, since a
270
+ * partial override has nothing to derive them from.
271
+ */
272
+ function emitColorVars(color: DesignTokenColor, vars: Record<string, string>): void {
273
+ if (color.bg !== undefined) vars['--brand-bg'] = color.bg;
274
+ if (color.surface !== undefined) vars['--brand-surface'] = color.surface;
275
+ if (color.surface_muted !== undefined) vars['--brand-surface-muted'] = color.surface_muted;
276
+ if (color.text !== undefined) vars['--brand-text'] = color.text;
277
+ if (color.text_muted !== undefined) vars['--brand-text-muted'] = color.text_muted;
278
+ if (color.primary !== undefined) {
279
+ vars['--brand-primary'] = color.primary;
280
+ vars['--brand-button-bg'] = color.primary;
281
+ vars['--brand-button-hover-bg'] = shade(color.primary, 0.08);
282
+ }
283
+ if (color.accent !== undefined) {
284
+ vars['--brand-accent'] = color.accent;
285
+ vars['--brand-accent-soft'] = tint(color.accent, 0.12);
286
+ }
287
+ if (color.on_accent !== undefined) {
288
+ vars['--brand-on-accent'] = color.on_accent;
289
+ vars['--brand-button-text'] = color.on_accent;
290
+ }
291
+ if (color.border !== undefined) vars['--brand-border'] = color.border;
292
+ if (color.success !== undefined) vars['--brand-success'] = color.success;
293
+ if (color.warning !== undefined) vars['--brand-warning'] = color.warning;
294
+ if (color.danger !== undefined) vars['--brand-danger'] = color.danger;
295
+ }
296
+
297
+ function emitTypeVars(type: DesignTokenType, vars: Record<string, string>): void {
298
+ if (type.heading_font !== undefined) vars['--font-heading'] = type.heading_font;
299
+ if (type.body_font !== undefined) vars['--font-body'] = type.body_font;
300
+ if (type.scale_ratio !== undefined) {
301
+ for (const [name, n] of FONT_SIZE_STEPS) {
302
+ vars[`--fs-${name}`] = px(FONT_SIZE_BASE_PX * (type.scale_ratio as number) ** n);
303
+ }
304
+ }
305
+ if (type.heading_weight !== undefined) vars['--fw-heading'] = String(type.heading_weight);
306
+ if (type.body_weight !== undefined) vars['--fw-body'] = String(type.body_weight);
307
+ if (type.heading_case !== undefined) vars['--case-heading'] = CASE_MAP[type.heading_case];
308
+ if (type.heading_tracking !== undefined) vars['--tracking-heading'] = `${type.heading_tracking}em`;
309
+ if (type.body_tracking !== undefined) vars['--tracking-body'] = `${type.body_tracking}em`;
310
+ }
311
+
312
+ function emitSpaceVars(space: DesignTokenSpace, vars: Record<string, string>): void {
313
+ if (space.density === undefined) return;
314
+ const unit = DENSITY_UNIT_REM[space.density];
315
+ vars['--space-unit'] = rem(unit);
316
+ SPACE_STEPS.forEach((multiplier, index) => {
317
+ vars[`--space-${index + 1}`] = rem(unit * multiplier);
318
+ });
319
+ }
320
+
321
+ function emitShapeVars(shape: DesignTokenShape, vars: Record<string, string>): void {
322
+ if (shape.radius !== undefined) {
323
+ const radiusScale = RADIUS_SCALE[shape.radius];
324
+ vars['--radius-sm'] = radiusScale.sm;
325
+ vars['--radius-md'] = radiusScale.md;
326
+ vars['--radius-lg'] = radiusScale.lg;
327
+ }
328
+ if (shape.border_weight !== undefined) vars['--border-weight'] = BORDER_WEIGHT_PX[shape.border_weight];
329
+ }
330
+
331
+ function emitElevationVars(elevation: DesignElevation, vars: Record<string, string>): void {
332
+ const [shadow1, shadow2, shadow3] = ELEVATION_SHADOWS[elevation];
333
+ vars['--shadow-1'] = shadow1;
334
+ vars['--shadow-2'] = shadow2;
335
+ vars['--shadow-3'] = shadow3;
336
+ }
337
+
338
+ function emitMotionVars(motion: DesignMotion, vars: Record<string, string>): void {
339
+ const timing = MOTION_TIMING[motion];
340
+ vars['--duration-fast'] = timing.fast;
341
+ vars['--duration-base'] = timing.base;
342
+ vars['--duration-slow'] = timing.slow;
343
+ vars['--ease'] = timing.ease;
344
+ }
345
+
346
+ /**
347
+ * `includeRadius` defaults to true (section-scoped partial callers want it).
348
+ * The GLOBAL expander (`expandDesignTokens`) explicitly passes `false`: emitting
349
+ * `--image-radius` on the root/brand wrapper would flatten every themed image
350
+ * to one radius site-wide, since a `var(--image-radius, <per-element-default>)`
351
+ * fallback is only ever used when the property is unset ANYWHERE in the
352
+ * cascade — once a root value exists, it wins for every descendant and the
353
+ * element's own bespoke fallback (small pill thumb vs. full-bleed hero, etc.)
354
+ * never applies. `--image-fit`/`--image-filter` have no such per-element
355
+ * variance in this theme, so they stay uniform/global by design.
356
+ */
357
+ function emitImageVars(
358
+ image: DesignTokenImage,
359
+ vars: Record<string, string>,
360
+ { includeRadius = true }: { includeRadius?: boolean } = {},
361
+ ): void {
362
+ if (includeRadius && image.radius !== undefined) vars['--image-radius'] = IMAGE_RADIUS_MAP[image.radius];
363
+ if (image.fit !== undefined) vars['--image-fit'] = image.fit;
364
+ if (image.filter !== undefined) vars['--image-filter'] = IMAGE_FILTER_MAP[image.filter];
365
+ }
366
+
367
+ /**
368
+ * Pure expander: resolved (possibly partial/absent) semantic tokens →
369
+ * the full CSS custom-property set every theme reads. Every group is
370
+ * merged over its defaults independently, so a token object missing
371
+ * entire groups (or being `null`/`undefined` altogether) still produces
372
+ * every var with a sane value — never throws, never omits a role.
373
+ */
374
+ export function expandDesignTokens(tokens?: DesignTokens | null): Record<string, string> {
375
+ const color = { ...DEFAULT_COLOR, ...tokens?.color };
376
+ ensureReadableOnAccent(color);
377
+ const type = { ...DEFAULT_TYPE, ...tokens?.type };
378
+ const space = { ...DEFAULT_SPACE, ...tokens?.space };
379
+ const shape = { ...DEFAULT_SHAPE, ...tokens?.shape };
380
+ const elevation = tokens?.elevation ?? DEFAULT_ELEVATION;
381
+ const motion = tokens?.motion ?? DEFAULT_MOTION;
382
+ const image = { ...DEFAULT_IMAGE, ...tokens?.image };
383
+
384
+ const vars: Record<string, string> = {};
385
+
386
+ // COLOR — every role, fixing the missing-12 bug.
387
+ emitColorVars(color, vars);
388
+
389
+ // TYPE
390
+ emitTypeVars(type, vars);
391
+ vars['--leading-heading'] = '1.15';
392
+ vars['--leading-body'] = '1.5';
393
+
394
+ // SPACE
395
+ emitSpaceVars(space, vars);
396
+
397
+ // SHAPE
398
+ emitShapeVars(shape, vars);
399
+ vars['--radius-pill'] = '9999px';
400
+
401
+ // ELEVATION
402
+ emitElevationVars(elevation, vars);
403
+
404
+ // MOTION
405
+ emitMotionVars(motion, vars);
406
+
407
+ // IMAGE — fit/filter only; radius is intentionally per-section-only (see
408
+ // emitImageVars doc comment) so it never flattens every image site-wide.
409
+ emitImageVars(image, vars, { includeRadius: false });
410
+
411
+ return vars;
412
+ }
413
+
414
+ /**
415
+ * Section-scoped partial expander: given ONLY the keys a single page section
416
+ * overrides (e.g. `{ image: { radius: 'rounded' } }`), returns ONLY the
417
+ * `--var` entries derived from those keys — no defaults are filled in for
418
+ * anything else. Meant to be applied on a small wrapper around one section so
419
+ * unset vars fall through the CSS cascade to the global `--var` map injected
420
+ * by `getBrandCssVariables` on the brand-root wrapper (`provider.tsx`).
421
+ *
422
+ * Reuses the exact same `emit*Vars` enum→value lookups as `expandDesignTokens`
423
+ * — no duplicated maps/switches.
424
+ */
425
+ export function expandDesignTokensPartial(partial?: Partial<DesignTokens> | null): Record<string, string> {
426
+ const vars: Record<string, string> = {};
427
+ if (!partial) return vars;
428
+
429
+ if (partial.color) emitColorVars(partial.color, vars);
430
+ if (partial.type) emitTypeVars(partial.type, vars);
431
+ if (partial.space) emitSpaceVars(partial.space, vars);
432
+ if (partial.shape) emitShapeVars(partial.shape, vars);
433
+ if (partial.elevation !== undefined) emitElevationVars(partial.elevation, vars);
434
+ if (partial.motion !== undefined) emitMotionVars(partial.motion, vars);
435
+ if (partial.image) emitImageVars(partial.image, vars);
436
+
437
+ return vars;
438
+ }
439
+
440
+ /**
441
+ * Back-compat bridge: maps the LEGACY `config.colors` / `brand.colors` /
442
+ * `brand.font` (or `config.typography`) fields — the only source for the
443
+ * ~100s of live stores with no `tokens` yet — onto the semantic token
444
+ * shape so `expandDesignTokens` can fill the rest from its own defaults.
445
+ * Only the groups legacy data can actually populate (color, type) are
446
+ * returned; space/shape/elevation/motion/image always fall through to
447
+ * the token defaults, matching current (untokenized) rendering.
448
+ */
449
+ function legacyTokensFromConfig(brand?: Brand | null, config?: StorefrontConfig | null): DesignTokens {
450
+ const overrides = config?.colors;
451
+ const brandColors = brand?.colors;
452
+ const pickColor = (key: keyof NonNullable<typeof brandColors>): string | undefined =>
453
+ (overrides?.[key] as string | undefined) ?? brandColors?.[key];
454
+
455
+ const color: DesignTokenColor | undefined =
456
+ overrides || brandColors
457
+ ? {
458
+ bg: pickColor('background'),
459
+ surface: pickColor('surface'),
460
+ text: pickColor('text'),
461
+ text_muted: pickColor('text_muted'),
462
+ primary: pickColor('primary'),
463
+ accent: pickColor('accent'),
464
+ border: pickColor('border'),
465
+ }
466
+ : undefined;
467
+
468
+ const headingFont = config?.typography?.heading ?? brand?.font?.heading;
469
+ const bodyFont = config?.typography?.body ?? brand?.font?.body;
470
+ const type: DesignTokenType | undefined =
471
+ headingFont || bodyFont ? { heading_font: headingFont, body_font: bodyFont } : undefined;
472
+
473
+ return { color, type };
474
+ }
475
+
476
+ /**
477
+ * Resolution order per the contract: `config.tokens` (per-group) ←
478
+ * legacy fields ← (handled by `expandDesignTokens`'s own defaults).
479
+ */
480
+ function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig | null): DesignTokens {
481
+ const legacy = legacyTokensFromConfig(brand, config);
482
+ const tokens = config?.tokens;
483
+
484
+ if (!tokens) return legacy;
485
+
486
+ return {
487
+ color: { ...legacy.color, ...tokens.color },
488
+ type: { ...legacy.type, ...tokens.type },
489
+ space: tokens.space ?? legacy.space,
490
+ shape: { ...legacy.shape, ...tokens.shape },
491
+ elevation: tokens.elevation ?? legacy.elevation,
492
+ motion: tokens.motion ?? legacy.motion,
493
+ image: { ...legacy.image, ...tokens.image },
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Resolve brand CSS variables for the `provider.tsx` wrapper. Emits the
499
+ * full token-derived var set (see `expandDesignTokens`), then layers on
500
+ * the legacy chrome/override vars that sit outside the token contract:
501
+ * footer colors and an explicit `config.colors.button(_text)` override
502
+ * (pre-tokens stores could set a button color independent of `primary`).
503
+ */
504
+ export function getBrandCssVariables(
505
+ brand?: Brand | null,
506
+ config?: StorefrontConfig | null,
507
+ ): CSSProperties {
508
+ const tokens = resolveEffectiveTokens(brand, config);
509
+ const vars = expandDesignTokens(tokens);
510
+
511
+ if (config?.footer?.bg) vars['--brand-footer-bg'] = config.footer.bg;
512
+ if (config?.footer?.text) vars['--brand-footer-text'] = config.footer.text;
513
+ if (config?.footer?.muted) vars['--brand-footer-muted'] = config.footer.muted;
514
+ if (config?.colors?.button) vars['--brand-button-bg'] = config.colors.button;
515
+ if (config?.colors?.button_text) vars['--brand-button-text'] = config.colors.button_text;
516
+
517
+ return vars as CSSProperties;
518
+ }
@@ -0,0 +1,44 @@
1
+ import type { ContactBlockData } from '../types/block';
2
+
3
+ const HOURS_RANGE = /^\s*(\d{1,2})(?::(\d{2}))?\s*(?:-|–|to)\s*(\d{1,2})(?::(\d{2}))?\s*$/i;
4
+
5
+ function parseCoordinate(value: number | string | null | undefined, minimum: number, maximum: number): number | null {
6
+ const coordinate = typeof value === 'string' ? Number(value) : value;
7
+ return typeof coordinate === 'number' && Number.isFinite(coordinate) && coordinate >= minimum && coordinate <= maximum
8
+ ? coordinate
9
+ : null;
10
+ }
11
+
12
+ export function normalizeContactHours(hours?: string): string | undefined {
13
+ const value = hours?.trim();
14
+ if (!value) return undefined;
15
+
16
+ const match = value.match(HOURS_RANGE);
17
+ if (!match) return value;
18
+
19
+ const start = `${match[1].padStart(2, '0')}:${(match[2] ?? '00').padStart(2, '0')}`;
20
+ const end = `${match[3].padStart(2, '0')}:${(match[4] ?? '00').padStart(2, '0')}`;
21
+ return start === end ? 'Open 24 hours' : value;
22
+ }
23
+
24
+ export function getContactPresentation(data: ContactBlockData): {
25
+ hasContactFacts: boolean;
26
+ mapSrc: string | null;
27
+ directionsUrl: string | null;
28
+ hours: string | undefined;
29
+ } {
30
+ const address = data.address?.trim();
31
+ const latitude = parseCoordinate(data.map_lat, -90, 90);
32
+ const longitude = parseCoordinate(data.map_lng, -180, 180);
33
+ const hasCoordinates = latitude !== null && longitude !== null;
34
+ const explicitMapEmbed = data.map_embed_url?.trim() || null;
35
+ const mapSrc = explicitMapEmbed
36
+ ?? (hasCoordinates ? `https://www.google.com/maps?q=${encodeURIComponent(`${latitude},${longitude}`)}&output=embed` : null);
37
+ const directionsUrl = data.cta_url ?? (address ? `https://www.google.com/maps?q=${encodeURIComponent(address)}` : null);
38
+ const hours = normalizeContactHours(data.hours);
39
+ const hasContactFacts = Boolean(
40
+ address || data.email || data.phone || hours || explicitMapEmbed || hasCoordinates || data.socials?.length,
41
+ );
42
+
43
+ return { hasContactFacts, mapSrc, directionsUrl, hours };
44
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Was hardcoded to `maximumFractionDigits: 0` — which, per Intl's own
3
+ * resolution rules, also clamps the (otherwise currency-default 2) minimum
4
+ * down to 0, so it silently truncated EVERY fractional-naira amount to a
5
+ * whole number, everywhere in the storefront. A ₦0.28 balance (e.g. a tiny
6
+ * residual left after a near-100% discount) displayed as "₦0" — the customer
7
+ * saw a free checkout, then got asked to actually pay something. POS already
8
+ * shows real money correctly (lib/currency.ts: minimumFractionDigits: 2,
9
+ * maximumFractionDigits: 2, always).
10
+ *
11
+ * Deliberately NOT matching POS's always-2-decimals contract here: the
12
+ * overwhelming majority of storefront prices are whole naira, and putting
13
+ * ".00" on every single one would be a large, unrequested visual change
14
+ * across every theme. `minimumFractionDigits: 0, maximumFractionDigits: 2`
15
+ * shows decimals only when the amount genuinely has them — ₦5,000 stays
16
+ * ₦5,000, ₦0.28 now correctly shows as ₦0.28.
17
+ */
18
+ export function formatMoney(amount: number, currency = 'NGN'): string {
19
+ try {
20
+ return new Intl.NumberFormat('en-NG', {
21
+ style: 'currency',
22
+ currency,
23
+ minimumFractionDigits: 0,
24
+ maximumFractionDigits: 2,
25
+ }).format(amount);
26
+ } catch {
27
+ return `${currency} ${amount.toLocaleString()}`;
28
+ }
29
+ }
30
+
31
+ export function toPercent(value: number): string {
32
+ return `${Math.max(0, Math.round(value))}%`;
33
+ }
34
+
35
+ /**
36
+ * Turn a backend status string ("ready_for_pickup", "pending_payment") into a
37
+ * human-readable label ("Ready For Pickup", "Pending Payment"). Falls back to
38
+ * the original when given an empty value.
39
+ */
40
+ export function humanizeStatus(value?: string | null): string {
41
+ const v = (value ?? '').toString().trim();
42
+ if (!v) return '';
43
+ return v
44
+ .replace(/[_-]+/g, ' ')
45
+ .split(' ')
46
+ .filter(Boolean)
47
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
48
+ .join(' ');
49
+ }
50
+
51
+ /**
52
+ * Order status label, aware of the offline-payment confirmation gate: plain
53
+ * "Pending Payment" reads as "nothing has happened yet," but for a bank-
54
+ * transfer order the customer already sent the money — it's the vendor's
55
+ * confirmation that's pending, not the payment itself. Every other status
56
+ * (including pending_payment for an online-gateway order, where the
57
+ * customer genuinely hasn't paid yet) is untouched.
58
+ */
59
+ export function orderStatusLabel(order: { status?: string | null; status_label?: string | null; payment_status?: string | null }): string {
60
+ if (order.payment_status === 'awaiting_confirmation') {
61
+ return 'Pending Payment Confirmation';
62
+ }
63
+ return order.status_label || humanizeStatus(order.status);
64
+ }
@@ -0,0 +1,35 @@
1
+ import type { GalleryBlockData } from '../types/block';
2
+
3
+ type HeroSlide = GalleryBlockData['images'][number];
4
+
5
+ export interface HeroFallbacks {
6
+ title: string;
7
+ subtitle?: string | null;
8
+ shopHref: string;
9
+ }
10
+
11
+ export interface ResolvedHeroContent {
12
+ title: string;
13
+ subtitle: string | null;
14
+ ctaLabel: string;
15
+ ctaHref: string;
16
+ slideHref: string | null;
17
+ }
18
+
19
+ export function heroImageMode(images: GalleryBlockData['images']): 'zero' | 'single' | 'multi' {
20
+ if (images.length === 0) return 'zero';
21
+ return images.length === 1 ? 'single' : 'multi';
22
+ }
23
+
24
+ export function resolveHeroContent(
25
+ slide: HeroSlide | undefined,
26
+ fallback: HeroFallbacks,
27
+ ): ResolvedHeroContent {
28
+ return {
29
+ title: slide?.title?.trim() || fallback.title,
30
+ subtitle: slide?.subtitle?.trim() || fallback.subtitle?.trim() || null,
31
+ ctaLabel: slide?.cta_label?.trim() || 'Shop now',
32
+ ctaHref: slide?.cta_url?.trim() || fallback.shopHref,
33
+ slideHref: slide?.link?.trim() || null,
34
+ };
35
+ }