@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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@usequeek/theme-kit",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "description": "The framework a Queek storefront theme is built against.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/usequeek/queek-storefront.git",
10
+ "directory": "packages/theme-kit"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./index.ts",
17
+ "types": "./index.ts",
18
+ "sideEffects": [
19
+ "*.css"
20
+ ],
21
+ "files": [
22
+ "*.ts",
23
+ "*.tsx",
24
+ "**/*.ts",
25
+ "**/*.tsx",
26
+ "**/*.css",
27
+ "README.md"
28
+ ],
29
+ "peerDependencies": {
30
+ "next": ">=15",
31
+ "react": ">=19",
32
+ "react-dom": ">=19",
33
+ "@queekai/client-sdk": "*"
34
+ },
35
+ "dependencies": {
36
+ "zustand": "^5.0.11",
37
+ "react-markdown": "^10.0.0",
38
+ "remark-gfm": "^4.0.0",
39
+ "html-to-image": "^1.11.0"
40
+ }
41
+ }
@@ -0,0 +1,365 @@
1
+ 'use client';
2
+
3
+ import { Component, Suspense, use, useEffect, useMemo, type ComponentType, type ReactNode } from 'react';
4
+ import type { Block, BlockDataMap } from './types/block';
5
+ import type { Product } from './types/product';
6
+ import { useTheme } from './theme-context';
7
+ import { useStorefront } from './provider';
8
+ import { expandDesignTokensPartial } from './utils/brand';
9
+ import {
10
+ CoreButtonBlock,
11
+ CoreCalloutBlock,
12
+ CoreContentDefaultBlock,
13
+ CoreDividerBlock,
14
+ CoreEmbedBlock,
15
+ CoreQuoteBlock,
16
+ CoreFaqBlock,
17
+ CoreImageBlock,
18
+ CoreProductQaBlock,
19
+ CoreReviewsBlock,
20
+ CoreTableBlock,
21
+ CoreVideoBlock,
22
+ } from './shared-blocks';
23
+ import { EditableBlock, useEditMode } from './edit-mode';
24
+ import { useEditPreviewStore } from './stores/edit-preview-store';
25
+
26
+ /**
27
+ * Contains a single section while the merchant is editing.
28
+ *
29
+ * The server sends `_normalized_content` — the same sanitiser the write path
30
+ * uses — so a malformed section (the canonical case being a gallery with a null
31
+ * slide) never reaches a theme component in normal rendering. Sections streamed
32
+ * live from the editor skip that sanitiser by definition: they are mid-edit and
33
+ * have not been saved yet.
34
+ *
35
+ * So one half-typed section must not blank the whole page the vendor is working
36
+ * on. It fails in place, the rest of the page keeps rendering, and the next
37
+ * server payload heals it.
38
+ */
39
+ class DraftSectionBoundary extends Component<
40
+ { children: ReactNode },
41
+ { failed: boolean }
42
+ > {
43
+ state = { failed: false };
44
+
45
+ static getDerivedStateFromError(): { failed: boolean } {
46
+ return { failed: true };
47
+ }
48
+
49
+ componentDidCatch(error: Error): void {
50
+ console.warn('[edit] a draft section failed to render:', error.message);
51
+ }
52
+
53
+ render(): ReactNode {
54
+ if (!this.state.failed) return this.props.children;
55
+ return <div className="core-draft-section-error" role="status">This section can’t be previewed yet — keep editing.</div>;
56
+ }
57
+ }
58
+
59
+ export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
60
+ const draftBlocks = useEditPreviewStore((state) => state.blocks);
61
+ const reconcileDraftBlocks = useEditPreviewStore((state) => state.reconcile);
62
+
63
+ // A new server payload is authoritative — drop the streamed draft so the
64
+ // debounced save reconciles and a drifted preview cannot outlive a refresh.
65
+ const serverSignature = useMemo(() => JSON.stringify(blocks), [blocks]);
66
+ useEffect(() => {
67
+ reconcileDraftBlocks();
68
+ }, [serverSignature, reconcileDraftBlocks]);
69
+
70
+ const theme = useTheme();
71
+ const { config } = useStorefront();
72
+ const defaultVariants = config.default_variants ?? {};
73
+ const editMode = useEditMode();
74
+
75
+ const renderBlock = (block: Block, index: number): JSX.Element | null => {
76
+ const variant = block.variant ?? defaultVariants[block.type] ?? undefined;
77
+ const key = `${block.type}-${index}`;
78
+ const node = renderInner(block, variant, key);
79
+ if (node === null) return null;
80
+
81
+ // Section-scoped design-token override: only wraps in an extra div when
82
+ // the section actually sets `tokens` — no-override sections render
83
+ // byte-identical to before (no wrapper, no style attribute).
84
+ const tokenVars = block.tokens ? expandDesignTokensPartial(block.tokens) : null;
85
+ const hasTokenVars = !!tokenVars && Object.keys(tokenVars).length > 0;
86
+ // Generic per-section background override. Applied at this single shared
87
+ // wrapper (not per-theme-component) so every block type/theme honors it
88
+ // uniformly instead of each component having to opt in individually.
89
+ const rawBgColor = (block.data as Record<string, unknown> | undefined)?.bg_color;
90
+ const bgColor = typeof rawBgColor === 'string' && rawBgColor ? rawBgColor : undefined;
91
+ // bg_image takes over from bg_color when set (same mutual-exclusion the
92
+ // theme-level content variants already use for their own image/color
93
+ // pairs, e.g. brand-story's video-takes-over-image). Rendered as a real
94
+ // background-image (not an <img>) so it never disturbs the section's own
95
+ // document flow/height — the section sizes itself exactly as it would
96
+ // with a solid bg_color.
97
+ const rawBgImage = (block.data as Record<string, unknown> | undefined)?.bg_image;
98
+ const bgImage = typeof rawBgImage === 'string' && rawBgImage ? rawBgImage : undefined;
99
+ const rawBgOverlay = (block.data as Record<string, unknown> | undefined)?.bg_overlay;
100
+ const bgOverlay = (['none', 'light', 'medium', 'strong'] as const).includes(rawBgOverlay as never)
101
+ ? (rawBgOverlay as 'none' | 'light' | 'medium' | 'strong')
102
+ : 'medium';
103
+ // A flat scrim, not a directional gradient — unlike a theme's own hero
104
+ // (fixed copy position, so a gradient can be tuned to sit exactly under
105
+ // the text), this wrapper hosts arbitrary block content in an unknown
106
+ // position, so a uniform darken is the only overlay that's guaranteed to
107
+ // help regardless of where the section's own text ends up. Forcing
108
+ // `color: #fff` alongside it is deliberate, not decorative — the same
109
+ // "on_accent independently defaulting to something unrelated" trap that
110
+ // broke Taylor's checkout (2026-08-21, see brand.ts's
111
+ // ensureReadableOnAccent) applies here too: a theme's own default text
112
+ // color was never chosen with an arbitrary vendor photo behind it, so
113
+ // legibility can't be left to inherit from wherever the theme happens to
114
+ // default to. `!important`-equivalent isn't needed — this is the
115
+ // closest ancestor rule, and CSS3 UI text is dark by default in every
116
+ // theme (see brand.ts's own comment on directly-matched rules winning
117
+ // over inherited ones for exactly the fix this exists to preempt).
118
+ const OVERLAY_ALPHA: Record<'none' | 'light' | 'medium' | 'strong', number> = {
119
+ none: 0,
120
+ light: 0.25,
121
+ medium: 0.45,
122
+ strong: 0.65,
123
+ };
124
+
125
+ let content = node;
126
+ if (bgColor || bgImage) {
127
+ // Full-bleed band: `.content-stack`'s ancestor (`.ql-page-body`) is a
128
+ // centered, padded reading column (max-width + margin:auto), so a plain
129
+ // background-color here would only paint that narrow column, not a true
130
+ // edge-to-edge band. The standard breakout (100vw + negative margins)
131
+ // escapes that constraint; the block's own `.ql-container` (rendered by
132
+ // every theme's section components, itself self-centering/self-capped —
133
+ // app/storefront.css) keeps the actual content at the normal reading
134
+ // width, so only the color bleeds full-width, not the content.
135
+ content = (
136
+ <div
137
+ style={{
138
+ ...(hasTokenVars ? tokenVars : {}),
139
+ ...(bgImage
140
+ ? {
141
+ backgroundImage: `url(${bgImage})`,
142
+ backgroundSize: 'cover',
143
+ backgroundPosition: 'center',
144
+ color: '#fff',
145
+ }
146
+ : { backgroundColor: bgColor }),
147
+ position: 'relative',
148
+ width: '100vw',
149
+ marginLeft: 'calc(50% - 50vw)',
150
+ marginRight: 'calc(50% - 50vw)',
151
+ // `.content-stack` (globals.css) is `display:grid`, gapped by
152
+ // the `--content-stack-gap` custom property (1rem by default) —
153
+ // a sensible default for a plain content page's paragraphs/
154
+ // images, but for a colored band it leaves a seam of the page's
155
+ // own background between adjacent sections, breaking the "solid
156
+ // band" look (confirmed live: 16px, exactly the default gap).
157
+ // Pull this section half a gap into the space above/below it;
158
+ // when the neighbor is ALSO a bg_color band doing the same, the
159
+ // two halves meet and the seam closes to zero. Derives from the
160
+ // SAME variable `.content-stack` sets — never a hardcoded
161
+ // number — so a theme override of the gap (e.g. nova zeroes it
162
+ // entirely) can't silently desync this from the real value and
163
+ // cause an overlap instead of a clean seam-close. Plain
164
+ // (non-colored) blocks are untouched either way.
165
+ marginBlock: 'calc(var(--content-stack-gap, 1rem) / -2)',
166
+ }}
167
+ >
168
+ {bgImage && OVERLAY_ALPHA[bgOverlay] > 0 ? (
169
+ <div
170
+ aria-hidden
171
+ style={{
172
+ position: 'absolute',
173
+ inset: 0,
174
+ background: `rgba(0, 0, 0, ${OVERLAY_ALPHA[bgOverlay]})`,
175
+ pointerEvents: 'none',
176
+ }}
177
+ />
178
+ ) : null}
179
+ <div style={bgImage ? { position: 'relative', zIndex: 1 } : undefined}>{node}</div>
180
+ </div>
181
+ );
182
+ } else if (hasTokenVars) {
183
+ content = <div style={tokenVars}>{node}</div>;
184
+ }
185
+
186
+ const wrapped = (
187
+ <EditableBlock key={key} index={index} type={block.type} variant={variant} enabled={editMode}>
188
+ {content}
189
+ </EditableBlock>
190
+ );
191
+
192
+ // `display: contents` (app/storefront.css) — draws no box of its own, so
193
+ // it never disturbs the section's own layout. Only wraps when the section
194
+ // actually has an anchor_id (menu-linked landing-page sections); every
195
+ // other section renders byte-identical to before.
196
+ if (!block.anchor_id) return wrapped;
197
+ return (
198
+ <div key={`anchor-${key}`} id={block.anchor_id} className="qk-anchor">
199
+ {wrapped}
200
+ </div>
201
+ );
202
+ };
203
+
204
+ const rendered = editMode && draftBlocks ? draftBlocks : blocks;
205
+
206
+ const usingDraft = editMode && !!draftBlocks;
207
+
208
+ return (
209
+ <div className="content-stack">
210
+ {rendered.map((block, index) =>
211
+ usingDraft
212
+ ? <DraftSectionBoundary key={`draft-${index}`}>{renderBlock(block, index)}</DraftSectionBoundary>
213
+ : renderBlock(block, index),
214
+ )}
215
+ </div>
216
+ );
217
+
218
+ function renderInner(block: Block, variant: string | undefined, key: string): JSX.Element | null {
219
+ switch (block.type) {
220
+ case 'content': {
221
+ // Ensure `markdown` is always a string — structured variants (promo,
222
+ // brand-story, testimonials) may omit it, but theme components
223
+ // destructure it from ContentBlockData and call .split()/.trim().
224
+ // Spreading with a default prevents undefined crashes across all themes.
225
+ const data = { markdown: '', ...(block.data as Record<string, unknown>) } as BlockDataMap['content'] & Record<string, unknown>;
226
+ if (!variant || variant === 'default') {
227
+ return <CoreContentDefaultBlock key={key} {...data} />;
228
+ }
229
+ const Component = theme.getBlock('content', variant);
230
+ return <Component key={key} {...data} />;
231
+ }
232
+ case 'image': {
233
+ // Framework-owned.
234
+ const data = block.data as BlockDataMap['image'];
235
+ return <CoreImageBlock key={key} {...data} />;
236
+ }
237
+ case 'gallery': {
238
+ const Component = theme.getBlock('gallery', variant);
239
+ const data = block.data as BlockDataMap['gallery'];
240
+ return <Component key={key} {...data} />;
241
+ }
242
+ case 'video': {
243
+ // Framework-owned.
244
+ const data = block.data as BlockDataMap['video'];
245
+ return <CoreVideoBlock key={key} {...data} />;
246
+ }
247
+ case 'table': {
248
+ // Framework-owned.
249
+ const data = block.data as BlockDataMap['table'];
250
+ return <CoreTableBlock key={key} {...data} />;
251
+ }
252
+ case 'button': {
253
+ // Framework-owned.
254
+ const data = block.data as BlockDataMap['button'];
255
+ return <CoreButtonBlock key={key} {...data} />;
256
+ }
257
+ case 'embed': {
258
+ // Framework-owned.
259
+ const data = block.data as BlockDataMap['embed'];
260
+ return <CoreEmbedBlock key={key} {...data} />;
261
+ }
262
+ case 'divider': {
263
+ // Framework-owned.
264
+ const data = block.data as BlockDataMap['divider'];
265
+ return <CoreDividerBlock key={key} {...data} />;
266
+ }
267
+ case 'callout': {
268
+ // Framework-owned.
269
+ const data = { markdown: '', ...(block.data as Record<string, unknown>) } as BlockDataMap['callout'];
270
+ return <CoreCalloutBlock key={key} {...data} />;
271
+ }
272
+ case 'quote': {
273
+ // Framework-owned.
274
+ const data = block.data as BlockDataMap['quote'];
275
+ return <CoreQuoteBlock key={key} {...data} />;
276
+ }
277
+ case 'products': {
278
+ const Component = theme.getBlock('products', variant);
279
+ const data = block.data as BlockDataMap['products'];
280
+
281
+ if (!block.productsPromise) {
282
+ // No server-seeded promise (a route that hasn't called
283
+ // `hydrateProductBlocks` yet) — theme falls back to its own
284
+ // `useProducts()` client fetch, unchanged from before.
285
+ return <Component key={key} {...data} />;
286
+ }
287
+
288
+ return (
289
+ <Suspense key={key} fallback={<ProductsBlockFallback title={data.title} limit={data.limit} />}>
290
+ <ProductsBlockResolved Component={Component} data={data} promise={block.productsPromise} />
291
+ </Suspense>
292
+ );
293
+ }
294
+ case 'categories': {
295
+ const Component = theme.getBlock('categories', variant);
296
+ const data = block.data as BlockDataMap['categories'];
297
+ return <Component key={key} {...data} />;
298
+ }
299
+ case 'contact': {
300
+ const Component = theme.getBlock('contact', variant);
301
+ const data = block.data as BlockDataMap['contact'];
302
+ return <Component key={key} {...data} />;
303
+ }
304
+ case 'blog': {
305
+ const Component = theme.getBlock('blog', variant);
306
+ const data = block.data as BlockDataMap['blog'];
307
+ return <Component key={key} {...data} />;
308
+ }
309
+ case 'reviews': {
310
+ // Framework-owned.
311
+ const data = block.data as BlockDataMap['reviews'];
312
+ const dataWithLayout = { ...data, layout: data.layout ?? (variant as BlockDataMap['reviews']['layout']) ?? 'grid' };
313
+ return <CoreReviewsBlock key={key} {...dataWithLayout} />;
314
+ }
315
+ case 'faq': {
316
+ // Framework-owned.
317
+ const data = block.data as BlockDataMap['faq'];
318
+ return <CoreFaqBlock key={key} {...data} />;
319
+ }
320
+ case 'product_qa': {
321
+ // Framework-owned.
322
+ const data = block.data as BlockDataMap['product_qa'];
323
+ return <CoreProductQaBlock key={key} {...data} />;
324
+ }
325
+ default:
326
+ return null;
327
+ }
328
+ }
329
+ }
330
+
331
+ /** Unwraps a server-seeded product promise and hands the result straight to
332
+ * the theme's `products` component as its `products` prop — the same prop
333
+ * every theme block already treats as "I have data, skip my own fetch". */
334
+ function ProductsBlockResolved({
335
+ Component,
336
+ data,
337
+ promise,
338
+ }: {
339
+ Component: ComponentType<BlockDataMap['products'] & { products?: Product[] }>;
340
+ data: BlockDataMap['products'];
341
+ promise: Promise<Product[]>;
342
+ }): JSX.Element {
343
+ const products = use(promise);
344
+ return <Component {...data} products={products} />;
345
+ }
346
+
347
+ /**
348
+ * Shown only while a `products` block's server-side fetch is still pending —
349
+ * in practice, only on a cache-cold request (a vendor's first hit after the
350
+ * 60s TTL). A warm cache resolves before this ever paints. Deliberately
351
+ * theme-neutral (not a per-theme skeleton) since it is the rare-path
352
+ * placeholder for a boundary that sits above every theme's own component.
353
+ */
354
+ function ProductsBlockFallback({ title, limit }: { title?: string | null; limit?: number }): JSX.Element {
355
+ return (
356
+ <section aria-hidden="true">
357
+ {title ? <h2>{title}</h2> : null}
358
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '1rem' }}>
359
+ {Array.from({ length: limit ?? 4 }).map((_, i) => (
360
+ <div key={i} style={{ aspectRatio: '3 / 4', borderRadius: 8, background: 'rgba(127,127,127,0.15)' }} />
361
+ ))}
362
+ </div>
363
+ </section>
364
+ );
365
+ }
package/provider.tsx ADDED
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useMemo, useRef, type ReactNode } from 'react';
4
+ import type { Menu } from './types/menu';
5
+ import type { ReviewItem } from './types/block';
6
+ import type { Post } from './types/page';
7
+ import type { Product } from './types/product';
8
+ import type { StorefrontConfig, VendorProfile } from './types/vendor';
9
+ import { getBrandCssVariables } from './utils/brand';
10
+ import { DesignTokenPreviewListener } from './design-token-preview';
11
+
12
+ export interface StorefrontPreviewData {
13
+ products?: Product[];
14
+ /** Vendor-scope reviews for preview surfaces. `useReviews` reads these the
15
+ * same way `useProducts` reads `products` — without them a review-backed
16
+ * section renders nothing in the preview, style guide and section library,
17
+ * which is what a vendor browses before choosing it. */
18
+ reviews?: ReviewItem[];
19
+ categories?: Array<{
20
+ id: string;
21
+ slug: string;
22
+ name: string;
23
+ image?: string | null;
24
+ products_count?: number;
25
+ }>;
26
+ posts?: Post[];
27
+ }
28
+
29
+ interface StorefrontContextValue {
30
+ vendor: VendorProfile;
31
+ config: StorefrontConfig;
32
+ menus: Menu[];
33
+ previewData?: StorefrontPreviewData;
34
+ basePath: string;
35
+ /** slug => 'full' | 'bare', for ShellInner to decide the CURRENT page's
36
+ * header/footer without a per-navigation fetch. See VendorShell. */
37
+ pagesChrome: Record<string, 'full' | 'bare'>;
38
+ }
39
+
40
+ export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
41
+
42
+ export function StorefrontProvider({
43
+ vendor,
44
+ config,
45
+ menus,
46
+ previewData,
47
+ basePath,
48
+ pagesChrome,
49
+ children,
50
+ }: {
51
+ vendor: VendorProfile;
52
+ config: StorefrontConfig;
53
+ menus: Menu[];
54
+ previewData?: StorefrontPreviewData;
55
+ basePath?: string;
56
+ pagesChrome?: Record<string, 'full' | 'bare'>;
57
+ children: ReactNode;
58
+ }): JSX.Element {
59
+ const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
60
+ const value = useMemo(
61
+ () => ({
62
+ vendor,
63
+ config,
64
+ menus,
65
+ previewData,
66
+ basePath: resolvedBasePath,
67
+ pagesChrome: pagesChrome ?? {},
68
+ }),
69
+ [config, menus, previewData, vendor, resolvedBasePath, pagesChrome],
70
+ );
71
+ const brandRootRef = useRef<HTMLDivElement>(null);
72
+
73
+ return (
74
+ <StorefrontContext.Provider value={value}>
75
+ <div ref={brandRootRef} data-brand-root style={getBrandCssVariables(config.brand, config)}>
76
+ <DesignTokenPreviewListener targetRef={brandRootRef} />
77
+ {children}
78
+ </div>
79
+ </StorefrontContext.Provider>
80
+ );
81
+ }
82
+
83
+ export function useStorefront(): StorefrontContextValue {
84
+ const context = useContext(StorefrontContext);
85
+
86
+ if (!context) {
87
+ throw new Error('useStorefront must be used within StorefrontProvider');
88
+ }
89
+
90
+ return context;
91
+ }