@usequeek/theme-kit 0.1.5 → 0.1.7
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 +55 -5
- package/client.ts +2 -0
- package/components/default-metaobject-page.tsx +40 -0
- package/components/product-metafields.tsx +226 -0
- package/next-reference.d.ts +12 -0
- package/package.json +19 -3
- package/page-renderer.tsx +55 -1
- package/scripts/verify-package.ts +104 -0
- package/sdk/queek-client.ts +31 -2
- package/sdk/server-store-client.ts +13 -0
- package/shared-blocks/core-blocks.css +63 -0
- package/shared-blocks/index.ts +1 -0
- package/shared-blocks/manifest.ts +5 -0
- package/shared-blocks/metaobjects.tsx +50 -0
- package/tests/default-metaobject-page.test.tsx +56 -0
- package/tests/fixtures/external-theme.tsx +69 -0
- package/tests/manifest-parity.test.ts +54 -0
- package/tests/markdown-render.test.ts +47 -0
- package/tests/metaobjects-block.test.tsx +70 -0
- package/tests/product-metafields.test.tsx +154 -0
- package/tests/public-surface.test.ts +38 -0
- package/tests/storefront-client-scope.test.ts +43 -0
- package/types/block.ts +31 -2
- package/types/product.ts +31 -0
- package/types/theme.ts +27 -2
- package/utils/product-normalizer.ts +14 -0
|
@@ -1790,3 +1790,66 @@
|
|
|
1790
1790
|
font-size: 0.85rem;
|
|
1791
1791
|
color: var(--brand-text-muted, rgba(0, 0, 0, 0.6));
|
|
1792
1792
|
}
|
|
1793
|
+
|
|
1794
|
+
/* ── Metaobjects (content-entries section block) ────────────────── */
|
|
1795
|
+
.core-block-metaobjects__title {
|
|
1796
|
+
margin: 0 0 1.5rem;
|
|
1797
|
+
font-size: clamp(1.25rem, 2.5vw, 1.75rem);
|
|
1798
|
+
font-weight: 600;
|
|
1799
|
+
color: var(--brand-text, #1a1a1a);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
.core-block-metaobjects__list {
|
|
1803
|
+
display: grid;
|
|
1804
|
+
gap: 1.25rem;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
.core-block-metaobjects__list--grid {
|
|
1808
|
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
.core-block-metaobjects__list--list {
|
|
1812
|
+
grid-template-columns: 1fr;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
.core-block-metaobjects__card-link {
|
|
1816
|
+
display: block;
|
|
1817
|
+
color: inherit;
|
|
1818
|
+
text-decoration: none;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
.core-block-metaobjects__card-link:hover .core-metafields__entry-card {
|
|
1822
|
+
border-color: var(--brand-accent, rgba(0, 0, 0, 0.2));
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/* ── Metaobject entry page (DefaultMetaobjectPage) ───────────────── */
|
|
1826
|
+
.core-metaobject-page {
|
|
1827
|
+
margin: 0 auto;
|
|
1828
|
+
padding: clamp(1.5rem, 4vw, 3rem) 1.25rem;
|
|
1829
|
+
max-width: var(--core-block-max-width, 1200px);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
.core-metaobject-page__title {
|
|
1833
|
+
margin: 0 0 2rem;
|
|
1834
|
+
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
|
1835
|
+
font-weight: 700;
|
|
1836
|
+
color: var(--brand-text, #1a1a1a);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
.core-metaobject-page__fields {
|
|
1840
|
+
display: grid;
|
|
1841
|
+
gap: 1.5rem;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
.core-metaobject-page__label {
|
|
1845
|
+
margin: 0 0 0.5rem;
|
|
1846
|
+
font-size: 0.85rem;
|
|
1847
|
+
font-weight: 600;
|
|
1848
|
+
text-transform: uppercase;
|
|
1849
|
+
letter-spacing: 0.03em;
|
|
1850
|
+
color: var(--brand-text-muted, rgba(0, 0, 0, 0.55));
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
.core-metaobject-page__value {
|
|
1854
|
+
color: var(--brand-text, #1a1a1a);
|
|
1855
|
+
}
|
package/shared-blocks/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { CoreContentDefaultBlock } from './content-default';
|
|
|
16
16
|
export { CoreReviewsBlock } from './reviews';
|
|
17
17
|
export { CoreFaqBlock } from './faq';
|
|
18
18
|
export { CoreProductQaBlock } from './product-qa';
|
|
19
|
+
export { CoreMetaobjectsBlock } from './metaobjects';
|
|
19
20
|
|
|
20
21
|
export {
|
|
21
22
|
FRAMEWORK_BLOCK_MANIFEST,
|
|
@@ -46,6 +46,10 @@ export const FRAMEWORK_BLOCK_MANIFEST: Record<string, ThemeManifestVariant[]> =
|
|
|
46
46
|
product_qa: [
|
|
47
47
|
{ id: 'default', label: 'Product Q&A', default: true, purpose: 'Customer questions and vendor answers for this product' },
|
|
48
48
|
],
|
|
49
|
+
metaobjects: [
|
|
50
|
+
{ id: 'grid', label: 'Grid', default: true, purpose: 'Entries of a chosen content type (designers, ingredients, …) in a responsive grid' },
|
|
51
|
+
{ id: 'list', label: 'List', purpose: 'Entries of a chosen content type as a vertical list' },
|
|
52
|
+
],
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -65,6 +69,7 @@ export const FRAMEWORK_OWNED_TYPES = new Set<string>([
|
|
|
65
69
|
'reviews',
|
|
66
70
|
'faq',
|
|
67
71
|
'product_qa',
|
|
72
|
+
'metaobjects',
|
|
68
73
|
]);
|
|
69
74
|
|
|
70
75
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { JSX } from 'react';
|
|
4
|
+
import type { MetaobjectsResolved } from '../types/block';
|
|
5
|
+
import { EntryCard } from '../components/product-metafields';
|
|
6
|
+
|
|
7
|
+
export interface CoreMetaobjectsBlockProps extends MetaobjectsResolved {
|
|
8
|
+
title?: string | null;
|
|
9
|
+
layout?: 'grid' | 'list';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Framework-owned "custom content" section — a merchant-picked metaobject
|
|
14
|
+
* type rendered as a grid or list of entry cards, reusing the same
|
|
15
|
+
* `EntryCard` a product's linked-entry metafields render. Renders nothing
|
|
16
|
+
* when the type is unknown, hidden, or has no active entries (the server
|
|
17
|
+
* seeds `entries: []` for all three cases — see `hydrateProductBlocks` in
|
|
18
|
+
* the storefront). Each card links to `/{type}/{handle}` only when the
|
|
19
|
+
* definition opted into entry pages (`has_pages`); otherwise it's inert.
|
|
20
|
+
*/
|
|
21
|
+
export function CoreMetaobjectsBlock({
|
|
22
|
+
title,
|
|
23
|
+
layout = 'grid',
|
|
24
|
+
entries,
|
|
25
|
+
definition,
|
|
26
|
+
}: CoreMetaobjectsBlockProps): JSX.Element | null {
|
|
27
|
+
const safeEntries = Array.isArray(entries) ? entries : [];
|
|
28
|
+
if (safeEntries.length === 0) return null;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<section className={`core-block core-block-metaobjects core-block-metaobjects--${layout}`}>
|
|
32
|
+
{title ? <h2 className="core-block-metaobjects__title">{title}</h2> : null}
|
|
33
|
+
<div className={`core-block-metaobjects__list core-block-metaobjects__list--${layout}`}>
|
|
34
|
+
{safeEntries.map((entry) =>
|
|
35
|
+
definition?.has_pages ? (
|
|
36
|
+
<a
|
|
37
|
+
key={`${entry.type}:${entry.p_id}`}
|
|
38
|
+
className="core-block-metaobjects__card-link"
|
|
39
|
+
href={`/${entry.type}/${entry.handle}`}
|
|
40
|
+
>
|
|
41
|
+
<EntryCard entry={entry} />
|
|
42
|
+
</a>
|
|
43
|
+
) : (
|
|
44
|
+
<EntryCard key={`${entry.type}:${entry.p_id}`} entry={entry} />
|
|
45
|
+
),
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { DefaultMetaobjectPage } from '../components/default-metaobject-page';
|
|
5
|
+
import type { MetaobjectEntry } from '../types/product';
|
|
6
|
+
import type { MetaobjectPageDefinition } from '../types/theme';
|
|
7
|
+
|
|
8
|
+
function render(fields: MetaobjectEntry['fields'], fieldDefs: MetaobjectPageDefinition['fields']): string {
|
|
9
|
+
const entry: MetaobjectEntry = {
|
|
10
|
+
p_id: 1,
|
|
11
|
+
type: 'designer',
|
|
12
|
+
handle: 'adaeze',
|
|
13
|
+
display_name: 'Adaeze',
|
|
14
|
+
fields,
|
|
15
|
+
};
|
|
16
|
+
const definition: MetaobjectPageDefinition = { type: 'designer', name: 'Designer', fields: fieldDefs };
|
|
17
|
+
|
|
18
|
+
return renderToStaticMarkup(React.createElement(DefaultMetaobjectPage, { entry, definition }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('DefaultMetaobjectPage', () => {
|
|
22
|
+
it('renders the entry display name as the title', () => {
|
|
23
|
+
const html = render({}, []);
|
|
24
|
+
expect(html).toContain('core-metaobject-page__title');
|
|
25
|
+
expect(html).toContain('Adaeze');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders a text field with its definition label', () => {
|
|
29
|
+
const html = render({ bio: 'Lagos-based designer' }, [{ key: 'bio', name: 'Biography', type: 'single_line_text' }]);
|
|
30
|
+
expect(html).toContain('Biography');
|
|
31
|
+
expect(html).toContain('Lagos-based designer');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('falls back to a humanised key label when no definition field matches', () => {
|
|
35
|
+
const html = render({ years_active: '10' }, []);
|
|
36
|
+
expect(html).toContain('Years active');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders a boolean field as Yes/No', () => {
|
|
40
|
+
const html = render({ verified: true }, [{ key: 'verified', name: 'Verified', type: 'boolean' }]);
|
|
41
|
+
expect(html).toContain('>Yes<');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders a url field as a link', () => {
|
|
45
|
+
const html = render({ portfolio: 'https://example.com/adaeze' }, [{ key: 'portfolio', name: 'Portfolio', type: 'url' }]);
|
|
46
|
+
expect(html).toContain('<a');
|
|
47
|
+
expect(html).toContain('href="https://example.com/adaeze"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders a list field as bullets', () => {
|
|
51
|
+
const html = render({ tags: ['couture', 'bridal'] }, [{ key: 'tags', name: 'Tags', type: 'list.single_line_text' }]);
|
|
52
|
+
expect(html).toContain('<ul');
|
|
53
|
+
expect(html).toContain('couture');
|
|
54
|
+
expect(html).toContain('bridal');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FRAMEWORK_BLOCK_MANIFEST, FRAMEWORK_OWNED_TYPES } from '../shared-blocks/manifest';
|
|
3
|
+
import type { BlockType } from '../types/block';
|
|
4
|
+
import type { FrameworkBlockType } from '../types/theme';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Three places declare "this block type is framework-owned" and must never
|
|
8
|
+
* drift: `types/theme.ts` `FrameworkBlockType`, `shared-blocks/manifest.ts`
|
|
9
|
+
* `FRAMEWORK_OWNED_TYPES`, and `page-renderer.tsx`'s switch (checked here by
|
|
10
|
+
* proxy — every framework-owned type must at least have a manifest entry, or
|
|
11
|
+
* a theme's Section Library drawer would offer a block the renderer has no
|
|
12
|
+
* variant list for).
|
|
13
|
+
*/
|
|
14
|
+
describe('framework-owned block manifest parity', () => {
|
|
15
|
+
// Mirrors `FrameworkBlockType` in types/theme.ts — TypeScript enforces the
|
|
16
|
+
// literal union is exhaustive against `BlockType` here (compile error if
|
|
17
|
+
// one is added without the other), so this list doubles as the type check.
|
|
18
|
+
const frameworkBlockTypes: FrameworkBlockType[] = [
|
|
19
|
+
'divider',
|
|
20
|
+
'embed',
|
|
21
|
+
'video',
|
|
22
|
+
'table',
|
|
23
|
+
'button',
|
|
24
|
+
'image',
|
|
25
|
+
'reviews',
|
|
26
|
+
'faq',
|
|
27
|
+
'callout',
|
|
28
|
+
'quote',
|
|
29
|
+
'product_qa',
|
|
30
|
+
'metaobjects',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
it('FrameworkBlockType and FRAMEWORK_OWNED_TYPES name the same set', () => {
|
|
34
|
+
expect(new Set(frameworkBlockTypes)).toEqual(FRAMEWORK_OWNED_TYPES);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('every framework-owned type (except content, handled specially) has a manifest entry', () => {
|
|
38
|
+
for (const type of FRAMEWORK_OWNED_TYPES) {
|
|
39
|
+
expect(FRAMEWORK_BLOCK_MANIFEST[type], `${type} missing from FRAMEWORK_BLOCK_MANIFEST`).toBeDefined();
|
|
40
|
+
expect(FRAMEWORK_BLOCK_MANIFEST[type].length).toBeGreaterThan(0);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('metaobjects is a real BlockType', () => {
|
|
45
|
+
const type: BlockType = 'metaobjects';
|
|
46
|
+
expect(FRAMEWORK_OWNED_TYPES.has(type)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('metaobjects manifest declares grid (default) and list variants', () => {
|
|
50
|
+
const variants = FRAMEWORK_BLOCK_MANIFEST.metaobjects;
|
|
51
|
+
expect(variants.map((v) => v.id)).toEqual(expect.arrayContaining(['grid', 'list']));
|
|
52
|
+
expect(variants.find((v) => v.default)?.id).toBe('grid');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -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,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { CoreMetaobjectsBlock } from '../shared-blocks/metaobjects';
|
|
5
|
+
import type { CoreMetaobjectsBlockProps } from '../shared-blocks/metaobjects';
|
|
6
|
+
import type { MetaobjectEntry } from '../types/product';
|
|
7
|
+
|
|
8
|
+
function makeEntry(overrides: Partial<MetaobjectEntry> = {}): MetaobjectEntry {
|
|
9
|
+
return {
|
|
10
|
+
p_id: 1,
|
|
11
|
+
type: 'designer',
|
|
12
|
+
handle: 'adaeze',
|
|
13
|
+
display_name: 'Adaeze',
|
|
14
|
+
fields: { bio: 'Lagos-based designer' },
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render(props: Partial<CoreMetaobjectsBlockProps> = {}): string {
|
|
20
|
+
return renderToStaticMarkup(
|
|
21
|
+
React.createElement(CoreMetaobjectsBlock, {
|
|
22
|
+
entries: [makeEntry()],
|
|
23
|
+
definition: { type: 'designer', name: 'Designer', has_pages: false },
|
|
24
|
+
...props,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('CoreMetaobjectsBlock', () => {
|
|
30
|
+
it('renders nothing when there are no entries', () => {
|
|
31
|
+
expect(render({ entries: [] })).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a grid by default with entry cards', () => {
|
|
35
|
+
const html = render();
|
|
36
|
+
expect(html).toContain('core-block-metaobjects--grid');
|
|
37
|
+
expect(html).toContain('core-block-metaobjects__list--grid');
|
|
38
|
+
expect(html).toContain('core-metafields__entry-card');
|
|
39
|
+
expect(html).toContain('Adaeze');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders a list layout when requested', () => {
|
|
43
|
+
const html = render({ layout: 'list' });
|
|
44
|
+
expect(html).toContain('core-block-metaobjects--list');
|
|
45
|
+
expect(html).toContain('core-block-metaobjects__list--list');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders the title when provided', () => {
|
|
49
|
+
expect(render({ title: 'Our designers' })).toContain('Our designers');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not link entries when the definition has no pages', () => {
|
|
53
|
+
const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: false } });
|
|
54
|
+
expect(html).not.toContain('<a ');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('links each entry to /{type}/{handle} when the definition has pages', () => {
|
|
58
|
+
const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: true } });
|
|
59
|
+
expect(html).toContain('<a ');
|
|
60
|
+
expect(html).toContain('href="/designer/adaeze"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders multiple entries', () => {
|
|
64
|
+
const html = render({
|
|
65
|
+
entries: [makeEntry(), makeEntry({ p_id: 2, handle: 'chidi', display_name: 'Chidi' })],
|
|
66
|
+
});
|
|
67
|
+
expect(html).toContain('Adaeze');
|
|
68
|
+
expect(html).toContain('Chidi');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -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
|
+
});
|