@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/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
|
+
}
|