@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useContext, useState, type ImgHTMLAttributes } from 'react';
|
|
4
|
+
import { StorefrontContext } from '../provider';
|
|
5
|
+
import type { ImageVariants } from '../types/media';
|
|
6
|
+
import { resolveBrandPalette } from '../utils/brand-palette';
|
|
7
|
+
import { resolveImageSources, type ImageIntent } from '../utils/image-srcset';
|
|
8
|
+
import { resolvePlaceholder, type PlaceholderVariant } from '../utils/placeholder-resolver';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generic fallback used when the caller does not provide a `placeholder` prop.
|
|
12
|
+
* Kept for backward compatibility with existing <Image> call sites that have not
|
|
13
|
+
* yet been wired up to the Smart Placeholder system.
|
|
14
|
+
*/
|
|
15
|
+
const PLACEHOLDER_SVG = `data:image/svg+xml,${encodeURIComponent(
|
|
16
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
|
|
17
|
+
<rect width="400" height="400" fill="#f0f0f0"/>
|
|
18
|
+
<g transform="translate(150,150)" opacity="0.3">
|
|
19
|
+
<rect x="0" y="10" width="100" height="80" rx="8" fill="none" stroke="#999" stroke-width="4"/>
|
|
20
|
+
<circle cx="30" cy="35" r="10" fill="#999"/>
|
|
21
|
+
<polyline points="10,75 40,50 60,65 90,40" fill="none" stroke="#999" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
22
|
+
</g>
|
|
23
|
+
</svg>`,
|
|
24
|
+
)}`;
|
|
25
|
+
|
|
26
|
+
export interface PlaceholderProp {
|
|
27
|
+
label?: string;
|
|
28
|
+
sublabel?: string;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
variant?: PlaceholderVariant;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
|
35
|
+
fallback?: string;
|
|
36
|
+
placeholder?: PlaceholderProp;
|
|
37
|
+
/**
|
|
38
|
+
* The backend's srcset-ready tier map (product.media.image_variants,
|
|
39
|
+
* vendor.logo_variants, collection.image_variants, mediaIndex[url], …).
|
|
40
|
+
* Purely additive: when absent — as it is at every call site that hasn't been
|
|
41
|
+
* migrated, and whenever the backend legitimately sends null (off-host image,
|
|
42
|
+
* SVG, legacy upload) — this component behaves exactly as it did before.
|
|
43
|
+
*/
|
|
44
|
+
variants?: ImageVariants | null;
|
|
45
|
+
/**
|
|
46
|
+
* What surface this image renders on. Drives which tier is preferred and what
|
|
47
|
+
* `sizes` is emitted. Only meaningful alongside `variants`. Defaults to 'raw'
|
|
48
|
+
* ("best available single url, no responsive selection") so passing `variants`
|
|
49
|
+
* without an `intent` can never accidentally downgrade a hero.
|
|
50
|
+
*/
|
|
51
|
+
intent?: ImageIntent;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where the <img> currently is in the fallback chain:
|
|
56
|
+
* 'primary' — the resolved/passed src
|
|
57
|
+
* 'original' — retrying variants.original.url after the primary 404'd
|
|
58
|
+
* 'failed' — give up, render `fallback` or the Smart Placeholder
|
|
59
|
+
*/
|
|
60
|
+
type LoadStage = 'primary' | 'original' | 'failed';
|
|
61
|
+
|
|
62
|
+
export function Image({ fallback, placeholder, variants, intent = 'raw', onError, ...props }: ImageProps) {
|
|
63
|
+
const [stage, setStage] = useState<LoadStage>('primary');
|
|
64
|
+
// Read context directly — returns null outside a StorefrontProvider
|
|
65
|
+
// (test environments, isolated captures) without throwing.
|
|
66
|
+
const storefront = useContext(StorefrontContext);
|
|
67
|
+
|
|
68
|
+
const resolved = resolveImageSources(variants, intent);
|
|
69
|
+
|
|
70
|
+
// The resolved tier WINS over an explicitly-passed `src`. That's the whole
|
|
71
|
+
// point of threading `variants` down: the collapsed `src` field is the thing
|
|
72
|
+
// being downgraded away from. It's safe because resolveImageSources() enforces
|
|
73
|
+
// the intent ceiling — a capped intent can never resolve to `original`, so the
|
|
74
|
+
// result is always <= what `src` already implied. Call sites keep passing
|
|
75
|
+
// `src` unchanged; it stays the fallback for null/partial maps.
|
|
76
|
+
const primarySrc = resolved.src ?? (typeof props.src === 'string' ? props.src : undefined) ?? props.src;
|
|
77
|
+
|
|
78
|
+
// Reset the fallback chain when the underlying image changes (a reused card in
|
|
79
|
+
// a virtualized rail, a variant swap on the PDP) — without this, a component
|
|
80
|
+
// that already fell back to the placeholder for image A stays stuck on the
|
|
81
|
+
// placeholder forever even after being reused for a working image B. Render-
|
|
82
|
+
// phase derived-state, per React's "adjusting state when props change" — no
|
|
83
|
+
// extra commit, no effect.
|
|
84
|
+
const [lastKey, setLastKey] = useState<typeof primarySrc>(primarySrc);
|
|
85
|
+
if (primarySrc !== lastKey) {
|
|
86
|
+
setLastKey(primarySrc);
|
|
87
|
+
if (stage !== 'primary') setStage('primary');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const originalUrl = variants?.original?.url;
|
|
91
|
+
// Only worth a retry if it's a genuinely different url from the one that failed.
|
|
92
|
+
const canRetryOriginal = typeof originalUrl === 'string' && originalUrl !== '' && originalUrl !== primarySrc;
|
|
93
|
+
|
|
94
|
+
const hasSrc = !!primarySrc;
|
|
95
|
+
const needsPlaceholder = !hasSrc || stage === 'failed';
|
|
96
|
+
|
|
97
|
+
let placeholderSrc = fallback ?? PLACEHOLDER_SVG;
|
|
98
|
+
|
|
99
|
+
if (needsPlaceholder && placeholder) {
|
|
100
|
+
const palette = resolveBrandPalette(storefront?.config ?? null);
|
|
101
|
+
const font = storefront?.config?.brand?.font?.body ?? storefront?.config?.brand?.font?.heading;
|
|
102
|
+
placeholderSrc = resolvePlaceholder({
|
|
103
|
+
width: placeholder.width,
|
|
104
|
+
height: placeholder.height,
|
|
105
|
+
label: placeholder.label,
|
|
106
|
+
sublabel: placeholder.sublabel,
|
|
107
|
+
variant: placeholder.variant,
|
|
108
|
+
palette,
|
|
109
|
+
font,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let effectiveSrc = primarySrc;
|
|
114
|
+
// CRITICAL: srcSet/sizes must be dropped on every non-primary stage. A srcSet
|
|
115
|
+
// OUTRANKS src in the browser's selection algorithm, so leaving it in place
|
|
116
|
+
// while swapping src would make the browser re-pick the very candidate that
|
|
117
|
+
// just failed — the fallback would never render and onError would loop.
|
|
118
|
+
let effectiveSrcSet = resolved.srcSet;
|
|
119
|
+
let effectiveSizes = resolved.sizes;
|
|
120
|
+
|
|
121
|
+
if (stage === 'original') {
|
|
122
|
+
effectiveSrc = originalUrl;
|
|
123
|
+
effectiveSrcSet = undefined;
|
|
124
|
+
effectiveSizes = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (needsPlaceholder) {
|
|
127
|
+
effectiveSrc = placeholderSrc;
|
|
128
|
+
effectiveSrcSet = undefined;
|
|
129
|
+
effectiveSizes = undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<img
|
|
134
|
+
{...props}
|
|
135
|
+
src={effectiveSrc}
|
|
136
|
+
srcSet={effectiveSrcSet}
|
|
137
|
+
sizes={effectiveSizes}
|
|
138
|
+
onError={(e) => {
|
|
139
|
+
// 2-step chain: preferred tier → variants.original → placeholder.
|
|
140
|
+
if (stage === 'primary') {
|
|
141
|
+
setStage(canRetryOriginal ? 'original' : 'failed');
|
|
142
|
+
} else if (stage === 'original') {
|
|
143
|
+
setStage('failed');
|
|
144
|
+
}
|
|
145
|
+
onError?.(e);
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef } from 'react';
|
|
4
|
+
import ReactMarkdown from 'react-markdown';
|
|
5
|
+
import remarkGfm from 'remark-gfm';
|
|
6
|
+
import { Image } from './image';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rich long-form markdown renderer for article bodies and callouts. Uses
|
|
10
|
+
* `react-markdown` + `remark-gfm` so headings, nested lists, code fences,
|
|
11
|
+
* tables, blockquotes, task lists, and inline formatting all render properly.
|
|
12
|
+
*
|
|
13
|
+
* Raw HTML is NOT enabled (react-markdown's default) — content is sanitized.
|
|
14
|
+
*
|
|
15
|
+
* Inline markdown images are routed through the core <Image> component so the
|
|
16
|
+
* Smart Placeholder / fallback system applies (never a bare, unoptimized
|
|
17
|
+
* <img>). Short-form snippets (footers, product blurbs) keep using the
|
|
18
|
+
* lightweight `renderMarkdown` helper in `lib/core/utils/markdown`.
|
|
19
|
+
*/
|
|
20
|
+
function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<'img'>): JSX.Element | null {
|
|
21
|
+
if (!src || typeof src !== 'string') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Image
|
|
27
|
+
src={src}
|
|
28
|
+
alt={alt ?? ''}
|
|
29
|
+
className="core-markdown__img"
|
|
30
|
+
loading="lazy"
|
|
31
|
+
placeholder={{ label: alt || undefined, width: 1200, height: 800, variant: 'photo' }}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function MarkdownLink({ href, children, ...rest }: ComponentPropsWithoutRef<'a'>): JSX.Element {
|
|
37
|
+
const isExternal = typeof href === 'string' && /^https?:\/\//.test(href);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<a href={href} {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})} {...rest}>
|
|
41
|
+
{children}
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Markdown({ children, className }: { children: string; className?: string }): JSX.Element {
|
|
47
|
+
return (
|
|
48
|
+
<div className={className}>
|
|
49
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ img: MarkdownImage, a: MarkdownLink }}>
|
|
50
|
+
{children ?? ''}
|
|
51
|
+
</ReactMarkdown>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, type PlaceholderProp } from './image';
|
|
4
|
+
import type { ImageIntent } from '../utils/image-srcset';
|
|
5
|
+
import type { MediaFrame } from '../utils/product-media';
|
|
6
|
+
|
|
7
|
+
interface ProductMediaFrameProps {
|
|
8
|
+
frame: MediaFrame;
|
|
9
|
+
className?: string;
|
|
10
|
+
placeholder?: PlaceholderProp;
|
|
11
|
+
/** 'main' (the selected/current frame) plays with controls. 'thumbnail'
|
|
12
|
+
* renders muted with no controls — `preload="metadata"` makes the browser
|
|
13
|
+
* show the video's first frame as a static image without playing it, the
|
|
14
|
+
* same trick a poster image would achieve, with no separate poster asset. */
|
|
15
|
+
variant?: 'main' | 'thumbnail';
|
|
16
|
+
/** Override the intent derived from `variant` (e.g. a theme rendering the
|
|
17
|
+
* main frame inside a narrow split-pane rather than a full-width hero). */
|
|
18
|
+
intent?: ImageIntent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ProductMediaFrame({
|
|
22
|
+
frame,
|
|
23
|
+
className,
|
|
24
|
+
placeholder,
|
|
25
|
+
variant = 'main',
|
|
26
|
+
intent,
|
|
27
|
+
}: ProductMediaFrameProps): JSX.Element {
|
|
28
|
+
if (frame.type === 'video') {
|
|
29
|
+
return (
|
|
30
|
+
<video
|
|
31
|
+
className={className}
|
|
32
|
+
src={frame.url}
|
|
33
|
+
aria-label={frame.alt}
|
|
34
|
+
controls={variant === 'main'}
|
|
35
|
+
muted={variant === 'thumbnail'}
|
|
36
|
+
playsInline
|
|
37
|
+
preload="metadata"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Thumbnail strips render at ~64-90px across every theme — the `thumb` tier
|
|
43
|
+
// (a fixed-src, never-in-a-srcset tier by contract) is exactly right there.
|
|
44
|
+
const resolvedIntent: ImageIntent = intent ?? (variant === 'thumbnail' ? 'avatar' : 'hero');
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Image
|
|
48
|
+
className={className}
|
|
49
|
+
src={frame.url}
|
|
50
|
+
alt={frame.alt}
|
|
51
|
+
placeholder={placeholder}
|
|
52
|
+
variants={frame.variants}
|
|
53
|
+
intent={resolvedIntent}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
VariantPicker — shared option-selection UI (QR ordering sheet,
|
|
3
|
+
default theme's quick-view modal). Prefix: qk-variant-.
|
|
4
|
+
Reads --brand-* directly (not a context-scoped token set like
|
|
5
|
+
--qr- or --qka-) so it renders correctly regardless of which
|
|
6
|
+
surface mounts it. Mirrors quick-add-page.css's qka-option/qka-swatch
|
|
7
|
+
rules — same visual language, kept as a separate small file since
|
|
8
|
+
this component is reused outside that page's scope.
|
|
9
|
+
============================================================ */
|
|
10
|
+
|
|
11
|
+
.qk-variant-option {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: 8px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.qk-variant-option + .qk-variant-option {
|
|
18
|
+
margin-top: 12px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.qk-variant-option__label {
|
|
22
|
+
font-size: 0.8rem;
|
|
23
|
+
font-weight: 600;
|
|
24
|
+
color: var(--brand-text-muted, #6b7280);
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: 0.03em;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 6px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.qk-variant-option__selected {
|
|
33
|
+
font-weight: 400;
|
|
34
|
+
text-transform: none;
|
|
35
|
+
letter-spacing: normal;
|
|
36
|
+
color: var(--brand-text, #1a1a1a);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.qk-variant-option__values {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-wrap: wrap;
|
|
42
|
+
gap: 8px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.qk-variant-swatch {
|
|
46
|
+
width: 32px;
|
|
47
|
+
height: 32px;
|
|
48
|
+
border-radius: 50%;
|
|
49
|
+
border: 2px solid color-mix(in srgb, var(--brand-text, #1a1a1a) 12%, transparent);
|
|
50
|
+
padding: 0;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
overflow: hidden;
|
|
53
|
+
background-size: cover;
|
|
54
|
+
background-position: center;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.qk-variant-swatch.is-selected {
|
|
58
|
+
border-color: var(--brand-primary, #229879);
|
|
59
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand-primary, #229879) 30%, transparent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.qk-variant-swatch--image img {
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: 100%;
|
|
65
|
+
object-fit: cover;
|
|
66
|
+
display: block;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.qk-variant-option__btn {
|
|
70
|
+
padding: 8px 14px;
|
|
71
|
+
border-radius: 999px;
|
|
72
|
+
border: 1px solid color-mix(in srgb, var(--brand-text, #1a1a1a) 12%, transparent);
|
|
73
|
+
background: var(--brand-surface, #ffffff);
|
|
74
|
+
color: var(--brand-text, #1a1a1a);
|
|
75
|
+
font-size: 0.85rem;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.qk-variant-option__btn.is-selected {
|
|
81
|
+
border-color: var(--brand-primary, #229879);
|
|
82
|
+
background: color-mix(in srgb, var(--brand-primary, #229879) 12%, var(--brand-surface, #ffffff));
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product, ProductVariant } from '../types/product';
|
|
4
|
+
import './variant-picker.css';
|
|
5
|
+
|
|
6
|
+
interface VariantPickerProps {
|
|
7
|
+
product: Product;
|
|
8
|
+
selectedOptions: Record<string, string>;
|
|
9
|
+
onSelect: (optionName: string, value: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders a product's option groups as color swatch / image swatch / text
|
|
15
|
+
* buttons — standard e-commerce priority: color_code first, else a
|
|
16
|
+
* representative image, else plain text. Extracted from quick-add-page.tsx
|
|
17
|
+
* (the channel/chat add-to-cart flow, the one place that already got this
|
|
18
|
+
* right) so the other two surfaces missing a picker entirely — the QR
|
|
19
|
+
* ordering sheet and the default theme's quick-view modal — can reuse it
|
|
20
|
+
* instead of a third reimplementation. Pair with `matchVariant()`
|
|
21
|
+
* (lib/core/utils/match-variant.ts) to resolve `selectedOptions` into a real
|
|
22
|
+
* `ProductVariant`, and gate the add action on that being non-null before
|
|
23
|
+
* calling `addProduct`/`addItemWithAddons` — a variant-enabled product added
|
|
24
|
+
* without one fails the cart save server-side (DiscountService's
|
|
25
|
+
* VariantSelectionException) rather than merely being "no variant"; there
|
|
26
|
+
* is no valid no-selection state to fall back to.
|
|
27
|
+
*/
|
|
28
|
+
export function VariantPicker({ product, selectedOptions, onSelect, className }: VariantPickerProps): JSX.Element | null {
|
|
29
|
+
if (product.options.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={className}>
|
|
33
|
+
{product.options.map((option) => {
|
|
34
|
+
const isColor = option.type === 'color' || option.values.some((v) => v.color_code);
|
|
35
|
+
const selectedValue = selectedOptions[option.name];
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="qk-variant-option" key={option.name}>
|
|
39
|
+
<span className="qk-variant-option__label">
|
|
40
|
+
{option.name}
|
|
41
|
+
{selectedValue ? (
|
|
42
|
+
<span className="qk-variant-option__selected">
|
|
43
|
+
{option.values.find((v) => v.value === selectedValue)?.label ?? selectedValue}
|
|
44
|
+
</span>
|
|
45
|
+
) : null}
|
|
46
|
+
</span>
|
|
47
|
+
<div className="qk-variant-option__values">
|
|
48
|
+
{option.values.map((val) => {
|
|
49
|
+
const isSelected = selectedValue === val.value;
|
|
50
|
+
const handleSelect = (): void => onSelect(option.name, val.value);
|
|
51
|
+
|
|
52
|
+
if (isColor && val.color_code) {
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
key={val.value}
|
|
56
|
+
type="button"
|
|
57
|
+
className={`qk-variant-swatch${isSelected ? ' is-selected' : ''}`}
|
|
58
|
+
style={{ background: val.color_code }}
|
|
59
|
+
title={val.label}
|
|
60
|
+
aria-label={val.label}
|
|
61
|
+
onClick={handleSelect}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (val.image) {
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
key={val.value}
|
|
70
|
+
type="button"
|
|
71
|
+
className={`qk-variant-swatch qk-variant-swatch--image${isSelected ? ' is-selected' : ''}`}
|
|
72
|
+
title={val.label}
|
|
73
|
+
aria-label={val.label}
|
|
74
|
+
onClick={handleSelect}
|
|
75
|
+
>
|
|
76
|
+
<img src={val.image} alt={val.label} />
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
key={val.value}
|
|
84
|
+
type="button"
|
|
85
|
+
className={`qk-variant-option__btn${isSelected ? ' is-selected' : ''}`}
|
|
86
|
+
onClick={handleSelect}
|
|
87
|
+
>
|
|
88
|
+
{val.label}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True once every one of the product's option groups has a selected value —
|
|
101
|
+
* the gate to check alongside `matchVariant(...) !== null` before allowing
|
|
102
|
+
* an add. Centralized so both call sites stay in sync on the definition. */
|
|
103
|
+
export function allVariantOptionsSelected(product: Product, selectedOptions: Record<string, string>): boolean {
|
|
104
|
+
return product.options.every((option) => Boolean(selectedOptions[option.name]));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type { ProductVariant };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, type RefObject } from 'react';
|
|
4
|
+
import { expandDesignTokens } from './utils/brand';
|
|
5
|
+
import type { DesignTokens } from './types/vendor';
|
|
6
|
+
|
|
7
|
+
interface DesignTokenPreviewMessage {
|
|
8
|
+
__queekEditor: true;
|
|
9
|
+
kind: 'design-tokens';
|
|
10
|
+
tokens?: DesignTokens | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StylableTarget {
|
|
14
|
+
style: {
|
|
15
|
+
setProperty(name: string, value: string): void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* SECURITY: only the exact editor shape is accepted — everything else
|
|
21
|
+
* (including other `postMessage` traffic already on this window, e.g. the
|
|
22
|
+
* `edit-mode-active.tsx` block-select/refresh bridge) is ignored.
|
|
23
|
+
*/
|
|
24
|
+
export function isDesignTokenPreviewMessage(data: unknown): data is DesignTokenPreviewMessage {
|
|
25
|
+
if (!data || typeof data !== 'object') return false;
|
|
26
|
+
const candidate = data as Record<string, unknown>;
|
|
27
|
+
return candidate.__queekEditor === true && candidate.kind === 'design-tokens';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wires a `message` listener that overlays live `--var` writes onto
|
|
32
|
+
* `target.style` whenever a matching editor message arrives. Pure DOM
|
|
33
|
+
* wiring (no React) so it is directly unit-testable without a browser
|
|
34
|
+
* environment. Returns the disposer.
|
|
35
|
+
*/
|
|
36
|
+
export function attachDesignTokenPreviewListener(target: StylableTarget): () => void {
|
|
37
|
+
const onMessage = (event: MessageEvent): void => {
|
|
38
|
+
if (!isDesignTokenPreviewMessage(event.data)) return;
|
|
39
|
+
|
|
40
|
+
const vars = expandDesignTokens(event.data.tokens);
|
|
41
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
42
|
+
target.style.setProperty(key, value);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
window.addEventListener('message', onMessage);
|
|
47
|
+
return () => window.removeEventListener('message', onMessage);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ephemeral live-preview overlay for the merchant editor. Active ONLY when
|
|
52
|
+
* embedded: the web dashboard's real preview iframe (`window.parent !==
|
|
53
|
+
* window`), or the mobile app's top-level WebView, which opts in explicitly
|
|
54
|
+
* via `?host=native` since it has no real parent frame to detect — real
|
|
55
|
+
* public storefront visits set neither, so the listener never installs
|
|
56
|
+
* (zero overhead). On a matching `{__queekEditor:true, kind:'design-tokens',
|
|
57
|
+
* tokens}` message, expands the tokens and writes every `--var` straight
|
|
58
|
+
* onto the brand-root element's inline style, overlaying (not replacing)
|
|
59
|
+
* the SSR vars from `getBrandCssVariables`. Never persisted — a normal
|
|
60
|
+
* navigation/refresh reloads the real config.
|
|
61
|
+
*
|
|
62
|
+
* Frozen protocol: queek_backend `.agent/.tmp/live-preview-protocol.md`.
|
|
63
|
+
*/
|
|
64
|
+
export function DesignTokenPreviewListener({
|
|
65
|
+
targetRef,
|
|
66
|
+
}: {
|
|
67
|
+
targetRef: RefObject<HTMLElement | null>;
|
|
68
|
+
}): null {
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (typeof window === 'undefined') return undefined;
|
|
71
|
+
const isEmbedded = window.parent !== window || new URLSearchParams(location.search).get('host') === 'native';
|
|
72
|
+
if (!isEmbedded) return undefined;
|
|
73
|
+
|
|
74
|
+
const target = targetRef.current;
|
|
75
|
+
if (!target) return undefined;
|
|
76
|
+
|
|
77
|
+
return attachDesignTokenPreviewListener(target);
|
|
78
|
+
}, [targetRef]);
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|