@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
@@ -0,0 +1,243 @@
1
+ import type { Menu } from './menu';
2
+ import type { ImageVariants } from './media';
3
+
4
+ export interface BrandFont {
5
+ heading: string;
6
+ body: string;
7
+ }
8
+
9
+ /**
10
+ * Semantic design-token shapes — the FROZEN contract
11
+ * (`.agent/.tmp/design-token-contract.md` in queek_backend). Backend resolves
12
+ * theme defaults ← legacy vendor fields ← vendor `tokens` overrides and
13
+ * returns this object under `StorefrontConfig.tokens`. Every field is
14
+ * optional: during the transition many vendors have no `tokens` at all, and
15
+ * the storefront injector (`lib/core/utils/brand.ts`) fills every gap with a
16
+ * sane default so it can never crash or omit a CSS var.
17
+ */
18
+ export type DesignColorScheme = 'light' | 'dark';
19
+ export type DesignScaleRatio = 1.125 | 1.2 | 1.25 | 1.333;
20
+ export type DesignHeadingCase = 'none' | 'upper' | 'small-caps';
21
+ export type DesignDensity = 'compact' | 'cozy' | 'comfortable';
22
+ export type DesignRadius = 'sharp' | 'soft' | 'rounded' | 'pill';
23
+ export type DesignBorderWeight = 'hairline' | 'medium' | 'bold';
24
+ export type DesignElevation = 'flat' | 'subtle' | 'lifted';
25
+ export type DesignMotion = 'none' | 'subtle' | 'lively';
26
+ export type DesignImageRadius = 'sharp' | 'soft' | 'rounded';
27
+ export type DesignImageFit = 'cover' | 'contain';
28
+ export type DesignImageFilter = 'none' | 'mono' | 'warm';
29
+
30
+ export interface DesignTokenColor {
31
+ scheme?: DesignColorScheme;
32
+ bg?: string;
33
+ surface?: string;
34
+ surface_muted?: string;
35
+ text?: string;
36
+ text_muted?: string;
37
+ primary?: string;
38
+ accent?: string;
39
+ on_accent?: string;
40
+ border?: string;
41
+ success?: string;
42
+ warning?: string;
43
+ danger?: string;
44
+ }
45
+
46
+ export interface DesignTokenType {
47
+ heading_font?: string;
48
+ body_font?: string;
49
+ scale_ratio?: DesignScaleRatio;
50
+ heading_weight?: number;
51
+ body_weight?: number;
52
+ heading_case?: DesignHeadingCase;
53
+ heading_tracking?: number;
54
+ body_tracking?: number;
55
+ }
56
+
57
+ export interface DesignTokenSpace {
58
+ density?: DesignDensity;
59
+ }
60
+
61
+ export interface DesignTokenShape {
62
+ radius?: DesignRadius;
63
+ border_weight?: DesignBorderWeight;
64
+ }
65
+
66
+ export interface DesignTokenImage {
67
+ radius?: DesignImageRadius;
68
+ fit?: DesignImageFit;
69
+ filter?: DesignImageFilter;
70
+ }
71
+
72
+ export interface DesignTokens {
73
+ color?: DesignTokenColor;
74
+ type?: DesignTokenType;
75
+ space?: DesignTokenSpace;
76
+ shape?: DesignTokenShape;
77
+ elevation?: DesignElevation;
78
+ motion?: DesignMotion;
79
+ image?: DesignTokenImage;
80
+ }
81
+
82
+ /**
83
+ * One of the 7 top-level design-token dimensions.
84
+ */
85
+ export type DesignTokenAxis = keyof DesignTokens;
86
+
87
+ /**
88
+ * A single stylable LEAF inside those 7 dimensions. `ThemeManifestVariant.editable`
89
+ * lists exactly which of these a variant's rendered markup/CSS actually consumes —
90
+ * not just the parent dimension as a whole. A dimension with multiple UI sub-controls
91
+ * (shape → radius + border_weight; image → radius + fit + filter; color → primary +
92
+ * accent) can be live on ONE sub-control while hardcoded on another — e.g. a
93
+ * variant's `<img>` genuinely reads `--image-fit`/`--image-filter` while its
94
+ * container's radius is a hardcoded literal, or a button's border reads
95
+ * `--border-weight` while its radius doesn't. Declaring only the dimension name
96
+ * (e.g. `'shape'`) would show a dead sub-dial next to a live one — this is the
97
+ * exact 2026-08-03 gap found in Allure's products/carousel section (Image radius
98
+ * and Corner radius dead, Image fit/filter and Border weight live, all under the
99
+ * same 'shape'/'image' bundle). `type`/`elevation`/`motion` have no UI sub-controls
100
+ * today, so they stay bare (no `.field` suffix) — add one here the day a sub-dial
101
+ * is added for them.
102
+ */
103
+ export type DesignTokenAxisPath =
104
+ | 'color.primary'
105
+ | 'color.accent'
106
+ | 'type'
107
+ | 'space.density'
108
+ | 'shape.radius'
109
+ | 'shape.border_weight'
110
+ | 'elevation'
111
+ | 'motion'
112
+ | 'image.radius'
113
+ | 'image.fit'
114
+ | 'image.filter';
115
+
116
+ export interface BrandPalette {
117
+ primary: string;
118
+ accent: string;
119
+ background: string;
120
+ surface: string;
121
+ text: string;
122
+ text_muted: string;
123
+ border: string;
124
+ }
125
+
126
+ export interface Brand {
127
+ id: string;
128
+ name: string;
129
+ mode: 'light' | 'dark';
130
+ font_key?: string;
131
+ font?: BrandFont;
132
+ colors: BrandPalette;
133
+ }
134
+
135
+ export interface VendorProfile {
136
+ id: string;
137
+ slug: string | null;
138
+ name: string | null;
139
+ logo: string | null;
140
+ banner?: string | null;
141
+ /** VendorResource:126-127. Sits beside the unchanged raw `logo`/`banner`
142
+ * urls; null when the file has no Media row (common on older vendors). */
143
+ logo_variants?: ImageVariants | null;
144
+ banner_variants?: ImageVariants | null;
145
+ tagline?: string | null;
146
+ address?: string | null;
147
+ delivery_time?: string | null;
148
+ currency?: string | null;
149
+ rating_value?: number;
150
+ rating_count?: number;
151
+ rating_label?: string | null;
152
+ }
153
+
154
+ export interface StorefrontConfig {
155
+ theme: string;
156
+ brand: Brand;
157
+ available_brands?: Array<{ id: string; name: string }>;
158
+ header?: {
159
+ variant?: string;
160
+ logo_url?: string | null;
161
+ announcement?: {
162
+ enabled: boolean;
163
+ text: string;
164
+ link?: string | null;
165
+ } | null;
166
+ show_search?: boolean;
167
+ show_cart?: boolean;
168
+ show_account?: boolean;
169
+ show_offers?: boolean;
170
+ };
171
+ footer?: {
172
+ variant?: string;
173
+ heading?: string | null;
174
+ tagline?: string | null;
175
+ columns?: Array<{ heading: string; content: string }>;
176
+ socials?: Record<string, string>;
177
+ copyright?: string | null;
178
+ bg?: string | null;
179
+ text?: string | null;
180
+ muted?: string | null;
181
+ };
182
+ colors?: Partial<BrandPalette> & { button?: string; button_text?: string };
183
+ typography?: {
184
+ font?: string;
185
+ heading?: string;
186
+ body?: string;
187
+ };
188
+ /** Resolved semantic design tokens (see design-token-contract.md). Absent for stores not yet migrated. */
189
+ tokens?: DesignTokens | null;
190
+ menus?: {
191
+ header?: Menu;
192
+ footer?: Menu;
193
+ };
194
+ default_variants?: Record<string, string>;
195
+ pages?: Record<string, {
196
+ banner?: string;
197
+ title?: string;
198
+ subtitle?: string;
199
+ /** QR-only header overrides, each falling back to the vendor's own
200
+ * profile field when unset (see QrHeader). `phones` has NO vendor
201
+ * fallback — the vendor's own support_phone is deliberately masked in
202
+ * the public API (Customer\VendorResource — anti-scraping/privacy), so
203
+ * a phone only ever shows on the QR page if explicitly published here. */
204
+ address?: string;
205
+ slogan?: string;
206
+ phones?: string[];
207
+ /** QR page footer (only emitted for `pages.qr` when in-store ordering is on). */
208
+ footer?: {
209
+ enabled?: boolean;
210
+ text?: string;
211
+ show_socials?: boolean;
212
+ };
213
+ /** QR product-row layout (only emitted for `pages.qr`). Defaults to 'row'. */
214
+ listing_style?: 'row' | 'stacked';
215
+ /** Darkening scrim over the QR home page's collection/category card
216
+ * images (only emitted for `pages.qr`). Defaults to 'medium' — bright
217
+ * vendor photos otherwise swallow the white card label. */
218
+ tile_overlay?: 'none' | 'light' | 'medium' | 'strong';
219
+ seo?: {
220
+ title?: string;
221
+ description?: string;
222
+ og_image?: string;
223
+ keywords?: string[];
224
+ canonical_url?: string;
225
+ noindex?: boolean;
226
+ };
227
+ }>;
228
+ apps?: {
229
+ whatsapp_chat?: { enabled?: boolean; number?: string; message?: string; position?: string; bottom_offset?: number };
230
+ analytics?: { enabled?: boolean; ga_id?: string };
231
+ follow_card?: { enabled?: boolean; display?: 'floating' | 'footer' | 'block'; position?: string };
232
+ subscribe?: {
233
+ enabled?: boolean;
234
+ variant?: string;
235
+ heading?: string;
236
+ tagline?: string;
237
+ cta?: string;
238
+ /** `modal` variant only — when it should pop up. Backend field pending; defaults to 'scroll' when unset. */
239
+ trigger?: 'immediate' | 'delay' | 'scroll';
240
+ };
241
+ };
242
+ followers_count?: number;
243
+ }
@@ -0,0 +1,31 @@
1
+ import type { ProductAddon } from '../types/product';
2
+ import type { CartAddon } from '../types/cart';
3
+
4
+ /**
5
+ * Every PDP `pages/product.tsx` tracks addon picks as a `Set<"addonId:itemId">`
6
+ * (see each theme's `toggleAddon`). This turns that selection into the
7
+ * `CartAddon[]` shape `addItemWithAddons` actually persists — the piece every
8
+ * theme's PDP was missing, so a customer's addon/pack picks were silently
9
+ * dropped on add-to-cart regardless of what the UI showed them selecting.
10
+ */
11
+ export function resolveSelectedAddons(addons: ProductAddon[], selected: Set<string>): CartAddon[] {
12
+ const result: CartAddon[] = [];
13
+ for (const addon of addons) {
14
+ for (const item of addon.items) {
15
+ if (selected.has(`${addon.id}:${item.id}`)) {
16
+ result.push({ addon_id: addon.id, item_id: item.id, name: item.display_name ?? item.name, price: item.price });
17
+ }
18
+ }
19
+ }
20
+ return result;
21
+ }
22
+
23
+ /** True once every required addon group's minimum is met by `selected`. */
24
+ export function requiredAddonsSatisfied(addons: ProductAddon[], selected: Set<string>): boolean {
25
+ return addons
26
+ .filter((addon) => addon.required)
27
+ .every((addon) => {
28
+ const count = addon.items.filter((item) => selected.has(`${addon.id}:${item.id}`)).length;
29
+ return count >= Math.max(1, addon.min || 1);
30
+ });
31
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shared icon-keyword vocabulary for "benefit row" content blocks (glow's
3
+ * `trust-badges`, any theme's `features-columns`, and similar icon+title+text
4
+ * item lists). qee generates a short keyword per item (e.g. "star", "truck"),
5
+ * never raw SVG markup — themes render that keyword through this resolver
6
+ * instead of each theme re-declaring its own switch (glow and allure both
7
+ * had one, independently, before this was extracted) or a theme trying to
8
+ * `dangerouslySetInnerHTML` the keyword directly (renders as literal text,
9
+ * not an icon — the bug that prompted this).
10
+ *
11
+ * `currentColor` throughout — the caller controls color via CSS `color` on
12
+ * an ancestor, same convention every existing usage already followed.
13
+ */
14
+ export const BENEFIT_ICON_KEYS = [
15
+ 'check',
16
+ 'shipping',
17
+ 'leaf',
18
+ 'shield',
19
+ 'star',
20
+ 'wallet',
21
+ 'refresh',
22
+ 'heart',
23
+ 'gift',
24
+ 'clock',
25
+ 'lock',
26
+ 'sparkle',
27
+ ] as const;
28
+
29
+ export type BenefitIconKey = (typeof BENEFIT_ICON_KEYS)[number];
30
+
31
+ const PATHS: Record<BenefitIconKey, JSX.Element> = {
32
+ check: <path d="M5 12.5l4.5 4.5L19 7" />,
33
+ shipping: (
34
+ <>
35
+ <path d="M3 7h11v9H3zM14 10h4l3 3v3h-7z" />
36
+ <circle cx="7.5" cy="18" r="1.6" />
37
+ <circle cx="17.5" cy="18" r="1.6" />
38
+ </>
39
+ ),
40
+ leaf: (
41
+ <>
42
+ <path d="M20 4C10 4 4 10 4 18c8 0 14-6 14-14z" />
43
+ <path d="M6 18c4-4 8-6 12-10" />
44
+ </>
45
+ ),
46
+ shield: <path d="M12 3l7 3v6c0 5-3.5 7.5-7 9-3.5-1.5-7-4-7-9V6z" />,
47
+ star: <path d="M12 3.5l2.6 5.6 6.1.7-4.5 4.2 1.2 6-5.4-3-5.4 3 1.2-6-4.5-4.2 6.1-.7z" />,
48
+ wallet: (
49
+ <>
50
+ <rect x="3" y="6" width="18" height="13" rx="2" />
51
+ <path d="M3 10h18" />
52
+ <circle cx="16.5" cy="14" r="1.2" />
53
+ </>
54
+ ),
55
+ refresh: (
56
+ <>
57
+ <path d="M4 12a8 8 0 0 1 13.66-5.66L20 8" />
58
+ <path d="M20 4v4h-4" />
59
+ <path d="M20 12a8 8 0 0 1-13.66 5.66L4 16" />
60
+ <path d="M4 20v-4h4" />
61
+ </>
62
+ ),
63
+ heart: <path d="M12 20.5S3.5 15 3.5 9a4.5 4.5 0 0 1 8.5-2 4.5 4.5 0 0 1 8.5 2c0 6-8.5 11.5-8.5 11.5z" />,
64
+ gift: (
65
+ <>
66
+ <rect x="4" y="9" width="16" height="11" rx="1" />
67
+ <path d="M4 13h16M12 9v11" />
68
+ <path d="M12 9c-1.5-4-6-4-6-1.5S9 9 12 9zM12 9c1.5-4 6-4 6-1.5S15 9 12 9z" />
69
+ </>
70
+ ),
71
+ clock: (
72
+ <>
73
+ <circle cx="12" cy="12" r="9" />
74
+ <path d="M12 7v5l3.5 2" />
75
+ </>
76
+ ),
77
+ lock: (
78
+ <>
79
+ <rect x="5" y="11" width="14" height="9" rx="1.5" />
80
+ <path d="M8 11V7a4 4 0 0 1 8 0v4" />
81
+ </>
82
+ ),
83
+ sparkle: (
84
+ <>
85
+ <path d="M12 3l1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" />
86
+ </>
87
+ ),
88
+ };
89
+
90
+ // Synonyms qee has actually generated in practice for a key in BENEFIT_ICON_KEYS
91
+ // (confirmed live: "truck" for a shipping/delivery benefit) — the AI is given
92
+ // the canonical keyword list in the manifest/prompt, but a close synonym
93
+ // should still resolve to the right icon instead of silently falling back.
94
+ const ALIASES: Record<string, BenefitIconKey> = {
95
+ truck: 'shipping',
96
+ delivery: 'shipping',
97
+ return: 'refresh',
98
+ returns: 'refresh',
99
+ secure: 'shield',
100
+ security: 'shield',
101
+ guarantee: 'shield',
102
+ quality: 'star',
103
+ payment: 'wallet',
104
+ price: 'wallet',
105
+ // Carried over from glow's pre-shared TrustIcon switch — real demo data
106
+ // and, presumably, real qee-generated pages already use this word.
107
+ sustainability: 'leaf',
108
+ };
109
+
110
+ /** A vendor-uploaded icon takes over from the fixed keyword vocabulary — checked
111
+ * on the ORIGINAL casing (a URL path can be case-sensitive) before the
112
+ * keyword lookup below lowercases its own input. */
113
+ function isImageUrl(value: string): boolean {
114
+ return /^https?:\/\//i.test(value) || value.startsWith('/');
115
+ }
116
+
117
+ export function BenefitIcon({ icon, className }: { icon?: string; className?: string }): JSX.Element {
118
+ const trimmed = (icon ?? '').trim();
119
+
120
+ if (isImageUrl(trimmed)) {
121
+ // Fixed-size icon slot, not a content photo — the shared <Image>
122
+ // wrapper's placeholder/lazy-load machinery is built for larger,
123
+ // layout-affecting media.
124
+ // eslint-disable-next-line @next/next/no-img-element
125
+ return <img src={trimmed} alt="" className={className} loading="lazy" />;
126
+ }
127
+
128
+ const raw = trimmed.toLowerCase();
129
+ const key = ALIASES[raw] ?? raw;
130
+ const body = PATHS[key as BenefitIconKey] ?? PATHS.check;
131
+ return (
132
+ <svg
133
+ className={className}
134
+ viewBox="0 0 24 24"
135
+ fill="none"
136
+ stroke="currentColor"
137
+ strokeWidth={1.5}
138
+ strokeLinecap="round"
139
+ strokeLinejoin="round"
140
+ aria-hidden="true"
141
+ >
142
+ {body}
143
+ </svg>
144
+ );
145
+ }
@@ -0,0 +1,64 @@
1
+ import type { StorefrontConfig } from '../types/vendor';
2
+ import type { PlaceholderPalette } from './placeholder-resolver';
3
+
4
+ const FALLBACK: PlaceholderPalette = {
5
+ from: '#e8e4df',
6
+ to: '#c9c3ba',
7
+ text: '#1a1a1a',
8
+ };
9
+
10
+ /**
11
+ * Build a two-stop gradient palette from the current storefront config.
12
+ *
13
+ * Reads in this order of preference:
14
+ * config.colors.surface / config.colors.background / config.colors.text
15
+ * → config.brand.colors.surface / ... / .text
16
+ * → neutral fallback
17
+ */
18
+ export function resolveBrandPalette(config?: StorefrontConfig | null): PlaceholderPalette {
19
+ if (!config) return FALLBACK;
20
+
21
+ const override = config.colors ?? {};
22
+ const brand = config.brand?.colors;
23
+
24
+ const from = override.surface ?? brand?.surface ?? override.background ?? brand?.background ?? FALLBACK.from;
25
+ const to = override.background ?? brand?.background ?? override.surface ?? brand?.surface ?? FALLBACK.to;
26
+ const text = override.text ?? brand?.text ?? FALLBACK.text;
27
+
28
+ // If surface and background resolve to the same value, shift the gradient
29
+ // stops slightly so it doesn't look flat.
30
+ if (from === to) {
31
+ return { from, to: shade(to, -0.08), text };
32
+ }
33
+
34
+ return { from, to, text };
35
+ }
36
+
37
+ /**
38
+ * Apply a lightness delta to a hex color. Negative darkens, positive lightens.
39
+ * Returns a hex string. Internal helper.
40
+ */
41
+ function shade(hex: string, delta: number): string {
42
+ const cleaned = hex.replace('#', '');
43
+ if (cleaned.length !== 3 && cleaned.length !== 6) return hex;
44
+
45
+ const expanded =
46
+ cleaned.length === 3
47
+ ? cleaned
48
+ .split('')
49
+ .map((c) => c + c)
50
+ .join('')
51
+ : cleaned;
52
+
53
+ const r = parseInt(expanded.slice(0, 2), 16);
54
+ const g = parseInt(expanded.slice(2, 4), 16);
55
+ const b = parseInt(expanded.slice(4, 6), 16);
56
+
57
+ const adjust = (value: number): number => {
58
+ const next = Math.round(value + 255 * delta);
59
+ return Math.max(0, Math.min(255, next));
60
+ };
61
+
62
+ const hex2 = (value: number): string => value.toString(16).padStart(2, '0');
63
+ return `#${hex2(adjust(r))}${hex2(adjust(g))}${hex2(adjust(b))}`;
64
+ }