@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/types/vendor.ts
ADDED
|
@@ -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
|
+
}
|