@usequeek/theme-kit 0.1.5 → 0.1.6

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  The framework a Queek storefront theme is built against: hooks, stores, headless
4
4
  flows (auth, cart, checkout), types and the block renderer.
5
5
 
6
- **Ships TypeScript source, not a build.** 78 of its 115 modules carry a
6
+ **Ships TypeScript source, not a build.** Most of its modules carry a
7
7
  `'use client'` directive, and that directive is the whole contract with the React
8
8
  Server Components boundary — bundlers routinely strip or hoist it when a library
9
9
  is pre-compiled, which turns a client module into a server one silently. The
@@ -15,8 +15,16 @@ map. One was tried: with source-shipped `.ts`/`.tsx` an extensionless wildcard
15
15
  target does not resolve, and spelling out extensions cannot cover both. Legacy
16
16
  resolution under `moduleResolution: bundler` handles it. The cost is that the
17
17
  package has no subpath encapsulation, so what a theme may import is enforced by
18
- `tests/core-public-surface.test.ts` in the storefront repo instead — which is
19
- where the real contract lives anyway. Revisit if this is ever published.
18
+ convention plus tests: `tests/public-surface.test.ts` here (barrels declare
19
+ only modules that exist) and `core-public-surface.test.ts` in the storefront
20
+ repo (barrels cover every module the themes actually import).
21
+
22
+ Every module that annotates a return as `JSX.Element` does
23
+ `import type { JSX } from 'react'` — React 19 removed the global `JSX`
24
+ namespace, and 0.1.0 shipped broken for every external consumer by relying on
25
+ the storefront app's `next-env.d.ts` to provide it. `scripts/verify-package.ts`
26
+ packs the current source and typechecks a real theme against it from a sandbox,
27
+ so that class of breakage fails here instead of after publishing.
20
28
 
21
29
  ## Use
22
30
 
@@ -30,8 +38,50 @@ to DECLARE the public surface — what a theme is allowed to build against — b
30
38
  importing through them pulls every client module into one chunk and was measured
31
39
  to break the app's build. Import the module you want.
32
40
 
41
+ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks.css`
42
+ (framework-owned blocks), plus per-component CSS next to its component
43
+ (`components/variant-picker.css`, `apps/apps.css`).
44
+
45
+ ## Consumers
46
+
47
+ - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
48
+ from the npm registry (`"@usequeek/theme-kit": "^x.y.z"`) and transpiles it
49
+ via `transpilePackages`. Its vitest config inlines the package
50
+ (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
51
+ source is transformed like first-party code.
52
+ - **External theme developers** start from `packages/theme-starter` in the
53
+ storefront repo, which builds standalone against a packed tarball of this
54
+ source. Anyone testing a theme with vitest needs the same `inline` line;
55
+ anyone compiling one needs `moduleResolution: bundler` + `jsx: react-jsx`.
56
+
33
57
  ## Boundary
34
58
 
35
59
  The kit must never import the storefront app, its themes, or `lib/storefront`.
36
- That one-way rule is enforced by `tests/core-storefront-boundary.test.ts` in the
37
- storefront repo, and it is why this package can be extracted at all.
60
+ That one-way rule is what makes this package extractable at all.
61
+
62
+ ## Develop
63
+
64
+ ```bash
65
+ npm install
66
+ npm run typecheck # tsc --noEmit, standalone — no storefront files
67
+ npm test # vitest run
68
+ npm run verify-package # pack current source, typecheck a real theme against it
69
+ ```
70
+
71
+ ## Release
72
+
73
+ The package is public on npm; the repo is private. Publishing runs on tags via
74
+ `.github/workflows/publish.yml` (needs the `NPM_TOKEN` secret):
75
+
76
+ ```bash
77
+ # 1. Bump version in package.json and commit
78
+ # 2. Tag and push the tag — the workflow typechecks, tests, verify-packages,
79
+ # then `npm publish --access public`:
80
+ git tag v0.1.6
81
+ git push origin main --tags
82
+ ```
83
+
84
+ The storefront then bumps its dependency to match the published version
85
+ (`yarn add @usequeek/theme-kit@^0.1.6` + lockfile) and pushes separately —
86
+ never the other way round: the app must only ever depend on a version that
87
+ actually exists on the registry.
package/client.ts CHANGED
@@ -12,6 +12,7 @@ export * from './theme-variant-resolver';
12
12
  export * from './shared-blocks';
13
13
  export * from './components/blog';
14
14
  export * from './components/image';
15
+ export * from './components/product-metafields';
15
16
  export * from './components/product-media-frame';
16
17
  export * from './components/variant-picker';
17
18
  export * from './components/auth/auth-flow';
@@ -0,0 +1,213 @@
1
+ import type { JSX } from 'react';
2
+ import type {
3
+ MetafieldScalar,
4
+ MetafieldValue,
5
+ MetaobjectEntry,
6
+ Product,
7
+ } from '../types/product';
8
+
9
+ export interface ProductMetafieldDefinitionLabels {
10
+ [key: string]: { name: string; type: string };
11
+ }
12
+
13
+ export interface ProductMetafieldsProps {
14
+ product: Product;
15
+ definitions?: ProductMetafieldDefinitionLabels;
16
+ }
17
+
18
+ /**
19
+ * Core-owned product metafields section — the AuthFlow pattern: themes
20
+ * import-and-place it, never re-implement it.
21
+ *
22
+ * Renders the product's presented metafields (all types), with linked
23
+ * metaobject entries as cards. Renders nothing when there are no visible
24
+ * values. Markup carries `core-metafields*` classes so each theme styles it
25
+ * in its own `theme.css` (framework-owned styling contract) — no theme CSS
26
+ * lives in core.
27
+ */
28
+
29
+ function isEntry(value: unknown): value is MetaobjectEntry {
30
+ return (
31
+ !!value &&
32
+ typeof value === 'object' &&
33
+ !Array.isArray(value) &&
34
+ typeof (value as MetaobjectEntry).display_name === 'string' &&
35
+ typeof (value as MetaobjectEntry).handle === 'string'
36
+ );
37
+ }
38
+
39
+ /** `care_instructions` → `Care instructions`; unknown handles label from the key. */
40
+ export function humaniseMetafieldKey(key: string): string {
41
+ const words = key.replace(/[_-]+/g, ' ').trim();
42
+ if (words === '') return key;
43
+
44
+ return words.charAt(0).toUpperCase() + words.slice(1);
45
+ }
46
+
47
+ function labelFor(key: string, definitions?: ProductMetafieldDefinitionLabels): string {
48
+ const name = definitions?.[key]?.name?.trim();
49
+ return name && name !== '' ? name : humaniseMetafieldKey(key);
50
+ }
51
+
52
+ function typeFor(key: string, definitions?: ProductMetafieldDefinitionLabels): string {
53
+ return definitions?.[key]?.type ?? '';
54
+ }
55
+
56
+ function isVisibleScalar(value: MetafieldScalar): boolean {
57
+ if (typeof value === 'string') return value.trim() !== '';
58
+ return true;
59
+ }
60
+
61
+ function isVisibleValue(value: MetafieldValue): boolean {
62
+ if (value === null || value === undefined) return false;
63
+ if (Array.isArray(value)) return value.some((item) => (isEntry(item) ? true : isVisibleScalar(item as MetafieldScalar)));
64
+ if (isEntry(value)) return true;
65
+ return isVisibleScalar(value as MetafieldScalar);
66
+ }
67
+
68
+ function normaliseBoolean(value: MetafieldValue): boolean | null {
69
+ if (typeof value === 'boolean') return value;
70
+ if (value === 1 || value === '1' || value === 'true') return true;
71
+ if (value === 0 || value === '0' || value === 'false') return false;
72
+ return null;
73
+ }
74
+
75
+ const IMAGE_URL_PATTERN = /^https?:\/\/\S+\.(jpg|jpeg|png|gif|webp|avif)(\?\S*)?$/i;
76
+
77
+ function renderScalar(value: MetafieldScalar, key: string, label: string, type: string): JSX.Element {
78
+ if (typeof value === 'boolean' || (type === 'boolean' && normaliseBoolean(value) !== null)) {
79
+ const yes = typeof value === 'boolean' ? value : normaliseBoolean(value) === true;
80
+ return <p className="core-metafields__text">{yes ? 'Yes' : 'No'}</p>;
81
+ }
82
+
83
+ const text = String(value);
84
+
85
+ if (type === 'url') {
86
+ return /^https?:\/\//i.test(text) ? (
87
+ <a className="core-metafields__link" href={text} target="_blank" rel="noopener noreferrer">
88
+ {text}
89
+ </a>
90
+ ) : (
91
+ <p className="core-metafields__text">{text}</p>
92
+ );
93
+ }
94
+
95
+ if (type === 'media_id') {
96
+ return /^https?:\/\//i.test(text) ? (
97
+ // Media values arrive as resolved URLs (media contract) — plain <img>:
98
+ // this is core, not a theme, so the themes' <Image> rule doesn't apply.
99
+ <img className="core-metafields__image" src={text} alt={label} loading="lazy" />
100
+ ) : (
101
+ <p className="core-metafields__text">{text}</p>
102
+ );
103
+ }
104
+
105
+ if (type === 'date') {
106
+ const time = new Date(text).getTime();
107
+ return (
108
+ <p className="core-metafields__text">
109
+ {Number.isNaN(time) ? text : new Date(time).toLocaleDateString()}
110
+ </p>
111
+ );
112
+ }
113
+
114
+ if (type === 'multi_line_text' || type === 'rich_text') {
115
+ return <pre className="core-metafields__pre">{text}</pre>;
116
+ }
117
+
118
+ // Entry sub-fields carry no per-field type — an image-looking URL renders
119
+ // as an image, everything else as text.
120
+ if (type === '' && IMAGE_URL_PATTERN.test(text)) {
121
+ return <img className="core-metafields__image" src={text} alt={label} loading="lazy" />;
122
+ }
123
+
124
+ return <p className="core-metafields__text">{text}</p>;
125
+ }
126
+
127
+ function renderEntryField(value: MetafieldScalar | MetafieldScalar[], fieldKey: string): JSX.Element {
128
+ if (Array.isArray(value)) {
129
+ return (
130
+ <ul className="core-metafields__list">
131
+ {value.map((item, index) => (
132
+ // Entry values are flat scalars; the index is stable per render.
133
+ // eslint-disable-next-line react/no-array-index-key
134
+ <li key={index} className="core-metafields__list-item">
135
+ {typeof item === 'boolean' ? (item ? 'Yes' : 'No') : String(item)}
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ );
140
+ }
141
+
142
+ return renderScalar(value, fieldKey, humaniseMetafieldKey(fieldKey), '');
143
+ }
144
+
145
+ function EntryCard({ entry }: { entry: MetaobjectEntry }): JSX.Element {
146
+ const fields = entry.fields && typeof entry.fields === 'object' ? entry.fields : {};
147
+
148
+ return (
149
+ <article className="core-metafields__entry-card">
150
+ <h4 className="core-metafields__entry-title">{entry.display_name}</h4>
151
+ {Object.entries(fields).map(([fieldKey, fieldValue]) => (
152
+ <div className="core-metafields__entry-field" key={fieldKey}>
153
+ <span className="core-metafields__entry-field-label">{humaniseMetafieldKey(fieldKey)}</span>
154
+ <div className="core-metafields__entry-field-value">
155
+ {renderEntryField(fieldValue as MetafieldScalar | MetafieldScalar[], fieldKey)}
156
+ </div>
157
+ </div>
158
+ ))}
159
+ </article>
160
+ );
161
+ }
162
+
163
+ function renderValue(value: MetafieldValue, fieldKey: string, label: string, type: string): JSX.Element {
164
+ if (isEntry(value)) {
165
+ return <EntryCard entry={value} />;
166
+ }
167
+
168
+ if (Array.isArray(value)) {
169
+ if (value.length > 0 && value.every((item) => isEntry(item))) {
170
+ return (
171
+ <div className="core-metafields__entry-grid">
172
+ {(value as MetaobjectEntry[]).map((entry) => (
173
+ <EntryCard key={`${entry.type}:${entry.p_id}`} entry={entry} />
174
+ ))}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <ul className="core-metafields__list">
181
+ {(value as MetafieldScalar[]).map((item, index) => (
182
+ // Presented lists are value snapshots; the index is stable per render.
183
+ // eslint-disable-next-line react/no-array-index-key
184
+ <li key={index} className="core-metafields__list-item">
185
+ {typeof item === 'boolean' ? (item ? 'Yes' : 'No') : String(item)}
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ );
190
+ }
191
+
192
+ return renderScalar(value as MetafieldScalar, fieldKey, label, type);
193
+ }
194
+
195
+ export function ProductMetafields({ product, definitions }: ProductMetafieldsProps): JSX.Element | null {
196
+ const metafields = product.metafields && typeof product.metafields === 'object' ? product.metafields : {};
197
+ const visible = Object.entries(metafields).filter(([, value]) => isVisibleValue(value as MetafieldValue));
198
+
199
+ if (visible.length === 0) return null;
200
+
201
+ return (
202
+ <section className="core-metafields" aria-label="Product details">
203
+ {visible.map(([key, value]) => (
204
+ <div className="core-metafields__field" key={key}>
205
+ <h3 className="core-metafields__label">{labelFor(key, definitions)}</h3>
206
+ <div className="core-metafields__value">
207
+ {renderValue(value as MetafieldValue, key, labelFor(key, definitions), typeFor(key, definitions))}
208
+ </div>
209
+ </div>
210
+ ))}
211
+ </section>
212
+ );
213
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pulls in Next.js's own published global types (RequestInit.next, image
3
+ * types, …) from the `next` PEER dependency — the same
4
+ * `/// <reference types="next" />` every Next app gets via next-env.d.ts.
5
+ *
6
+ * The kit runs inside Next apps, so depending on Next's own type package is
7
+ * correct. Depending on the storefront's next-env.d.ts (as the kit implicitly
8
+ * did before extraction — the file the kit free-rode on when 0.1.0 shipped
9
+ * broken) is what must never happen again. This file is that dependency,
10
+ * written down.
11
+ */
12
+ /// <reference types="next" />
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/usequeek/queek-storefront.git",
10
- "directory": "packages/theme-kit"
9
+ "url": "git+https://github.com/usequeek/theme-kit.git"
11
10
  },
12
11
  "publishConfig": {
13
12
  "access": "public"
@@ -38,5 +37,22 @@
38
37
  "react-markdown": "^10.0.0",
39
38
  "remark-gfm": "^4.0.0",
40
39
  "html-to-image": "^1.11.0"
40
+ },
41
+ "devDependencies": {
42
+ "@queekai/client-sdk": "^0.3.0",
43
+ "@types/node": "^20",
44
+ "@types/react": "^19",
45
+ "@types/react-dom": "^19",
46
+ "next": "^16.0.0",
47
+ "react": "^19.2.3",
48
+ "react-dom": "^19.2.3",
49
+ "tsx": "^4.21.0",
50
+ "typescript": "^5",
51
+ "vitest": "^3.2.4"
52
+ },
53
+ "scripts": {
54
+ "test": "vitest run",
55
+ "typecheck": "tsc --noEmit",
56
+ "verify-package": "tsx scripts/verify-package.ts"
41
57
  }
42
58
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Consumes the kit the way an OUTSIDER does, from a packed tarball.
3
+ *
4
+ * WHY THIS EXISTS. @usequeek/theme-kit@0.1.0 was published and was broken for
5
+ * every external consumer:
6
+ *
7
+ * node_modules/@usequeek/theme-kit/provider.tsx(58,5):
8
+ * error TS2503: Cannot find namespace 'JSX'.
9
+ *
10
+ * 42 modules annotated returns as `JSX.Element`, relying on a GLOBAL JSX
11
+ * namespace that came from the storefront app's `next-env.d.ts` — not from
12
+ * React. Inside the storefront repo the kit free-rode on that file, so tsc,
13
+ * the tests, the build and the render smoke were ALL green. The workspace
14
+ * supplies exactly the file whose absence is the bug, which is why no in-repo
15
+ * check could ever have found it. (Every module now does
16
+ * `import type { JSX } from 'react'`, and this script proves it.)
17
+ *
18
+ * It packs the CURRENT SOURCE rather than installing the published version, so
19
+ * it fails before a broken version ships instead of after. Installing from the
20
+ * registry would only ever test the last release.
21
+ */
22
+ import { execFileSync } from 'node:child_process';
23
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ // The kit package is `"type": "module"`, so `__dirname` does not exist here.
29
+ // (The storefront copy of this script could use it only because that package
30
+ // is CJS.)
31
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
32
+ const KIT = ROOT;
33
+
34
+ function run(cmd: string, args: string[], cwd: string): string {
35
+ return execFileSync(cmd, args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
36
+ }
37
+
38
+ function main(): void {
39
+ const sandbox = mkdtempSync(join(tmpdir(), 'kit-consumer-'));
40
+ let failed = false;
41
+
42
+ try {
43
+ const kitPkg = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf-8')) as {
44
+ name: string; version: string; peerDependencies?: Record<string, string>;
45
+ };
46
+
47
+ console.log(`verify-package: packing ${kitPkg.name}@${kitPkg.version} and consuming it as an outsider`);
48
+ run('npm', ['pack', '--pack-destination', sandbox], KIT);
49
+ const tarball = readdirSync(sandbox).find((f) => f.endsWith('.tgz'));
50
+ if (!tarball) throw new Error('npm pack produced no tarball');
51
+
52
+ // Peers are declared by the kit; pin the ones the fixture needs to compile.
53
+ const peers = kitPkg.peerDependencies ?? {};
54
+ writeFileSync(join(sandbox, 'package.json'), JSON.stringify({
55
+ name: 'kit-consumer', private: true, version: '1.0.0',
56
+ dependencies: {
57
+ [kitPkg.name]: `file:./${tarball}`,
58
+ '@queekai/client-sdk': peers['@queekai/client-sdk'] ?? '*',
59
+ next: '^16.0.0', react: '^19.0.0', 'react-dom': '^19.0.0',
60
+ },
61
+ devDependencies: { typescript: '^5', '@types/react': '^19', '@types/react-dom': '^19', '@types/node': '^22' },
62
+ }, null, 2));
63
+
64
+ // `bundler` resolution + no ambient Next types — exactly what a theme
65
+ // developer's own project looks like. NOT this repo's tsconfig.
66
+ writeFileSync(join(sandbox, 'tsconfig.json'), JSON.stringify({
67
+ compilerOptions: {
68
+ target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], jsx: 'react-jsx',
69
+ module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true,
70
+ skipLibCheck: true, esModuleInterop: true, isolatedModules: true,
71
+ },
72
+ include: ['app/**/*.ts', 'app/**/*.tsx'],
73
+ }, null, 2));
74
+
75
+ mkdirSync(join(sandbox, 'app'), { recursive: true });
76
+ writeFileSync(join(sandbox, 'app/theme.tsx'), readFileSync(join(ROOT, 'tests/fixtures/external-theme.tsx'), 'utf-8'));
77
+
78
+ console.log('verify-package: installing from the tarball…');
79
+ run('npm', ['install', '--no-audit', '--no-fund'], sandbox);
80
+
81
+ console.log('verify-package: typechecking a real theme against it…');
82
+ try {
83
+ run(join(sandbox, 'node_modules/.bin/tsc'), ['--noEmit'], sandbox);
84
+ console.log(`\n✓ ${kitPkg.name} is consumable from outside this repo.`);
85
+ } catch (error) {
86
+ const out = (error as { stdout?: string; stderr?: string });
87
+ console.error('\n✗ the packed kit does NOT work for an external consumer:\n');
88
+ console.error((out.stdout ?? '') + (out.stderr ?? ''));
89
+ console.error('Errors inside node_modules/@usequeek/theme-kit are PACKAGE bugs — the kit is');
90
+ console.error('relying on something only this repo provides. Errors in app/theme.tsx mean the');
91
+ console.error('fixture drifted from the real API.');
92
+ failed = true;
93
+ }
94
+ } catch (error) {
95
+ console.error('✗ verify-package failed to run:', (error as Error).message);
96
+ failed = true;
97
+ } finally {
98
+ rmSync(sandbox, { recursive: true, force: true });
99
+ }
100
+
101
+ process.exit(failed ? 1 : 0);
102
+ }
103
+
104
+ main();
@@ -39,11 +39,40 @@ const platformFetch: typeof fetch = (input, init) => {
39
39
  if (init?.headers) {
40
40
  const headers = new Headers(init.headers);
41
41
  headers.set('X-Platform', getActivePlatform());
42
- return fetch(input, { ...init, headers });
42
+ return fetch(input, { ...init, headers }).then(captureRequestId);
43
43
  }
44
- return fetch(input, init);
44
+ return fetch(input, init).then(captureRequestId);
45
45
  };
46
46
 
47
+ /**
48
+ * Every API response carries a backend request id. The SDK's `request()` returns
49
+ * only the parsed body, so this wrapper — the one place every SDK call's raw
50
+ * Response passes through — is where it can be read at all. Kept as the last-seen
51
+ * value: the storefront has no error-reporting integration (no Sentry), so the
52
+ * only consumer today is the checkout failure log, which is exactly the report a
53
+ * customer support ticket needs to be traceable to a backend request.
54
+ */
55
+ const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id', 'request-id'];
56
+
57
+ let lastRequestId: string | null = null;
58
+
59
+ function captureRequestId(response: Response): Response {
60
+ for (const name of REQUEST_ID_HEADERS) {
61
+ const value = response.headers.get(name);
62
+ if (value) {
63
+ lastRequestId = value;
64
+ break;
65
+ }
66
+ }
67
+
68
+ return response;
69
+ }
70
+
71
+ /** The backend request id of the most recent SDK call, when it sent one. */
72
+ export function getLastRequestId(): string | null {
73
+ return lastRequestId;
74
+ }
75
+
47
76
  let cachedClient: QueekClientInstance | null = null;
48
77
  let cachedSlug: string | null = null;
49
78
 
@@ -105,6 +105,11 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
105
105
  const product = /^\/products\/([^/]+)(?:\/related)?$/.exec(path);
106
106
  if (product) return policy(60, 'products', `product:${product[1]}`);
107
107
 
108
+ // Product metafield definitions (the labels/types the core-owned
109
+ // <ProductMetafields /> section renders values under). Merchant-edited,
110
+ // shared across visitors — cached like pages, busted by revalidation.
111
+ if (path === '/metafield-definitions') return policy(300, 'metafields');
112
+
108
113
  return null;
109
114
  }
110
115
 
@@ -0,0 +1,69 @@
1
+ // A real theme, written the way an external developer would, against the
2
+ // PUBLISHED package only — no access to the storefront repo.
3
+ import type { JSX } from 'react';
4
+ import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
5
+ import type { Product } from '@usequeek/theme-kit/types/product';
6
+ import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
7
+ import { useProducts } from '@usequeek/theme-kit/hooks/use-products';
8
+ import { useStorefront } from '@usequeek/theme-kit/provider';
9
+ import { useHref } from '@usequeek/theme-kit/hooks/use-href';
10
+ import { formatMoney } from '@usequeek/theme-kit/utils/format';
11
+ import { Image } from '@usequeek/theme-kit/components/image';
12
+ import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
13
+
14
+ export function Header({ menu, showCart }: HeaderProps): JSX.Element {
15
+ const { vendor } = useStorefront();
16
+ const { items } = useCart();
17
+ const href = useHref();
18
+
19
+ return (
20
+ <header>
21
+ <a href={href('/')}>{vendor?.name}</a>
22
+ {menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
23
+ {showCart ? <span>{items.length}</span> : null}
24
+ </header>
25
+ );
26
+ }
27
+
28
+ export function Footer({ copyright }: FooterProps): JSX.Element {
29
+ return <footer>{copyright}</footer>;
30
+ }
31
+
32
+ export function ProductCard({ product }: { product: Product }): JSX.Element {
33
+ const { addProduct } = useCart();
34
+
35
+ return (
36
+ <article>
37
+ <Image src={product.media.thumbnail ?? ''} alt={product.title} />
38
+ <h3>{product.title}</h3>
39
+ <span>{formatMoney(product.pricing.sale_amount, product.currency)}</span>
40
+ <button type="button" onClick={() => addProduct(product, 1)}>Add to cart</button>
41
+ </article>
42
+ );
43
+ }
44
+
45
+ export function Grid(): JSX.Element {
46
+ const { products, isLoading } = useProducts();
47
+ if (isLoading) return <p>Loading…</p>;
48
+
49
+ return <div>{products.map((p) => <ProductCard key={p.id} product={p} />)}</div>;
50
+ }
51
+
52
+ export const theme: Pick<ThemeModule, 'Header' | 'Footer'> = { Header, Footer };
53
+
54
+ /**
55
+ * Mounting the theme — the thing an external developer could not do before
56
+ * ThemeMount existed, because the component that does it lived in the
57
+ * storefront app rather than the package.
58
+ */
59
+ export function Preview({ theme: mod, children }: { theme: ThemeModule; children: JSX.Element }): JSX.Element {
60
+ return (
61
+ <ThemeMount
62
+ theme={mod}
63
+ headerProps={{ announcement: null, logo: null, menu: [], showSearch: true, showCart: true, showAccount: true, showOffers: true }}
64
+ footerProps={{ copyright: '© Demo Store' }}
65
+ >
66
+ {children}
67
+ </ThemeMount>
68
+ );
69
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { renderMarkdown } from '../utils/markdown';
5
+
6
+ /**
7
+ * Backend CT16 (queek_backend/.agent/frontend-chat/sessions/storefront-contact-block/chat.json):
8
+ * a heading immediately followed by other content, with no blank line between them, gets
9
+ * swallowed whole into the heading — `utils/markdown.tsx` used to treat an entire
10
+ * blank-line-delimited block as heading text the moment it matched a heading prefix. Real
11
+ * merchant copy (Jhema Wears' short_description) writes "### Our Offerings\n- item\n- item"
12
+ * with no blank line, which is common, valid markdown — the list must not disappear into the h3.
13
+ */
14
+
15
+ function html(markdown: string): string {
16
+ return renderToStaticMarkup(React.createElement(React.Fragment, null, ...renderMarkdown(markdown)));
17
+ }
18
+
19
+ describe('renderMarkdown heading + trailing-content handling', () => {
20
+ it('a heading immediately followed by a list (no blank line) renders both, not one heading swallowing the list', () => {
21
+ const out = html(
22
+ '### Our Offerings\n- Latest fashion apparel for men, women, and children\n- Accessories to complement every outfit',
23
+ );
24
+
25
+ expect(out).toContain('<h3');
26
+ expect(out).toContain('Our Offerings');
27
+ expect(out).toContain('<ul');
28
+ expect(out).toContain('<li>Latest fashion apparel for men, women, and children</li>');
29
+ expect(out).toContain('<li>Accessories to complement every outfit</li>');
30
+ expect(out).not.toMatch(/<h3[^>]*>[^<]*Latest fashion/);
31
+ });
32
+
33
+ it('a heading immediately followed by a paragraph (no blank line) renders both, not one heading swallowing the text', () => {
34
+ const out = html('## Our Story\nWe hand-pick every piece.');
35
+
36
+ expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
37
+ });
38
+
39
+ it('a heading with nothing after it still renders alone (regression guard)', () => {
40
+ expect(html('## Our Story')).toBe('<h2>Our Story</h2>');
41
+ });
42
+
43
+ it('a heading followed by a blank-line-separated paragraph still renders as two blocks (regression guard)', () => {
44
+ const out = html('## Our Story\n\nWe hand-pick every piece.');
45
+ expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
46
+ });
47
+ });
@@ -0,0 +1,154 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ProductMetafields } from '../components/product-metafields';
5
+ import type { MetafieldValue, Product } from '../types/product';
6
+
7
+ function makeProduct(metafields?: Record<string, MetafieldValue>): Product {
8
+ return {
9
+ shop_id: 'shop-1',
10
+ id: 'product-1',
11
+ p_id: 1,
12
+ title: 'Test product',
13
+ slug: 'test-product',
14
+ excerpt: null,
15
+ description: null,
16
+ kind: 'physical',
17
+ status: 'active',
18
+ barcode: null,
19
+ currency: 'NGN',
20
+ pricing: {
21
+ base_amount: 1000,
22
+ sale_amount: 800,
23
+ compare_at_amount: 1000,
24
+ discount_amount: 200,
25
+ discount_percent: 20,
26
+ is_price_from: false,
27
+ tax_inclusive: false,
28
+ price_range: { min_amount: 800, max_amount: 800 },
29
+ },
30
+ shop: { id: 'shop-1', name: 'Shop', slug: 'shop', logo: null, rating: 0, rating_count: 0 },
31
+ media: { thumbnail: null, image: null },
32
+ inventory: { in_stock: true, tracking: false, quantity: 5 },
33
+ variants_count: 0,
34
+ review_summary: { rating: 0, review_count: 0 },
35
+ flags: { featured: false, is_marketplace: false, is_wholesale: false, is_new: false },
36
+ categories: [],
37
+ options: [],
38
+ variants: [],
39
+ addons: [],
40
+ reviews: [],
41
+ metafields,
42
+ created_at: null,
43
+ updated_at: null,
44
+ };
45
+ }
46
+
47
+ function render(metafields?: Record<string, MetafieldValue>, definitions?: Record<string, { name: string; type: string }>): string {
48
+ return renderToStaticMarkup(React.createElement(ProductMetafields, { product: makeProduct(metafields), definitions }));
49
+ }
50
+
51
+ describe('ProductMetafields', () => {
52
+ it('renders nothing when the product has no metafields', () => {
53
+ expect(render(undefined)).toBe('');
54
+ expect(render({})).toBe('');
55
+ });
56
+
57
+ it('renders nothing when every value is empty', () => {
58
+ expect(render({ fabric: '', tags: [] })).toBe('');
59
+ });
60
+
61
+ it('renders text with the definition label', () => {
62
+ const html = render(
63
+ { fabric: 'Ankara cotton' },
64
+ { fabric: { name: 'Fabric', type: 'single_line_text' } },
65
+ );
66
+
67
+ expect(html).toContain('core-metafields');
68
+ expect(html).toContain('Fabric');
69
+ expect(html).toContain('Ankara cotton');
70
+ expect(html).toContain('<p');
71
+ });
72
+
73
+ it('falls back to a humanised key label for unknown handles', () => {
74
+ const html = render({ care_instructions: 'Hand wash only' });
75
+
76
+ expect(html).toContain('Care instructions');
77
+ expect(html).toContain('Hand wash only');
78
+ });
79
+
80
+ it('renders multi_line and rich text preformatted', () => {
81
+ expect(render({ notes: 'line one\nline two' }, { notes: { name: 'Notes', type: 'multi_line_text' } })).toContain('<pre');
82
+ expect(render({ story: '<p>rich</p>' }, { story: { name: 'Story', type: 'rich_text' } })).toContain('<pre');
83
+ });
84
+
85
+ it('renders scalar lists as bullets', () => {
86
+ const html = render({ tags: ['cotton', 'handmade'] }, { tags: { name: 'Tags', type: 'list.single_line_text' } });
87
+
88
+ expect(html).toContain('<ul');
89
+ expect(html).toContain('<li');
90
+ expect(html).toContain('cotton');
91
+ expect(html).toContain('handmade');
92
+ });
93
+
94
+ it('renders booleans as Yes/No', () => {
95
+ expect(render({ gift: true }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>Yes<');
96
+ expect(render({ gift: false }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>No<');
97
+ });
98
+
99
+ it('renders dates localised instead of raw ISO', () => {
100
+ const html = render({ drop: '2026-09-20' }, { drop: { name: 'Drop date', type: 'date' } });
101
+
102
+ expect(html).not.toContain('2026-09-20');
103
+ expect(html).toContain('2026');
104
+ });
105
+
106
+ it('renders urls as links', () => {
107
+ const html = render({ lookbook: 'https://example.com/look' }, { lookbook: { name: 'Lookbook', type: 'url' } });
108
+
109
+ expect(html).toContain('<a');
110
+ expect(html).toContain('href="https://example.com/look"');
111
+ });
112
+
113
+ it('renders media_id as an image', () => {
114
+ const html = render({ swatch: 'https://cdn.example.com/swatch.jpg' }, { swatch: { name: 'Swatch', type: 'media_id' } });
115
+
116
+ expect(html).toContain('<img');
117
+ expect(html).toContain('src="https://cdn.example.com/swatch.jpg"');
118
+ });
119
+
120
+ it('renders a linked entry as a card with its fields', () => {
121
+ const html = render(
122
+ {
123
+ designer: {
124
+ p_id: 1000,
125
+ type: 'designer',
126
+ handle: 'adaeze',
127
+ display_name: 'Adaeze',
128
+ fields: { name: 'Adaeze', instagram: '@adaeze' },
129
+ },
130
+ },
131
+ { designer: { name: 'Designer', type: 'metaobject_reference' } },
132
+ );
133
+
134
+ expect(html).toContain('core-metafields__entry-card');
135
+ expect(html).toContain('Adaeze');
136
+ expect(html).toContain('@adaeze');
137
+ });
138
+
139
+ it('renders a list of entries as a grid of cards', () => {
140
+ const html = render(
141
+ {
142
+ ingredients: [
143
+ { p_id: 1, type: 'ingredient', handle: 'shea', display_name: 'Shea', fields: { name: 'Shea' } },
144
+ { p_id: 2, type: 'ingredient', handle: 'cocoa', display_name: 'Cocoa', fields: { name: 'Cocoa' } },
145
+ ],
146
+ },
147
+ { ingredients: { name: 'Ingredients', type: 'list.metaobject_reference' } },
148
+ );
149
+
150
+ expect(html).toContain('core-metafields__entry-grid');
151
+ expect(html).toContain('Shea');
152
+ expect(html).toContain('Cocoa');
153
+ });
154
+ });
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const ROOT = resolve(__dirname, '..');
6
+
7
+ /**
8
+ * The barrels re-export by RELATIVE path — the kit carries no `@/` alias, so it
9
+ * resolves when imported from outside this repo. `./types/theme` and
10
+ * `@usequeek/theme-kit/types/theme` name the same module; normalise to the second.
11
+ */
12
+ function barrelModules(file: string): Set<string> {
13
+ const source = readFileSync(join(ROOT, file), 'utf8');
14
+ return new Set(
15
+ [...source.matchAll(/from '\.\/([^']+)'/g)].map((m) => m[1]),
16
+ );
17
+ }
18
+
19
+ /**
20
+ * `index.ts` + `client.ts` declare the surface a theme may build against — the
21
+ * set that ships as `@usequeek/theme-kit`.
22
+ *
23
+ * This is the kit-internal half of the contract: every module the barrels
24
+ * declare must exist. (The storefront repo holds the other half —
25
+ * `core-public-surface.test.ts` there asserts the barrels cover every module
26
+ * the themes actually import.)
27
+ */
28
+ describe('core public surface (kit-internal)', () => {
29
+ const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
30
+
31
+ it('declares nothing that no longer exists', () => {
32
+ const missing = [...declared].filter(
33
+ (mod) => !['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(join(ROOT, mod + ext))),
34
+ );
35
+
36
+ expect(missing).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { request } from '../sdk/client';
5
+
6
+ /**
7
+ * The storefront must ONLY ever call the `/v1/client/*` backend scope. That scope's
8
+ * ResolveClientContext middleware resolves the vendor AND forces the platform
9
+ * (storefront/instore_qr) — so prices are always the storefront's, never the
10
+ * marketplace commission-inclusive price. Legacy v1 routes (/products, /vendors/*,
11
+ * /categories, ...) bypass that middleware → wrong platform → wrong price.
12
+ *
13
+ * This guard prevents that whole class of bug from ever returning.
14
+ */
15
+ describe('storefront API scope guard', () => {
16
+ afterEach(() => vi.unstubAllGlobals());
17
+
18
+ it('refuses any backend route outside /v1/client', async () => {
19
+ for (const badPath of ['/products/abc', '/vendors/foo/products', '/categories', '/vendors/foo/collections/x/products']) {
20
+ await expect(request('http://api/api/v1', badPath)).rejects.toThrow(/only call \/v1\/client/);
21
+ }
22
+ });
23
+
24
+ it('allows /client/* routes through', async () => {
25
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) });
26
+ vi.stubGlobal('fetch', fetchMock);
27
+
28
+ await expect(request('http://api/api/v1', '/client/store/products')).resolves.toEqual({ data: [] });
29
+ expect(fetchMock).toHaveBeenCalledOnce();
30
+ });
31
+
32
+ it('does not reintroduce a raw v1-route client (createServerClient)', () => {
33
+ const root = process.cwd();
34
+ const sdk = readFileSync(join(root, 'sdk/client.ts'), 'utf8');
35
+ expect(sdk).not.toContain('export function createServerClient');
36
+
37
+ const apiDir = join(root, 'api');
38
+ for (const file of readdirSync(apiDir).filter((name) => name.endsWith('.ts'))) {
39
+ const src = readFileSync(join(apiDir, file), 'utf8');
40
+ expect(src, `${file} must not use the removed createServerClient (v1 routes)`).not.toContain('createServerClient');
41
+ }
42
+ });
43
+ });
package/types/product.ts CHANGED
@@ -157,6 +157,32 @@ export interface ProductFlags {
157
157
  is_new: boolean;
158
158
  }
159
159
 
160
+ /** A scalar metafield value (single_line_text, boolean, date, url, …). */
161
+ export type MetafieldScalar = string | number | boolean;
162
+
163
+ /**
164
+ * One metaobject entry linked through a `metaobject_reference` /
165
+ * `list.metaobject_reference` metafield. Presented by the backend
166
+ * (MetafieldPresenter) as the entry resource minus timestamps — a `draft`
167
+ * entry, or one whose definition is not storefront-visible, is already
168
+ * omitted on client surfaces, so the storefront renders whatever arrives.
169
+ */
170
+ export interface MetaobjectEntry {
171
+ p_id: number;
172
+ type: string;
173
+ handle: string;
174
+ display_name: string;
175
+ /** Flat entry values (definitions never nest references). */
176
+ fields: Record<string, MetafieldScalar | MetafieldScalar[]>;
177
+ }
178
+
179
+ /** A presented product metafield value: scalar, scalar list, or linked entry/entries. */
180
+ export type MetafieldValue =
181
+ | MetafieldScalar
182
+ | MetafieldScalar[]
183
+ | MetaobjectEntry
184
+ | MetaobjectEntry[];
185
+
160
186
  export interface Product {
161
187
  shop_id: string;
162
188
  id: string;
@@ -183,6 +209,9 @@ export interface Product {
183
209
  variants: ProductVariant[];
184
210
  addons: ProductAddon[];
185
211
  reviews: ProductReview[];
212
+ /** Presented product metafields keyed by definition key (incl. linked
213
+ * metaobject entries). Absent when the backend sends none. */
214
+ metafields?: Record<string, MetafieldValue>;
186
215
  created_at: string | null;
187
216
  updated_at: string | null;
188
217
  }
@@ -271,6 +300,8 @@ export interface LegacyProductLike {
271
300
  >;
272
301
  addons?: ProductAddon[];
273
302
  reviews?: ProductReview[];
303
+ /** Raw presented metafields — passed through verbatim by the normalizer. */
304
+ metafields?: Record<string, unknown>;
274
305
  review_summary?: Partial<ProductReviewSummary>;
275
306
  created_at?: string | null;
276
307
  updated_at?: string | null;
package/types/theme.ts CHANGED
@@ -63,6 +63,13 @@ export interface CollectionPageProps {
63
63
 
64
64
  export interface ProductPageProps {
65
65
  product: Product;
66
+ /**
67
+ * Product metafield definitions keyed by metafield key — the labels (and
68
+ * types) the core-owned `<ProductMetafields />` section renders values
69
+ * under. OPTIONAL so external themes that destructure `{ product }` keep
70
+ * compiling; unknown keys fall back to a humanised key label.
71
+ */
72
+ metafieldDefinitions?: Record<string, { name: string; type: string }>;
66
73
  }
67
74
 
68
75
  export interface GalleryPageProps {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  LegacyProductLike,
3
+ MetafieldValue,
3
4
  Product,
4
5
  ProductCategory,
5
6
  ProductOption,
@@ -196,6 +197,18 @@ function normalizeCategories(raw: LegacyProductLike): ProductCategory[] {
196
197
  }, []);
197
198
  }
198
199
 
200
+ function normalizeMetafields(raw: LegacyProductLike): Record<string, MetafieldValue> {
201
+ // Explicit whitelist like every other field above: without this the
202
+ // presented metafields (incl. linked metaobject entries) are dropped at
203
+ // runtime and <ProductMetafields /> renders nothing at every call site.
204
+ const source = raw.metafields;
205
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
206
+ return {};
207
+ }
208
+
209
+ return source as Record<string, MetafieldValue>;
210
+ }
211
+
199
212
  export function normalizeProduct(raw: LegacyProductLike): Product {
200
213
  const shopId = String(raw.shop_id ?? raw.vendor_id ?? raw.shop?.id ?? raw.vendor?.id ?? '');
201
214
 
@@ -292,6 +305,7 @@ export function normalizeProduct(raw: LegacyProductLike): Product {
292
305
  : [],
293
306
  addons: raw.addons ?? [],
294
307
  reviews: raw.reviews ?? [],
308
+ metafields: normalizeMetafields(raw),
295
309
  created_at: raw.created_at ?? null,
296
310
  updated_at: raw.updated_at ?? null,
297
311
  };