@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.
- package/README.md +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- 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
|
+
}
|
package/utils/format.ts
ADDED
|
@@ -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
|
+
}
|