@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,157 @@
|
|
|
1
|
+
import type { ImageVariantTier, ImageVariantTierName, ImageVariants } from '../types/media';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What surface is this image being rendered on. Chosen by the CALL SITE, which
|
|
5
|
+
* is the only thing that knows the CSS box — the helper never guesses.
|
|
6
|
+
*
|
|
7
|
+
* `card2x` is its own intent rather than just a rung on `card`'s ladder: the
|
|
8
|
+
* tier names and the intent names are deliberately decoupled. `card` and
|
|
9
|
+
* `card2x` build the SAME srcset ladder; they differ only in `sizes`, because
|
|
10
|
+
* this codebase has two genuinely different card sizes — the grid card
|
|
11
|
+
* (~320px CSS) and the featured/poster card (~620px CSS). Both would
|
|
12
|
+
* otherwise get the identical 480px `card` url, visibly soft on the poster
|
|
13
|
+
* ones.
|
|
14
|
+
*/
|
|
15
|
+
export type ImageIntent = 'avatar' | 'card' | 'card2x' | 'modal' | 'hero' | 'raw';
|
|
16
|
+
|
|
17
|
+
export interface ResolvedImageSources {
|
|
18
|
+
src: string | undefined;
|
|
19
|
+
srcSet: string | undefined;
|
|
20
|
+
sizes: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NONE: ResolvedImageSources = { src: undefined, srcSet: undefined, sizes: undefined };
|
|
24
|
+
|
|
25
|
+
/** Ascending by cap. `thumb` is excluded by contract rule 5; `original` is
|
|
26
|
+
* excluded on purpose — it is uncapped and unoptimized (raw jpg/png, possibly
|
|
27
|
+
* 6000px), so letting a browser pick it off a srcset is the exact regression
|
|
28
|
+
* this whole effort exists to remove. `original` survives only as a last-resort
|
|
29
|
+
* `src` for the `raw` intent and as the onError step-2 retry. */
|
|
30
|
+
const SRCSET_TIERS = ['card', 'card2x', 'view'] as const;
|
|
31
|
+
type SrcSetTier = (typeof SRCSET_TIERS)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* DOWNGRADE-ONLY ENFORCEMENT. The largest tier an intent is ever allowed to
|
|
35
|
+
* reach — for both `src` and `srcSet`. A capped intent can never resolve to
|
|
36
|
+
* `original`, so wiring `intent="card"` onto a call site that today ships the
|
|
37
|
+
* full original is guaranteed to be a downgrade or a no-op, never an upgrade.
|
|
38
|
+
*/
|
|
39
|
+
const INTENT_CEILING: Record<ImageIntent, SrcSetTier | null> = {
|
|
40
|
+
avatar: null, // fixed src off `thumb`, no srcset (rule 5)
|
|
41
|
+
card: 'card2x',
|
|
42
|
+
card2x: 'view',
|
|
43
|
+
modal: 'card2x',
|
|
44
|
+
hero: 'view',
|
|
45
|
+
raw: null, // "best single url, no responsive selection"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `sizes` per intent, derived from a survey of the 9 themes' real CSS grid
|
|
50
|
+
* breakpoints, validated against the real tier caps (480 / 800 / 1600):
|
|
51
|
+
*
|
|
52
|
+
* card @768px→384css: DPR1→card(480) · DPR2→768→card2x(800)
|
|
53
|
+
* @1100px→363css: DPR2→726→card2x · desktop 320css: DPR2→640→card2x
|
|
54
|
+
* card2x @768px→768css: DPR2→1536→view(1600) · desktop 620css: DPR2→1240→view
|
|
55
|
+
* modal 420css: DPR1→420→card(480) · DPR2→840→card2x(800, the ceiling)
|
|
56
|
+
* hero desktop 700css: DPR1→700→view · DPR2→1400→view(1600)
|
|
57
|
+
*
|
|
58
|
+
* `modal` is written as a media query (`(max-width: 456px) 92vw, 420px`,
|
|
59
|
+
* arithmetically identical to `min(420px, 92vw)`: 420/0.92 = 456.5) rather
|
|
60
|
+
* than relying on CSS math functions inside `sizes` — support for those is
|
|
61
|
+
* uneven, and a `sizes` the parser rejects silently degrades to 100vw, i.e.
|
|
62
|
+
* the browser picks the LARGEST candidate, the opposite of what we want.
|
|
63
|
+
*/
|
|
64
|
+
const INTENT_SIZES: Record<ImageIntent, string | undefined> = {
|
|
65
|
+
avatar: undefined,
|
|
66
|
+
card: '(max-width: 768px) 50vw, (max-width: 1100px) 33vw, 320px',
|
|
67
|
+
card2x: '(max-width: 768px) 100vw, (max-width: 1100px) 50vw, 620px',
|
|
68
|
+
modal: '(max-width: 456px) 92vw, 420px',
|
|
69
|
+
hero: '(max-width: 900px) calc(100vw - 32px), 700px',
|
|
70
|
+
raw: undefined,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Preference order for the plain `src`. Every list respects INTENT_CEILING —
|
|
74
|
+
* no capped intent can reach `original`. */
|
|
75
|
+
const INTENT_SRC_ORDER: Record<ImageIntent, ImageVariantTierName[]> = {
|
|
76
|
+
avatar: ['thumb', 'card'],
|
|
77
|
+
card: ['card', 'card2x'],
|
|
78
|
+
card2x: ['card2x', 'view', 'card'],
|
|
79
|
+
modal: ['card2x', 'card'],
|
|
80
|
+
// Deliberately NO 'card' fallback: `hero` callers (buildMediaFrames'
|
|
81
|
+
// heroUrl()) already fall back to the true raw original — a genuinely
|
|
82
|
+
// sharper image — when view/card2x don't exist yet for a product. If this
|
|
83
|
+
// list included 'card' here, resolveImageSources would silently win over
|
|
84
|
+
// that caller-provided `src` with the much smaller 480px tier, undoing the
|
|
85
|
+
// very fallback the caller carefully picked. Returning undefined instead
|
|
86
|
+
// lets the Image component's `resolved.src ?? props.src` defer to it.
|
|
87
|
+
hero: ['view', 'card2x'],
|
|
88
|
+
raw: ['original', 'view', 'card2x', 'card', 'thumb'],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function usableTier(tier: ImageVariantTier | null | undefined): tier is ImageVariantTier {
|
|
92
|
+
return !!tier && typeof tier.url === 'string' && tier.url.trim() !== '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolves a tier map + a surface intent into the three <img> attributes.
|
|
97
|
+
*
|
|
98
|
+
* Guarantees:
|
|
99
|
+
* - null/undefined `variants` → every field undefined, so the caller's own
|
|
100
|
+
* `src` prop keeps winning and behavior is byte-identical to today.
|
|
101
|
+
* - never emits `thumb` in a srcset.
|
|
102
|
+
* - never invents a url; null tiers are simply skipped.
|
|
103
|
+
* - never exceeds the intent's ceiling (downgrade-only).
|
|
104
|
+
* - dedupes candidates that collapsed to the same width on a small original,
|
|
105
|
+
* and drops the srcset entirely if fewer than 2 distinct widths survive
|
|
106
|
+
* (a 1-candidate srcset is pure byte overhead).
|
|
107
|
+
* - only emits `sizes` alongside a real `srcSet`.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveImageSources(
|
|
110
|
+
variants: ImageVariants | null | undefined,
|
|
111
|
+
intent: ImageIntent,
|
|
112
|
+
): ResolvedImageSources {
|
|
113
|
+
if (!variants) {
|
|
114
|
+
return NONE;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let src: string | undefined;
|
|
118
|
+
for (const name of INTENT_SRC_ORDER[intent]) {
|
|
119
|
+
const tier = variants[name];
|
|
120
|
+
if (usableTier(tier)) {
|
|
121
|
+
src = tier.url;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ceiling = INTENT_CEILING[intent];
|
|
127
|
+
if (ceiling === null) {
|
|
128
|
+
return { src, srcSet: undefined, sizes: undefined };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const limit = SRCSET_TIERS.indexOf(ceiling);
|
|
132
|
+
const byWidth = new Map<number, string>();
|
|
133
|
+
for (let i = 0; i <= limit; i += 1) {
|
|
134
|
+
const tier = variants[SRCSET_TIERS[i]];
|
|
135
|
+
// A tier with no usable width can't carry a `w` descriptor, and mixing
|
|
136
|
+
// `w` and bare descriptors in one srcset makes the whole attribute invalid.
|
|
137
|
+
if (!usableTier(tier) || typeof tier.w !== 'number' || !Number.isFinite(tier.w) || tier.w <= 0) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// First writer wins: SRCSET_TIERS is ascending by cap, so at a collapsed
|
|
141
|
+
// width the lower-cap encode (the smaller file) is the one we keep.
|
|
142
|
+
if (!byWidth.has(tier.w)) {
|
|
143
|
+
byWidth.set(tier.w, tier.url);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (byWidth.size < 2) {
|
|
148
|
+
return { src, srcSet: undefined, sizes: undefined };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const srcSet = [...byWidth.entries()]
|
|
152
|
+
.sort((a, b) => a[0] - b[0])
|
|
153
|
+
.map(([w, url]) => `${url} ${w}w`)
|
|
154
|
+
.join(', ');
|
|
155
|
+
|
|
156
|
+
return { src, srcSet, sizes: INTENT_SIZES[intent] };
|
|
157
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { Fragment } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Strip markdown syntax to plain text. Use for previews / single-line contexts
|
|
6
|
+
* (cards, modals, meta descriptions) where formatting must not appear.
|
|
7
|
+
*/
|
|
8
|
+
export function stripMarkdown(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/!\[[^\]]*\]\([^)]+\)/g, '') // images
|
|
11
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → label
|
|
12
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
|
|
13
|
+
.replace(/(\*|_)(.*?)\1/g, '$2') // italic
|
|
14
|
+
.replace(/`([^`]+)`/g, '$1') // inline code
|
|
15
|
+
.replace(/^#{1,6}\s+/gm, '') // headings
|
|
16
|
+
.replace(/^\s*[-*+]\s+/gm, '') // unordered list markers
|
|
17
|
+
.replace(/^\s*\d+\.\s+/gm, '') // ordered list markers
|
|
18
|
+
.replace(/^\s*>\s?/gm, '') // blockquote markers
|
|
19
|
+
.replace(/\n{2,}/g, ' ')
|
|
20
|
+
.replace(/\n/g, ' ')
|
|
21
|
+
.replace(/\s{2,}/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Matches  image, **bold**, __bold__, *italic*, _italic_, `code`, [label](url).
|
|
26
|
+
// Image before link (image is a superset starting with `!`); bold before italic.
|
|
27
|
+
const INLINE_RE = /(!\[[^\]]*\]\([^)]+\)|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
|
|
28
|
+
|
|
29
|
+
// A root-relative internal link ("/about") is correct as-is on a real vendor's
|
|
30
|
+
// own domain (where the storefront IS the domain root), but the exact same
|
|
31
|
+
// content also renders inside `/preview/[theme]/*`, where the theme's own
|
|
32
|
+
// pages live under a `/preview/<slug>` prefix — without this, every internal
|
|
33
|
+
// link written into admin-editable markdown (footer columns, taglines, page
|
|
34
|
+
// content) 404s in preview. Only touches root-relative paths; external URLs,
|
|
35
|
+
// hash anchors, and empty basePath (the real-vendor case) pass through
|
|
36
|
+
// untouched.
|
|
37
|
+
function withBasePath(href: string, basePath?: string): string {
|
|
38
|
+
if (!basePath || !href.startsWith('/') || href.startsWith('//')) return href;
|
|
39
|
+
return `${basePath}${href}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseInline(text: string, keyPrefix: string, basePath?: string): React.ReactNode[] {
|
|
43
|
+
const parts = text.split(INLINE_RE).filter(Boolean);
|
|
44
|
+
|
|
45
|
+
return parts.map((part, index) => {
|
|
46
|
+
const key = `${keyPrefix}-${index}`;
|
|
47
|
+
|
|
48
|
+
if ((part.startsWith('**') && part.endsWith('**')) || (part.startsWith('__') && part.endsWith('__'))) {
|
|
49
|
+
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if ((part.startsWith('*') && part.endsWith('*')) || (part.startsWith('_') && part.endsWith('_'))) {
|
|
53
|
+
return <em key={key}>{part.slice(1, -1)}</em>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (part.startsWith('`') && part.endsWith('`')) {
|
|
57
|
+
return <code key={key}>{part.slice(1, -1)}</code>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const imageMatch = part.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
61
|
+
if (imageMatch) {
|
|
62
|
+
const [, alt, src] = imageMatch;
|
|
63
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
64
|
+
return (
|
|
65
|
+
<img
|
|
66
|
+
key={key}
|
|
67
|
+
src={src}
|
|
68
|
+
alt={alt}
|
|
69
|
+
loading="lazy"
|
|
70
|
+
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
76
|
+
if (linkMatch) {
|
|
77
|
+
const [, label, href] = linkMatch;
|
|
78
|
+
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
|
79
|
+
return (
|
|
80
|
+
<a key={key} href={withBasePath(href, basePath)} {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
|
|
81
|
+
{label}
|
|
82
|
+
</a>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return <Fragment key={key}>{part}</Fragment>;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Render the lines of a single paragraph block, preserving single line breaks as <br>.
|
|
91
|
+
function renderParagraphLines(block: string, keyPrefix: string, basePath?: string): React.ReactNode[] {
|
|
92
|
+
const lines = block.split('\n');
|
|
93
|
+
return lines.flatMap((line, i) => {
|
|
94
|
+
const content = parseInline(line, `${keyPrefix}-l${i}`, basePath);
|
|
95
|
+
return i < lines.length - 1
|
|
96
|
+
? [...content, <br key={`${keyPrefix}-br${i}`} />]
|
|
97
|
+
: content;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Split a GFM table row into trimmed cells, tolerating optional leading/trailing pipes.
|
|
102
|
+
function tableCells(row: string): string[] {
|
|
103
|
+
return row.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render a GFM-style table block, or null if it isn't one. Shape:
|
|
108
|
+
* | Head A | Head B |
|
|
109
|
+
* | --- | --- |
|
|
110
|
+
* | a1 | b1 |
|
|
111
|
+
*/
|
|
112
|
+
function renderTable(block: string, key: string, basePath?: string): JSX.Element | null {
|
|
113
|
+
const rows = block.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
114
|
+
if (rows.length < 2 || !rows[0].includes('|')) return null;
|
|
115
|
+
// Second line must be the separator: only |, -, :, and spaces, with at least one dash.
|
|
116
|
+
if (!/^\|?[\s:|-]+\|?$/.test(rows[1]) || !rows[1].includes('-')) return null;
|
|
117
|
+
|
|
118
|
+
const headers = tableCells(rows[0]);
|
|
119
|
+
const bodyRows = rows.slice(2).map(tableCells);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<table key={key}>
|
|
123
|
+
<thead>
|
|
124
|
+
<tr>
|
|
125
|
+
{headers.map((h, i) => (
|
|
126
|
+
<th key={i}>{parseInline(h, `${key}-h${i}`, basePath)}</th>
|
|
127
|
+
))}
|
|
128
|
+
</tr>
|
|
129
|
+
</thead>
|
|
130
|
+
<tbody>
|
|
131
|
+
{bodyRows.map((cells, r) => (
|
|
132
|
+
<tr key={r}>
|
|
133
|
+
{cells.map((c, i) => (
|
|
134
|
+
<td key={i}>{parseInline(c, `${key}-r${r}-${i}`, basePath)}</td>
|
|
135
|
+
))}
|
|
136
|
+
</tr>
|
|
137
|
+
))}
|
|
138
|
+
</tbody>
|
|
139
|
+
</table>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A heading only ever occupies its own first line — `block.match` here is
|
|
144
|
+
// deliberately anchored to the start of the block (not per-line via `m`), so
|
|
145
|
+
// a block is a heading only when IT, not some later line, opens with 1-3 `#`.
|
|
146
|
+
// Anything after that first line (list, paragraph, even another heading) is
|
|
147
|
+
// real content of its own, not part of the heading text — see renderBlock.
|
|
148
|
+
const HEADING_RE = /^(#{1,3})[ \t]+([^\n]*)(?:\n([\s\S]*))?$/;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render one blank-line-delimited block into its element(s) — usually one,
|
|
152
|
+
* but a heading with no blank line before whatever follows it yields two:
|
|
153
|
+
* the heading (its first line only) plus that remainder re-processed through
|
|
154
|
+
* this same function. Without the split, "### Heading\n- item\n- item" (no
|
|
155
|
+
* blank line — the shape a merchant's pasted copy actually produces) read as
|
|
156
|
+
* ONE heading whose text was the literal list, since `block.startsWith('### ')`
|
|
157
|
+
* used to consume the whole block, list markers and all.
|
|
158
|
+
*/
|
|
159
|
+
function renderBlock(block: string, key: string, basePath?: string): JSX.Element[] {
|
|
160
|
+
const headingMatch = block.match(HEADING_RE);
|
|
161
|
+
if (headingMatch) {
|
|
162
|
+
const [, hashes, headingText, rest] = headingMatch;
|
|
163
|
+
const headingKey = `${key}-h`;
|
|
164
|
+
const inline = parseInline(headingText, headingKey, basePath);
|
|
165
|
+
const heading =
|
|
166
|
+
hashes.length === 1 ? <h1 key={headingKey}>{inline}</h1> :
|
|
167
|
+
hashes.length === 2 ? <h2 key={headingKey}>{inline}</h2> :
|
|
168
|
+
<h3 key={headingKey}>{inline}</h3>;
|
|
169
|
+
|
|
170
|
+
if (!rest || !rest.trim()) return [heading];
|
|
171
|
+
return [heading, ...renderBlock(rest.trim(), `${key}-r`, basePath)];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return [renderNonHeadingBlock(block, key, basePath)];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderNonHeadingBlock(block: string, key: string, basePath?: string): JSX.Element {
|
|
178
|
+
// Horizontal rule — a line of only ---, *** or ___ (3+).
|
|
179
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) return <hr key={key} />;
|
|
180
|
+
|
|
181
|
+
// GFM table — a header row, a |---|---| separator, then body rows. Without
|
|
182
|
+
// this the pipes render as literal text (the "markdown not rendered" case).
|
|
183
|
+
const tableEl = renderTable(block, key, basePath);
|
|
184
|
+
if (tableEl) return tableEl;
|
|
185
|
+
|
|
186
|
+
// Unordered list — every non-empty line starts with -, * or +
|
|
187
|
+
const lines = block.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
188
|
+
if (lines.length > 0 && lines.every((l) => /^[-*+]\s+/.test(l))) {
|
|
189
|
+
return (
|
|
190
|
+
<ul key={key}>
|
|
191
|
+
{lines.map((line, i) => (
|
|
192
|
+
<li key={i}>{parseInline(line.replace(/^[-*+]\s+/, ''), `${key}-${i}`, basePath)}</li>
|
|
193
|
+
))}
|
|
194
|
+
</ul>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Ordered list — every non-empty line starts with "N. "
|
|
199
|
+
if (lines.length > 0 && lines.every((l) => /^\d+\.\s+/.test(l))) {
|
|
200
|
+
return (
|
|
201
|
+
<ol key={key}>
|
|
202
|
+
{lines.map((line, i) => (
|
|
203
|
+
<li key={i}>{parseInline(line.replace(/^\d+\.\s+/, ''), `${key}-${i}`, basePath)}</li>
|
|
204
|
+
))}
|
|
205
|
+
</ol>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Blockquote — every non-empty line starts with ">"
|
|
210
|
+
if (lines.length > 0 && lines.every((l) => /^>\s?/.test(l))) {
|
|
211
|
+
const inner = lines.map((l) => l.replace(/^>\s?/, '')).join('\n');
|
|
212
|
+
return <blockquote key={key}>{renderParagraphLines(inner, key, basePath)}</blockquote>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return <p key={key}>{renderParagraphLines(block, key, basePath)}</p>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convert a markdown string to React elements. Supports headings, bold, italic,
|
|
220
|
+
* inline code, links, ordered/unordered lists, blockquotes, tables, horizontal
|
|
221
|
+
* rules, and line breaks. Returns block-level elements — render inside a wrapper.
|
|
222
|
+
*
|
|
223
|
+
* `basePath` prefixes root-relative links (`/about`) so the same admin-authored
|
|
224
|
+
* content works both on a real vendor's own domain (basePath '') and inside
|
|
225
|
+
* `/preview/[theme]/*` (basePath `/preview/<slug>`) — see `withBasePath` above.
|
|
226
|
+
* Optional and additive; omit it and behavior is unchanged.
|
|
227
|
+
*/
|
|
228
|
+
export function renderMarkdown(markdown: string, basePath?: string): JSX.Element[] {
|
|
229
|
+
if (!markdown || !markdown.trim()) return [];
|
|
230
|
+
|
|
231
|
+
return markdown
|
|
232
|
+
.replace(/\r\n/g, '\n')
|
|
233
|
+
.split(/\n{2,}/)
|
|
234
|
+
.map((block) => block.trim())
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.flatMap((block, index) => renderBlock(block, String(index), basePath));
|
|
237
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Product, ProductVariant } from '../types/product';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Same `selectedOptions -> variant` matching every theme's product page
|
|
5
|
+
* duplicates inline (see each theme's pages/product.tsx) — centralized here
|
|
6
|
+
* so it's usable (and testable) outside a themed page, e.g. the quick-add page.
|
|
7
|
+
*/
|
|
8
|
+
export function matchVariant(
|
|
9
|
+
variants: ProductVariant[],
|
|
10
|
+
selectedOptions: Record<string, string>,
|
|
11
|
+
): ProductVariant | null {
|
|
12
|
+
if (variants.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
variants.find((variant) =>
|
|
16
|
+
variant.option_values.every((ov) => selectedOptions[ov.option_name] === ov.value),
|
|
17
|
+
) ?? null
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when a product cannot be added to a cart without a chosen variant.
|
|
23
|
+
*
|
|
24
|
+
* `variants_count` is checked alongside `variants` on purpose: a product from a
|
|
25
|
+
* LIST payload carries the count but not the array (the array arrives with the
|
|
26
|
+
* detail fetch), and a quick-add from a card must still be refused rather than
|
|
27
|
+
* sending a variant-required line with no variant.
|
|
28
|
+
*/
|
|
29
|
+
export function productRequiresVariant(product: Product): boolean {
|
|
30
|
+
return (product.variants?.length ?? 0) > 0 || (product.variants_count ?? 0) > 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MenuItem } from '../types/menu';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable React key for a menu item. `label` alone isn't unique — two
|
|
5
|
+
* distinct collections (or two anchors on the same page) can share a
|
|
6
|
+
* display label, which then collides as a React key and can silently
|
|
7
|
+
* drop/duplicate the rendered item. `index` is the final tiebreaker since
|
|
8
|
+
* MenuItem carries no id and the list order is stable per render.
|
|
9
|
+
*/
|
|
10
|
+
export function menuItemKey(item: MenuItem, index: number): string {
|
|
11
|
+
return `${item.type}:${item.ref ?? ''}:${item.meta?.anchor ?? ''}:${index}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function menuItemToHref(item: MenuItem, basePath: string = ''): string {
|
|
15
|
+
switch (item.type) {
|
|
16
|
+
case 'page': {
|
|
17
|
+
const href = `${basePath}/${item.ref}`;
|
|
18
|
+
const anchor = item.meta?.anchor;
|
|
19
|
+
// href may already end in "/" (the home page's ref is normalized to
|
|
20
|
+
// "/" — see MenuResource::normalizeHomeRefs) — never emit a doubled
|
|
21
|
+
// slash before the fragment.
|
|
22
|
+
return anchor ? `${href.replace(/\/$/, '')}#${anchor}` : href;
|
|
23
|
+
}
|
|
24
|
+
case 'collection':
|
|
25
|
+
return `${basePath}/collections/${item.ref}`;
|
|
26
|
+
case 'collections':
|
|
27
|
+
return `${basePath}/collections`;
|
|
28
|
+
case 'shop':
|
|
29
|
+
return `${basePath}/shop`;
|
|
30
|
+
case 'blog':
|
|
31
|
+
return `${basePath}/blog`;
|
|
32
|
+
case 'post':
|
|
33
|
+
return `${basePath}/blog/${item.ref}`;
|
|
34
|
+
case 'gallery':
|
|
35
|
+
return `${basePath}/gallery/${item.ref}`;
|
|
36
|
+
case 'url':
|
|
37
|
+
return item.ref ?? '#';
|
|
38
|
+
default:
|
|
39
|
+
return '#';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart placeholder resolver — generates theme-aware SVG data URIs
|
|
3
|
+
* for empty image slots. Keeps vendor stores looking intentional, never broken.
|
|
4
|
+
*
|
|
5
|
+
* Purely visual. Text fallbacks for block content live in the block components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type PlaceholderVariant = 'photo' | 'banner' | 'card' | 'square';
|
|
9
|
+
|
|
10
|
+
export interface PlaceholderPalette {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PlaceholderContext {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
label?: string;
|
|
20
|
+
sublabel?: string;
|
|
21
|
+
palette?: PlaceholderPalette;
|
|
22
|
+
font?: string;
|
|
23
|
+
variant?: PlaceholderVariant;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NEUTRAL_PALETTE: PlaceholderPalette = {
|
|
27
|
+
from: '#e8e4df',
|
|
28
|
+
to: '#c9c3ba',
|
|
29
|
+
text: '#1a1a1a',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const MAX_LABEL_LENGTH = 40;
|
|
33
|
+
const MIN_LABEL_DIMENSION = 200;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Escape a string for safe insertion inside an SVG text node.
|
|
37
|
+
*/
|
|
38
|
+
function escapeSvgText(value: string): string {
|
|
39
|
+
return value
|
|
40
|
+
.replace(/&/g, '&')
|
|
41
|
+
.replace(/</g, '<')
|
|
42
|
+
.replace(/>/g, '>')
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
.replace(/'/g, ''');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Truncate a label if it exceeds the max display length.
|
|
49
|
+
*/
|
|
50
|
+
function truncateLabel(value: string): string {
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
if (trimmed.length <= MAX_LABEL_LENGTH) return trimmed;
|
|
53
|
+
return `${trimmed.slice(0, MAX_LABEL_LENGTH - 1).trimEnd()}…`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute relative luminance of a hex color (sRGB → linear → relative luminance).
|
|
58
|
+
* Returns 0..1. Used to decide whether text should be white or black over a gradient.
|
|
59
|
+
*/
|
|
60
|
+
function relativeLuminance(hex: string): number {
|
|
61
|
+
const cleaned = hex.replace('#', '');
|
|
62
|
+
if (cleaned.length !== 3 && cleaned.length !== 6) return 0.5;
|
|
63
|
+
const expanded =
|
|
64
|
+
cleaned.length === 3
|
|
65
|
+
? cleaned
|
|
66
|
+
.split('')
|
|
67
|
+
.map((c) => c + c)
|
|
68
|
+
.join('')
|
|
69
|
+
: cleaned;
|
|
70
|
+
|
|
71
|
+
const r = parseInt(expanded.slice(0, 2), 16) / 255;
|
|
72
|
+
const g = parseInt(expanded.slice(2, 4), 16) / 255;
|
|
73
|
+
const b = parseInt(expanded.slice(4, 6), 16) / 255;
|
|
74
|
+
|
|
75
|
+
const channel = (c: number): number => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
76
|
+
|
|
77
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute contrast ratio between two colors per WCAG 2.1.
|
|
82
|
+
* Returns a number >= 1. 4.5 is the AA threshold for normal text.
|
|
83
|
+
*/
|
|
84
|
+
function contrastRatio(fg: string, bg: string): number {
|
|
85
|
+
const fgLum = relativeLuminance(fg);
|
|
86
|
+
const bgLum = relativeLuminance(bg);
|
|
87
|
+
const brighter = Math.max(fgLum, bgLum);
|
|
88
|
+
const darker = Math.min(fgLum, bgLum);
|
|
89
|
+
return (brighter + 0.05) / (darker + 0.05);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pick a readable text color over a given gradient. Flips to black/white
|
|
94
|
+
* when contrast is insufficient.
|
|
95
|
+
*/
|
|
96
|
+
function readableText(requestedText: string, gradientFrom: string, gradientTo: string): string {
|
|
97
|
+
// Average the two gradient stops as the effective background luminance.
|
|
98
|
+
const effectiveBg = relativeLuminance(gradientFrom) * 0.5 + relativeLuminance(gradientTo) * 0.5;
|
|
99
|
+
|
|
100
|
+
// Contrast against the effective background midpoint.
|
|
101
|
+
const midLum = effectiveBg;
|
|
102
|
+
const requestedLum = relativeLuminance(requestedText);
|
|
103
|
+
const ratio = (Math.max(midLum, requestedLum) + 0.05) / (Math.min(midLum, requestedLum) + 0.05);
|
|
104
|
+
|
|
105
|
+
if (ratio >= 4.5) return requestedText;
|
|
106
|
+
|
|
107
|
+
// Pick the color with the highest contrast against the midpoint.
|
|
108
|
+
return midLum > 0.5 ? '#0a0a0a' : '#ffffff';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a theme-aware SVG placeholder and return it as a data URI.
|
|
113
|
+
* Safe to embed inline as an <img src> — no network request, no cache concerns.
|
|
114
|
+
*/
|
|
115
|
+
export function resolvePlaceholder(ctx: PlaceholderContext): string {
|
|
116
|
+
const width = Math.max(1, Math.round(ctx.width));
|
|
117
|
+
const height = Math.max(1, Math.round(ctx.height));
|
|
118
|
+
const palette = ctx.palette ?? NEUTRAL_PALETTE;
|
|
119
|
+
const font = ctx.font ?? 'system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
120
|
+
const textColor = readableText(palette.text, palette.from, palette.to);
|
|
121
|
+
|
|
122
|
+
const minDim = Math.min(width, height);
|
|
123
|
+
const showLabel = minDim >= MIN_LABEL_DIMENSION && !!ctx.label;
|
|
124
|
+
const labelText = ctx.label ? escapeSvgText(truncateLabel(ctx.label)) : '';
|
|
125
|
+
const sublabelText = ctx.sublabel && showLabel ? escapeSvgText(truncateLabel(ctx.sublabel)) : '';
|
|
126
|
+
|
|
127
|
+
// Font size scales with the smaller dimension, capped sensibly.
|
|
128
|
+
const fontSize = Math.round(Math.max(14, Math.min(minDim / 12, 48)));
|
|
129
|
+
const sublabelSize = Math.round(fontSize * 0.55);
|
|
130
|
+
|
|
131
|
+
// Gradient angle varies by variant for subtle visual difference.
|
|
132
|
+
const variant = ctx.variant ?? 'photo';
|
|
133
|
+
const gradientAngle =
|
|
134
|
+
variant === 'banner' ? '90 0 0' : variant === 'card' ? '135 0 0' : variant === 'square' ? '45 0 0' : '120 0 0';
|
|
135
|
+
|
|
136
|
+
// Subtle noise overlay — low opacity, decorative.
|
|
137
|
+
const noiseId = `n${variant}${width}${height}`.replace(/[^a-z0-9]/gi, '');
|
|
138
|
+
const gradientId = `g${noiseId}`;
|
|
139
|
+
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`);
|
|
142
|
+
parts.push('<defs>');
|
|
143
|
+
parts.push(
|
|
144
|
+
`<linearGradient id="${gradientId}" gradientTransform="rotate(${gradientAngle})"><stop offset="0%" stop-color="${palette.from}"/><stop offset="100%" stop-color="${palette.to}"/></linearGradient>`,
|
|
145
|
+
);
|
|
146
|
+
parts.push(
|
|
147
|
+
`<filter id="${noiseId}"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="2"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/></filter>`,
|
|
148
|
+
);
|
|
149
|
+
parts.push('</defs>');
|
|
150
|
+
parts.push(`<rect width="${width}" height="${height}" fill="url(#${gradientId})"/>`);
|
|
151
|
+
parts.push(`<rect width="${width}" height="${height}" filter="url(#${noiseId})"/>`);
|
|
152
|
+
|
|
153
|
+
if (showLabel) {
|
|
154
|
+
const cx = width / 2;
|
|
155
|
+
const cy = sublabelText ? height / 2 - sublabelSize / 2 : height / 2;
|
|
156
|
+
parts.push(
|
|
157
|
+
`<text x="${cx}" y="${cy}" font-family="${escapeSvgText(font)}" font-size="${fontSize}" font-weight="500" fill="${textColor}" text-anchor="middle" dominant-baseline="middle" letter-spacing="0.02em">${labelText}</text>`,
|
|
158
|
+
);
|
|
159
|
+
if (sublabelText) {
|
|
160
|
+
parts.push(
|
|
161
|
+
`<text x="${cx}" y="${cy + fontSize * 0.9}" font-family="${escapeSvgText(font)}" font-size="${sublabelSize}" font-weight="400" fill="${textColor}" fill-opacity="0.65" text-anchor="middle" dominant-baseline="middle">${sublabelText}</text>`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
parts.push('</svg>');
|
|
167
|
+
|
|
168
|
+
const svg = parts.join('');
|
|
169
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Exposed for tests. Not part of the public API.
|
|
174
|
+
*/
|
|
175
|
+
export const __internal = {
|
|
176
|
+
escapeSvgText,
|
|
177
|
+
truncateLabel,
|
|
178
|
+
relativeLuminance,
|
|
179
|
+
contrastRatio,
|
|
180
|
+
readableText,
|
|
181
|
+
NEUTRAL_PALETTE,
|
|
182
|
+
MAX_LABEL_LENGTH,
|
|
183
|
+
MIN_LABEL_DIMENSION,
|
|
184
|
+
};
|