@usequeek/theme-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
@@ -0,0 +1,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
+ }