@usequeek/theme-kit 0.1.23 → 0.1.24
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/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +164 -21
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-switcher.tsx +1 -0
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/menu-placement.test.ts +0 -63
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { PoweredByQueek } from '../components/powered-by-queek';
|
|
5
|
-
import { StorefrontContext } from '../provider';
|
|
6
|
-
|
|
7
|
-
const BASE = 'https://usequeek.com/business?utm_source=powered_by&utm_medium=storefront';
|
|
8
|
-
|
|
9
|
-
function render(props?: { className?: string; hidden?: boolean }, vendor?: { id: string; slug: string | null }): string {
|
|
10
|
-
const element = React.createElement(PoweredByQueek, props);
|
|
11
|
-
|
|
12
|
-
if (!vendor) return renderToStaticMarkup(element);
|
|
13
|
-
|
|
14
|
-
return renderToStaticMarkup(
|
|
15
|
-
React.createElement(StorefrontContext.Provider, { value: { vendor } as never }, element),
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('PoweredByQueek', () => {
|
|
20
|
-
it('links to the Queek business page with the attribution contract intact', () => {
|
|
21
|
-
const html = render();
|
|
22
|
-
|
|
23
|
-
expect(html).toContain('<a');
|
|
24
|
-
expect(html).toContain(`href="${BASE}"`);
|
|
25
|
-
expect(html).toContain('target="_blank"');
|
|
26
|
-
expect(html).toContain('rel="noopener noreferrer"');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('tags the link with the store slug as utm_campaign', () => {
|
|
30
|
-
const html = render(undefined, { id: 'uuid-1', slug: 'demo-store' });
|
|
31
|
-
|
|
32
|
-
expect(html).toContain(`href="${BASE}&utm_campaign=demo-store"`);
|
|
33
|
-
expect(html).toContain('rel="noopener noreferrer"');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('omits utm_campaign (never the internal UUID) when the store has no slug', () => {
|
|
37
|
-
const html = render(undefined, { id: 'uuid-1', slug: null });
|
|
38
|
-
|
|
39
|
-
expect(html).not.toContain('utm_campaign');
|
|
40
|
-
expect(html).not.toContain('uuid-1');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('omits utm_campaign (never errors) when no storefront provider is mounted', () => {
|
|
44
|
-
expect(render()).not.toContain('utm_campaign');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('renders "Powered by Queek" with the brand hook for theme styling', () => {
|
|
48
|
-
const html = render();
|
|
49
|
-
|
|
50
|
-
expect(html).toContain('core-powered-by');
|
|
51
|
-
expect(html).toContain('Powered by');
|
|
52
|
-
expect(html).toContain('<strong class="core-powered-by__brand">Queek</strong>');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('passes a footer positioning class through alongside the core class', () => {
|
|
56
|
-
const html = render({ className: 'qn-footer4__powered' });
|
|
57
|
-
|
|
58
|
-
expect(html).toContain('class="core-powered-by qn-footer4__powered"');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('renders nothing when hidden', () => {
|
|
62
|
-
expect(render({ hidden: true })).toBe('');
|
|
63
|
-
expect(render({ hidden: true }, { id: 'uuid-1', slug: 'demo-store' })).toBe('');
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { POWERED_BY_QUEEK_URL, poweredByQueekUrl } from '../utils/powered-by';
|
|
3
|
-
|
|
4
|
-
function params(url: string): Record<string, string> {
|
|
5
|
-
return Object.fromEntries(new URL(url).searchParams.entries());
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
describe('poweredByQueekUrl', () => {
|
|
9
|
-
it('keeps the business-page destination and adds source, medium and campaign', () => {
|
|
10
|
-
const url = poweredByQueekUrl('demo-store');
|
|
11
|
-
|
|
12
|
-
expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
|
|
13
|
-
expect(params(url)).toEqual({
|
|
14
|
-
utm_source: 'powered_by',
|
|
15
|
-
utm_medium: 'storefront',
|
|
16
|
-
utm_campaign: 'demo-store',
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it.each([undefined, null, '', ' '])('omits utm_campaign for a blank campaign (%j)', (campaign) => {
|
|
21
|
-
expect(params(poweredByQueekUrl(campaign))).toEqual({
|
|
22
|
-
utm_source: 'powered_by',
|
|
23
|
-
utm_medium: 'storefront',
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('preserves an existing query and overrides only its own utm keys', () => {
|
|
28
|
-
const url = poweredByQueekUrl('shop-1', 'https://usequeek.com/business?plan=pro&utm_source=old&utm_campaign=stale');
|
|
29
|
-
|
|
30
|
-
expect(params(url)).toEqual({
|
|
31
|
-
plan: 'pro',
|
|
32
|
-
utm_source: 'powered_by',
|
|
33
|
-
utm_medium: 'storefront',
|
|
34
|
-
utm_campaign: 'shop-1',
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('drops a stale utm_campaign when the new campaign is blank', () => {
|
|
39
|
-
expect(params(poweredByQueekUrl(null, 'https://usequeek.com/business?utm_campaign=stale'))).not.toHaveProperty('utm_campaign');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('url-encodes the campaign and is deterministic (SSR-safe, no window/Date)', () => {
|
|
43
|
-
const url = poweredByQueekUrl('a b&c');
|
|
44
|
-
|
|
45
|
-
expect(url).toContain('utm_campaign=a+b%26c');
|
|
46
|
-
expect(params(url).utm_campaign).toBe('a b&c');
|
|
47
|
-
expect(poweredByQueekUrl('a b&c')).toBe(url);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { LegacyProductLike, Product } from '../types/product';
|
|
3
|
-
import { normalizeProduct } from '../utils/product-normalizer';
|
|
4
|
-
import { buildMediaFrames, getHoverImage, getSlideshowImages } from '../utils/product-media';
|
|
5
|
-
|
|
6
|
-
function raw(overrides: LegacyProductLike = {}): LegacyProductLike {
|
|
7
|
-
return { id: 'p1', title: 'Ankara Gown', slug: 'ankara-gown', ...overrides };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function viewVariants(url: string) {
|
|
11
|
-
return {
|
|
12
|
-
thumb: null,
|
|
13
|
-
card: { url: '/card.jpg', w: 480, h: 480 },
|
|
14
|
-
card2x: { url: '/card2x.jpg', w: 800, h: 800 },
|
|
15
|
-
view: { url, w: 1600, h: 1600 },
|
|
16
|
-
original: { url: '/orig.jpg', w: 2400, h: 2400 },
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('normalizeProduct images', () => {
|
|
21
|
-
it('carries backend images verbatim, ordered primary-first', () => {
|
|
22
|
-
const product = normalizeProduct(
|
|
23
|
-
raw({
|
|
24
|
-
media: {
|
|
25
|
-
image: 'https://cdn/x/primary.jpg',
|
|
26
|
-
thumbnail: 'https://cdn/x/thumb.jpg',
|
|
27
|
-
images: [
|
|
28
|
-
{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Front', variants: null },
|
|
29
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side', variants: viewVariants('https://cdn/x/side-view.jpg') },
|
|
30
|
-
],
|
|
31
|
-
},
|
|
32
|
-
}),
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
expect(product.media.images).toHaveLength(2);
|
|
36
|
-
expect(product.media.images?.[0]).toMatchObject({ id: 11, url: 'https://cdn/x/primary.jpg' });
|
|
37
|
-
expect(product.media.images?.[1]?.variants).toMatchObject({ view: { url: 'https://cdn/x/side-view.jpg' } });
|
|
38
|
-
// The featured shortcut is untouched.
|
|
39
|
-
expect(product.media.image).toBe('https://cdn/x/primary.jpg');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('drops blank entries from a provided images list', () => {
|
|
43
|
-
const product = normalizeProduct(
|
|
44
|
-
raw({
|
|
45
|
-
media: {
|
|
46
|
-
image: 'https://cdn/x/primary.jpg',
|
|
47
|
-
images: [
|
|
48
|
-
{ id: 11, url: '', alt: 'Blank' },
|
|
49
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
50
|
-
],
|
|
51
|
-
},
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
expect(product.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/side.jpg']);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('ignores a legacy gallery key: images come from images only', () => {
|
|
59
|
-
const product = normalizeProduct(
|
|
60
|
-
raw({
|
|
61
|
-
media: {
|
|
62
|
-
image: 'https://cdn/x/primary.jpg',
|
|
63
|
-
images: [{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Front' }],
|
|
64
|
-
gallery: [
|
|
65
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
66
|
-
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
67
|
-
],
|
|
68
|
-
} as LegacyProductLike['media'],
|
|
69
|
-
}),
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
expect(product.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/primary.jpg']);
|
|
73
|
-
expect(product.media).not.toHaveProperty('gallery');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('rejects a misspelled images key at compile time instead of silently yielding []', () => {
|
|
77
|
-
const product = normalizeProduct(
|
|
78
|
-
raw({
|
|
79
|
-
media: {
|
|
80
|
-
// @ts-expect-error `imagess` is not a media key — the narrowed type turns this typo into a compile error, not a [].
|
|
81
|
-
imagess: [{ id: 1, url: 'https://cdn/x/a.jpg' }],
|
|
82
|
-
},
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
expect(product.media.images).toEqual([]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('derives nothing from image/gallery on old payloads: no images list means []', () => {
|
|
90
|
-
const product = normalizeProduct(
|
|
91
|
-
raw({
|
|
92
|
-
image: 'https://cdn/x/primary.jpg',
|
|
93
|
-
media: {
|
|
94
|
-
image: 'https://cdn/x/primary.jpg',
|
|
95
|
-
gallery: [
|
|
96
|
-
{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Dupe of primary' },
|
|
97
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
98
|
-
{ id: 13, url: '', alt: 'Blank' },
|
|
99
|
-
],
|
|
100
|
-
} as LegacyProductLike['media'],
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
expect(product.media.images).toEqual([]);
|
|
105
|
-
expect(product.media).not.toHaveProperty('gallery');
|
|
106
|
-
// The primary shortcut itself is untouched.
|
|
107
|
-
expect(product.media.image).toBe('https://cdn/x/primary.jpg');
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('yields [] for a payload with no imagery at all and no gallery key', () => {
|
|
111
|
-
const none = normalizeProduct(raw({}));
|
|
112
|
-
|
|
113
|
-
expect(none.media.images).toEqual([]);
|
|
114
|
-
expect(none.media).not.toHaveProperty('gallery');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('yields [] when a provided images list is entirely blank, even with gallery present', () => {
|
|
118
|
-
const product = normalizeProduct(
|
|
119
|
-
raw({
|
|
120
|
-
media: {
|
|
121
|
-
image: 'https://cdn/x/primary.jpg',
|
|
122
|
-
gallery: [{ id: 12, url: 'https://cdn/x/side.jpg' }],
|
|
123
|
-
images: [{ id: 11, url: '' }],
|
|
124
|
-
} as LegacyProductLike['media'],
|
|
125
|
-
}),
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
expect(product.media.images).toEqual([]);
|
|
129
|
-
expect(product.media).not.toHaveProperty('gallery');
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe('getHoverImage / getSlideshowImages', () => {
|
|
134
|
-
const product = (urls: string[]): Product =>
|
|
135
|
-
normalizeProduct(
|
|
136
|
-
raw({
|
|
137
|
-
media: {
|
|
138
|
-
image: urls[0] ?? null,
|
|
139
|
-
images: urls.map((url, i) => ({ id: i + 1, url })),
|
|
140
|
-
},
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
it('returns images[1] and null unless two images exist', () => {
|
|
145
|
-
expect(getHoverImage(product([]))).toBeNull();
|
|
146
|
-
expect(getHoverImage(product(['https://cdn/x/a.jpg']))).toBeNull();
|
|
147
|
-
expect(getHoverImage(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']))).toBe(
|
|
148
|
-
'https://cdn/x/b.jpg',
|
|
149
|
-
);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('returns the ordered slideshow list', () => {
|
|
153
|
-
const list = getSlideshowImages(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']));
|
|
154
|
-
expect(list.map((i) => i.url)).toEqual(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('treats a missing images key as no slideshow', () => {
|
|
158
|
-
const legacy = normalizeProduct(raw({ image: 'https://cdn/x/a.jpg' }));
|
|
159
|
-
delete legacy.media.images;
|
|
160
|
-
expect(getSlideshowImages(legacy)).toEqual([]);
|
|
161
|
-
expect(getHoverImage(legacy)).toBeNull();
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe('buildMediaFrames reads images', () => {
|
|
166
|
-
it('renders identical frames with and without a legacy gallery key present', () => {
|
|
167
|
-
const images = [
|
|
168
|
-
{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Ankara Gown' },
|
|
169
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
170
|
-
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
171
|
-
];
|
|
172
|
-
const withGallery = normalizeProduct(
|
|
173
|
-
raw({
|
|
174
|
-
media: {
|
|
175
|
-
image: 'https://cdn/x/primary.jpg',
|
|
176
|
-
original: 'https://cdn/x/primary-orig.jpg',
|
|
177
|
-
gallery: [{ id: 99, url: 'https://cdn/x/stale.jpg', alt: 'Stale' }],
|
|
178
|
-
images,
|
|
179
|
-
} as LegacyProductLike['media'],
|
|
180
|
-
}),
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
const stripped = normalizeProduct(
|
|
184
|
-
raw({
|
|
185
|
-
media: {
|
|
186
|
-
image: 'https://cdn/x/primary.jpg',
|
|
187
|
-
original: 'https://cdn/x/primary-orig.jpg',
|
|
188
|
-
images,
|
|
189
|
-
},
|
|
190
|
-
}),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const withUrls = buildMediaFrames(withGallery).map((f) => f.url);
|
|
194
|
-
const strippedUrls = buildMediaFrames(stripped).map((f) => f.url);
|
|
195
|
-
expect(withUrls).toEqual(strippedUrls);
|
|
196
|
-
expect(withUrls).toEqual([
|
|
197
|
-
'https://cdn/x/primary-orig.jpg',
|
|
198
|
-
'https://cdn/x/side.jpg',
|
|
199
|
-
'https://cdn/x/back.jpg',
|
|
200
|
-
]);
|
|
201
|
-
expect(withUrls).not.toContain('https://cdn/x/stale.jpg');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('builds no frames from a gallery-only payload: gallery urls never render', () => {
|
|
205
|
-
const product = normalizeProduct(
|
|
206
|
-
raw({
|
|
207
|
-
media: {
|
|
208
|
-
image: 'https://cdn/x/primary.jpg',
|
|
209
|
-
gallery: [
|
|
210
|
-
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
211
|
-
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
212
|
-
],
|
|
213
|
-
} as LegacyProductLike['media'],
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// Only the media.image primary single-frame fallback survives — the
|
|
218
|
-
// gallery urls are ignored, not derived into images.
|
|
219
|
-
expect(product.media.images).toEqual([]);
|
|
220
|
-
expect(buildMediaFrames(product).map((f) => f.url)).toEqual(['https://cdn/x/primary.jpg']);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('prefers the view tier off images entries and appends video last', () => {
|
|
224
|
-
const product = normalizeProduct(
|
|
225
|
-
raw({
|
|
226
|
-
media: {
|
|
227
|
-
image: 'https://cdn/x/primary.jpg',
|
|
228
|
-
images: [
|
|
229
|
-
{ id: 11, url: 'https://cdn/x/primary.jpg', variants: viewVariants('https://cdn/x/primary-view.jpg') },
|
|
230
|
-
{ id: 12, url: 'https://cdn/x/side.jpg' },
|
|
231
|
-
],
|
|
232
|
-
video_url: 'https://cdn/x/clip.mp4',
|
|
233
|
-
},
|
|
234
|
-
}),
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
const frames = buildMediaFrames(product);
|
|
238
|
-
expect(frames.map((f) => f.type)).toEqual(['image', 'image', 'video']);
|
|
239
|
-
expect(frames[0].url).toBe('https://cdn/x/primary-view.jpg');
|
|
240
|
-
expect(frames[2].url).toBe('https://cdn/x/clip.mp4');
|
|
241
|
-
});
|
|
242
|
-
});
|
|
@@ -1,154 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
* Every module the barrels declare must exist.
|
|
24
|
-
*/
|
|
25
|
-
describe('core public surface (kit-internal)', () => {
|
|
26
|
-
const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
|
|
27
|
-
|
|
28
|
-
it('declares nothing that no longer exists', () => {
|
|
29
|
-
const missing = [...declared].filter(
|
|
30
|
-
(mod) => !['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(join(ROOT, mod + ext))),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
expect(missing).toEqual([]);
|
|
34
|
-
});
|
|
35
|
-
});
|
package/tests/shop-query.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
SHOP_DEFAULT_PER_PAGE,
|
|
4
|
-
normalizeShopQuery,
|
|
5
|
-
parseShopResponse,
|
|
6
|
-
parseShopSearchParams,
|
|
7
|
-
sameShopQuery,
|
|
8
|
-
shopRequestParams,
|
|
9
|
-
} from '../utils/shop-query';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The server's /shop prefetch only saves the browser a fetch when both sides
|
|
13
|
-
* build the same query — these are the rules both share.
|
|
14
|
-
*/
|
|
15
|
-
describe('shop query (shared by useShop and the server prefetch)', () => {
|
|
16
|
-
it('applies the same defaults useShop always has', () => {
|
|
17
|
-
expect(normalizeShopQuery()).toEqual({ categorySlug: null, keyword: null, sort: 'latest', page: 1, perPage: SHOP_DEFAULT_PER_PAGE });
|
|
18
|
-
expect(SHOP_DEFAULT_PER_PAGE).toBe(24);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('treats a blank or padded keyword like the request does', () => {
|
|
22
|
-
expect(normalizeShopQuery({ keyword: ' ' }).keyword).toBeNull();
|
|
23
|
-
expect(normalizeShopQuery({ keyword: ' rice ' }).keyword).toBe('rice');
|
|
24
|
-
expect(sameShopQuery(normalizeShopQuery({ keyword: '' }), normalizeShopQuery({ keyword: null }))).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('distinguishes every field that changes the page fetched', () => {
|
|
28
|
-
const base = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 2, perPage: 24 });
|
|
29
|
-
for (const change of [{ categorySlug: 'rice' }, { keyword: 'okra' }, { sort: 'price_low' as const }, { page: 3 }, { perPage: 8 }]) {
|
|
30
|
-
expect(sameShopQuery(base, normalizeShopQuery({ ...base, ...change }))).toBe(false);
|
|
31
|
-
}
|
|
32
|
-
expect(sameShopQuery(base, normalizeShopQuery({ ...base }))).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('reads the /shop URL exactly as useShopParams always has', () => {
|
|
36
|
-
const read = (qs: string) => parseShopSearchParams((key) => new URLSearchParams(qs).get(key));
|
|
37
|
-
expect(read('')).toEqual({ categorySlug: null, keyword: '', sort: 'latest', page: 1 });
|
|
38
|
-
expect(read('category=soups&q=egusi&sort=price_high&page=3')).toEqual({ categorySlug: 'soups', keyword: 'egusi', sort: 'price_high', page: 3 });
|
|
39
|
-
expect(read('sort=cheapest&page=0').sort).toBe('latest');
|
|
40
|
-
expect(read('page=abc').page).toBe(1);
|
|
41
|
-
expect(read('page=2.7').page).toBe(2);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('sends the params the storefront products endpoint expects', () => {
|
|
45
|
-
expect(shopRequestParams(normalizeShopQuery())).toEqual({ per_page: 24, page: 1, sort: 'latest' });
|
|
46
|
-
expect(shopRequestParams(normalizeShopQuery({ categorySlug: 'soups', keyword: ' egusi ', sort: 'popular', page: 2 })))
|
|
47
|
-
.toEqual({ per_page: 24, page: 2, category_slug: 'soups', keyword: 'egusi', sort: 'popular' });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('reads the walk state top-level: the page it asked for, has_more from the API, no totals', () => {
|
|
51
|
-
const query = normalizeShopQuery({ page: 2 });
|
|
52
|
-
const page = parseShopResponse({ data: [], has_more: true, next_cursor: 'eyJ2IjoxfQ', meta: { listing_mode: 'listing' } } as never, query);
|
|
53
|
-
expect(page.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: true });
|
|
54
|
-
|
|
55
|
-
const last = parseShopResponse({ data: [], has_more: false, next_cursor: null } as never, normalizeShopQuery());
|
|
56
|
-
expect(last.pagination).toEqual({ currentPage: 1, perPage: 24, hasMore: false });
|
|
57
|
-
|
|
58
|
-
// A legacy paginator echo (the old meta.pagination/meta.current_page) is
|
|
59
|
-
// never read: the page is the one asked for, has_more defaults to false.
|
|
60
|
-
const legacy = parseShopResponse({ data: [], meta: { pagination: { current_page: 9, last_page: 12, total: 280, has_more: true } } } as never, query);
|
|
61
|
-
expect(legacy.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: false });
|
|
62
|
-
expect(Object.keys(legacy.pagination).sort()).toEqual(['currentPage', 'hasMore', 'perPage']);
|
|
63
|
-
});
|
|
64
|
-
});
|