@usequeek/theme-kit 0.1.18 → 0.1.20
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 +19 -6
- package/components/powered-by-queek.tsx +12 -3
- package/package.json +1 -1
- package/provider.tsx +18 -9
- package/tests/powered-by-queek.test.tsx +31 -3
- package/tests/powered-by-url.test.ts +49 -0
- package/tests/theme-strings-client.test.tsx +130 -2
- package/tests/theme-strings.test.tsx +3 -1
- package/theme-context.tsx +10 -0
- package/types/theme.ts +21 -0
- package/utils/powered-by.ts +31 -0
package/README.md
CHANGED
|
@@ -70,11 +70,21 @@ provider that passes nothing renders exactly as today, in English.
|
|
|
70
70
|
plus `useThemeStrings()` returning the same bound `t`; works inside
|
|
71
71
|
`ThemeMount`. The kit ships `locales/en.default.json` (cart, checkout,
|
|
72
72
|
auth, blog) as the last-resort English default.
|
|
73
|
-
- **Fallback.** Ordered dictionaries (override →
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
- **Fallback.** Ordered dictionaries (merchant override → host-loaded
|
|
74
|
+
locale dictionaries → `manifest.strings` → kit core English default);
|
|
75
|
+
first hit wins. The last fallback is the English default *value* —
|
|
76
|
+
never the raw key, never "translation missing". Absent everywhere
|
|
77
|
+
renders `""` (and warns once per key in development only — an empty
|
|
78
|
+
string would otherwise leak internal key names into the UI).
|
|
79
|
+
- **Theme English travels with the theme.** A theme ships its own English
|
|
80
|
+
defaults in its manifest: `import strings from './locales/en.default.json'`
|
|
81
|
+
inside the theme, then `manifest: { …, strings }` (`ThemeManifest.strings`,
|
|
82
|
+
ENGLISH ONLY — never other locales). `useThemeStrings()` layers it
|
|
83
|
+
between the host's `strings` prop and the kit core English, so hosts that
|
|
84
|
+
mount a theme with no `strings` (e.g. theme previews) still render the
|
|
85
|
+
theme's English with zero host cooperation. Host dictionaries always win
|
|
86
|
+
where present; keys missing there fall back to manifest English, never
|
|
87
|
+
to `""`.
|
|
78
88
|
- **Per-locale loading.** `createThemeStrings` reads ONLY the active
|
|
79
89
|
locale's dictionaries, and the host passes ONLY that locale to the
|
|
80
90
|
provider — other `{lang}.json` files never enter the client bundle.
|
|
@@ -193,7 +203,10 @@ export default function Footer() {
|
|
|
193
203
|
it — style the classes in your theme.
|
|
194
204
|
- The href (`https://usequeek.com/business`), `target="_blank"` and
|
|
195
205
|
`rel="noopener noreferrer"` are the contract — never re-implement the
|
|
196
|
-
markup by hand.
|
|
206
|
+
markup by hand. The href is tagged for attribution by `poweredByQueekUrl`
|
|
207
|
+
(`utm_source=powered_by&utm_medium=storefront&utm_campaign=<store slug>`,
|
|
208
|
+
campaign omitted when the store has no slug); the component reads the slug
|
|
209
|
+
from the storefront context, so themes pass nothing.
|
|
197
210
|
- `hidden` (default false) takes the component's visibility from a prop as
|
|
198
211
|
the seam for a possible future plan perk. There is no merchant-facing
|
|
199
212
|
toggle for it in v1 — always render it visible.
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useContext, type JSX } from 'react';
|
|
4
|
+
import { StorefrontContext } from '../provider';
|
|
5
|
+
import { poweredByQueekUrl } from '../utils/powered-by';
|
|
2
6
|
|
|
3
7
|
export interface PoweredByQueekProps {
|
|
4
8
|
/** Extra class so a footer can position the link — styling stays theme-owned. */
|
|
@@ -17,15 +21,20 @@ export interface PoweredByQueekProps {
|
|
|
17
21
|
* Required on every footer variant of every theme (enforced by `theme-check`
|
|
18
22
|
* rule `theme/footer-shows-powered-by` in the storefront repo). The link
|
|
19
23
|
* target (`https://usequeek.com/business`) and `rel` are the contract —
|
|
20
|
-
* do not change them.
|
|
24
|
+
* do not change them. The href is tagged `utm_source=powered_by`,
|
|
25
|
+
* `utm_medium=storefront`, `utm_campaign=<store slug>` via `poweredByQueekUrl`
|
|
26
|
+
* (read from the storefront context, so themes pass nothing; no provider means
|
|
27
|
+
* no campaign, never an error). Markup carries `core-powered-by` classes so each theme
|
|
21
28
|
* styles it in its own `theme.css`; no kit CSS ships for it.
|
|
22
29
|
*/
|
|
23
30
|
export function PoweredByQueek({ className, hidden = false }: PoweredByQueekProps): JSX.Element | null {
|
|
31
|
+
const vendor = useContext(StorefrontContext)?.vendor;
|
|
32
|
+
|
|
24
33
|
if (hidden) return null;
|
|
25
34
|
|
|
26
35
|
return (
|
|
27
36
|
<a
|
|
28
|
-
href=
|
|
37
|
+
href={poweredByQueekUrl(vendor?.slug)}
|
|
29
38
|
target="_blank"
|
|
30
39
|
rel="noopener noreferrer"
|
|
31
40
|
className={className ? `core-powered-by ${className}` : 'core-powered-by'}
|
package/package.json
CHANGED
package/provider.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ThemeStringsVars,
|
|
18
18
|
} from './strings/theme-strings';
|
|
19
19
|
import { DesignTokenPreviewListener } from './design-token-preview';
|
|
20
|
+
import { useOptionalTheme } from './theme-context';
|
|
20
21
|
|
|
21
22
|
export interface StorefrontPreviewData {
|
|
22
23
|
products?: Product[];
|
|
@@ -48,8 +49,10 @@ interface StorefrontContextValue {
|
|
|
48
49
|
* `useStorefrontLocale()` and send `?locale=` when it is set. */
|
|
49
50
|
locale: string | null;
|
|
50
51
|
/** Active locale's dictionaries (override, theme, core — the host merges and
|
|
51
|
-
* passes ONLY the active locale). Empty = English defaults only.
|
|
52
|
-
|
|
52
|
+
* passes ONLY the active locale). Empty or omitted = English defaults only.
|
|
53
|
+
* Optional so hand-built context values (storefront scripts, tests) keep
|
|
54
|
+
* compiling without it. */
|
|
55
|
+
strings?: ThemeStringsDictionary[];
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
|
|
@@ -151,11 +154,15 @@ export function useStorefrontLocale(): string | null {
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
|
-
* The client theme-string surface: the bound `t` for the active locale
|
|
155
|
-
* (`strings` prop, then
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
157
|
+
* The client theme-string surface: the bound `t` for the active locale.
|
|
158
|
+
* Chain order: merchant/host override dictionaries (`strings` prop), then
|
|
159
|
+
* the mounted theme's `manifest.strings` (theme English — travels WITH the
|
|
160
|
+
* theme, so preview hosts that pass no `strings` still render English),
|
|
161
|
+
* then the kit core English default — never the raw key. Works in themes
|
|
162
|
+
* mounted by `ThemeMount` and in hosts that provide the theme directly
|
|
163
|
+
* (both expose it through the theme context). No `strings` prop and no
|
|
164
|
+
* manifest → the English defaults, so kit components render exactly as
|
|
165
|
+
* before and third-party themes keep working untranslated.
|
|
159
166
|
*/
|
|
160
167
|
export function useThemeStrings(): ThemeStringsFn {
|
|
161
168
|
const context = useContext(StorefrontContext);
|
|
@@ -164,16 +171,18 @@ export function useThemeStrings(): ThemeStringsFn {
|
|
|
164
171
|
throw new Error('useThemeStrings must be used within StorefrontProvider');
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
const theme = useOptionalTheme();
|
|
175
|
+
const manifestStrings = theme?.manifest?.strings ?? null;
|
|
167
176
|
const { strings, locale } = context;
|
|
168
177
|
return useMemo(
|
|
169
178
|
() =>
|
|
170
179
|
(key: string, vars?: ThemeStringsVars, overrideLocale?: string | null) =>
|
|
171
180
|
translateThemeStrings(
|
|
172
|
-
[...strings, defaultThemeStrings],
|
|
181
|
+
[...(strings ?? []), ...(manifestStrings ? [manifestStrings] : []), defaultThemeStrings],
|
|
173
182
|
key,
|
|
174
183
|
vars,
|
|
175
184
|
overrideLocale ?? locale ?? undefined,
|
|
176
185
|
),
|
|
177
|
-
[strings, locale],
|
|
186
|
+
[strings, manifestStrings, locale],
|
|
178
187
|
);
|
|
179
188
|
}
|
|
@@ -2,9 +2,18 @@ import React from 'react';
|
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { PoweredByQueek } from '../components/powered-by-queek';
|
|
5
|
+
import { StorefrontContext } from '../provider';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
);
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
describe('PoweredByQueek', () => {
|
|
@@ -12,11 +21,29 @@ describe('PoweredByQueek', () => {
|
|
|
12
21
|
const html = render();
|
|
13
22
|
|
|
14
23
|
expect(html).toContain('<a');
|
|
15
|
-
expect(html).toContain(
|
|
24
|
+
expect(html).toContain(`href="${BASE}"`);
|
|
16
25
|
expect(html).toContain('target="_blank"');
|
|
17
26
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
18
27
|
});
|
|
19
28
|
|
|
29
|
+
it('tags the link with the store slug as utm_campaign', () => {
|
|
30
|
+
const html = render(undefined, { id: 'uuid-1', slug: 'kili-foods' });
|
|
31
|
+
|
|
32
|
+
expect(html).toContain(`href="${BASE}&utm_campaign=kili-foods"`);
|
|
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
|
+
|
|
20
47
|
it('renders "Powered by Queek" with the brand hook for theme styling', () => {
|
|
21
48
|
const html = render();
|
|
22
49
|
|
|
@@ -33,5 +60,6 @@ describe('PoweredByQueek', () => {
|
|
|
33
60
|
|
|
34
61
|
it('renders nothing when hidden', () => {
|
|
35
62
|
expect(render({ hidden: true })).toBe('');
|
|
63
|
+
expect(render({ hidden: true }, { id: 'uuid-1', slug: 'kili-foods' })).toBe('');
|
|
36
64
|
});
|
|
37
65
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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('kili-foods');
|
|
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: 'kili-foods',
|
|
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,12 +1,13 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import type { JSX, ReactNode } from 'react';
|
|
4
|
-
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
4
|
+
import { StorefrontContext, StorefrontProvider, useThemeStrings } from '../provider';
|
|
5
5
|
import { ThemeMount } from '../theme-mount';
|
|
6
6
|
import { DefaultCartShell } from '../components/cart/default-cart-shell';
|
|
7
7
|
import { defaultThemeStrings, t as translate, type ThemeStringsDictionary } from '../strings/theme-strings';
|
|
8
8
|
import type { CartItem } from '../types/cart';
|
|
9
9
|
import type { ThemeModule } from '../types/theme';
|
|
10
|
+
import type { StorefrontConfig, VendorProfile } from '../types/vendor';
|
|
10
11
|
|
|
11
12
|
const FR_STRINGS: ThemeStringsDictionary = {
|
|
12
13
|
cart: {
|
|
@@ -104,6 +105,133 @@ describe('client surface through ThemeMount', () => {
|
|
|
104
105
|
});
|
|
105
106
|
});
|
|
106
107
|
|
|
108
|
+
describe('manifest.strings travels with the theme (decision 15)', () => {
|
|
109
|
+
const MANIFEST_EN: ThemeStringsDictionary = {
|
|
110
|
+
theme: {
|
|
111
|
+
hero: { title: 'Fresh from the farm' },
|
|
112
|
+
lines: { count: { one: '{count} line', other: '{count} lines' } },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function ManifestHero(): JSX.Element {
|
|
117
|
+
const t = useThemeStrings();
|
|
118
|
+
return (
|
|
119
|
+
<header>
|
|
120
|
+
<h1>{t('theme.hero.title')}</h1>
|
|
121
|
+
<p>{t('theme.lines.count', { count: 1 })}</p>
|
|
122
|
+
<p>{t('theme.lines.count', { count: 5 })}</p>
|
|
123
|
+
<span>{t('cart.title')}</span>
|
|
124
|
+
</header>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function themeWithManifest(strings?: ThemeStringsDictionary): ThemeModule {
|
|
129
|
+
return {
|
|
130
|
+
Layout: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
131
|
+
Header: ManifestHero,
|
|
132
|
+
Footer: () => <footer />,
|
|
133
|
+
blocks: {},
|
|
134
|
+
getHeader: () => ManifestHero,
|
|
135
|
+
getFooter: () => (() => <footer />),
|
|
136
|
+
getBlock: () => (() => null),
|
|
137
|
+
pages: {},
|
|
138
|
+
manifest: strings
|
|
139
|
+
? { name: 'manifest-theme', slug: 'manifest-theme', description: '', version: '0', strings }
|
|
140
|
+
: { name: 'manifest-theme', slug: 'manifest-theme', description: '', version: '0' },
|
|
141
|
+
} as unknown as ThemeModule;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mount(
|
|
145
|
+
theme: ThemeModule,
|
|
146
|
+
options?: { locale?: string | null; strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null },
|
|
147
|
+
): string {
|
|
148
|
+
return render(
|
|
149
|
+
<ThemeMount theme={theme} headerProps={{ menu: [], announcement: null }} footerProps={{}}>
|
|
150
|
+
<p>page</p>
|
|
151
|
+
</ThemeMount>,
|
|
152
|
+
options,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it('(a) renders theme + kit core English with NO provider strings prop', () => {
|
|
157
|
+
const html = mount(themeWithManifest(MANIFEST_EN));
|
|
158
|
+
expect(html).toContain('<h1>Fresh from the farm</h1>');
|
|
159
|
+
expect(html).toContain('<p>1 line</p>');
|
|
160
|
+
expect(html).toContain('<p>5 lines</p>');
|
|
161
|
+
// The manifest says nothing about kit keys: kit core English still renders.
|
|
162
|
+
expect(html).toContain('<span>Your cart</span>');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('(b) manifest beats kit core English', () => {
|
|
166
|
+
const html = mount(
|
|
167
|
+
themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }),
|
|
168
|
+
);
|
|
169
|
+
expect(html).toContain('<span>Theme cart title</span>');
|
|
170
|
+
expect(html).not.toContain('<span>Your cart</span>');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('(b) host override beats manifest', () => {
|
|
174
|
+
const html = mount(themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }), {
|
|
175
|
+
strings: { cart: { title: 'Host cart title' } },
|
|
176
|
+
});
|
|
177
|
+
expect(html).toContain('<span>Host cart title</span>');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('(b) first host dictionary wins (merchant override position)', () => {
|
|
181
|
+
const html = mount(themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }), {
|
|
182
|
+
strings: [{ cart: { title: 'First' } }, { cart: { title: 'Second' } }],
|
|
183
|
+
});
|
|
184
|
+
expect(html).toContain('<span>First</span>');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('(c) non-English host dict falls back per key to manifest English, never empty', () => {
|
|
188
|
+
const html = mount(themeWithManifest(MANIFEST_EN), {
|
|
189
|
+
locale: 'fr',
|
|
190
|
+
strings: { cart: { title: 'Votre panier' } },
|
|
191
|
+
});
|
|
192
|
+
expect(html).toContain('<span>Votre panier</span>');
|
|
193
|
+
expect(html).toContain('<h1>Fresh from the farm</h1>');
|
|
194
|
+
expect(html).not.toContain('<h1></h1>');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('(d) manifest without strings behaves exactly as 0.1.18', () => {
|
|
198
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
199
|
+
try {
|
|
200
|
+
const html = mount(themeWithManifest());
|
|
201
|
+
// Theme key absent everywhere: empty + one dev warning, as in 0.1.18.
|
|
202
|
+
expect(html).toContain('<h1></h1>');
|
|
203
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('theme.hero.title'));
|
|
204
|
+
// Kit key: English default, unchanged.
|
|
205
|
+
expect(html).toContain('<span>Your cart</span>');
|
|
206
|
+
} finally {
|
|
207
|
+
warn.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('(e) a hand-built context value without strings type-checks and renders kit English', () => {
|
|
212
|
+
function Probe(): JSX.Element {
|
|
213
|
+
const t = useThemeStrings();
|
|
214
|
+
return <h1>{t('cart.title')}</h1>;
|
|
215
|
+
}
|
|
216
|
+
// No `strings` key at all: this literal must satisfy StorefrontContextValue
|
|
217
|
+
// (it failed to compile while the field was required in 0.1.18).
|
|
218
|
+
const value = {
|
|
219
|
+
vendor: { id: 'v1', slug: 'kili-foods', name: 'Kili Foods', logo: null } as VendorProfile,
|
|
220
|
+
config: { theme: 'stub', brand: {} } as StorefrontConfig,
|
|
221
|
+
menus: [],
|
|
222
|
+
basePath: '/kili-foods',
|
|
223
|
+
pagesChrome: {},
|
|
224
|
+
locale: null,
|
|
225
|
+
};
|
|
226
|
+
const html = renderToStaticMarkup(
|
|
227
|
+
<StorefrontContext.Provider value={value}>
|
|
228
|
+
<Probe />
|
|
229
|
+
</StorefrontContext.Provider>,
|
|
230
|
+
);
|
|
231
|
+
expect(html).toContain('<h1>Your cart</h1>');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
107
235
|
describe('migrated DefaultCartShell (identical English output)', () => {
|
|
108
236
|
const item: CartItem = {
|
|
109
237
|
id: 'li-1',
|
|
@@ -246,7 +246,9 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
246
246
|
|
|
247
247
|
it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
|
|
248
248
|
for (const [key] of pairs) {
|
|
249
|
-
|
|
249
|
+
// Same grammar as the backend overlay (ThemeStringTranslatable::KEY_PATTERN suffix) and theme-check
|
|
250
|
+
// `theme/locale-key-naming`: segments may start with a digit (e.g. `auth.2fa.code`).
|
|
251
|
+
expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
|
|
250
252
|
expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
|
|
251
253
|
}
|
|
252
254
|
// `<theme-slug>.<key>` must fit varchar(64): 40 + 1 + 23.
|
package/theme-context.tsx
CHANGED
|
@@ -27,3 +27,13 @@ export function useTheme(): ThemeModule {
|
|
|
27
27
|
|
|
28
28
|
return theme;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Null outside a theme provider (kit chrome such as the cart shell renders
|
|
33
|
+
* under `StorefrontProvider` with no theme mounted). `useThemeStrings`
|
|
34
|
+
* reads the mounted theme's `manifest.strings` through this, so a missing
|
|
35
|
+
* theme simply contributes no dictionary instead of throwing.
|
|
36
|
+
*/
|
|
37
|
+
export function useOptionalTheme(): ThemeModule | null {
|
|
38
|
+
return useContext(ThemeContext);
|
|
39
|
+
}
|
package/types/theme.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { MenuItem } from './menu';
|
|
|
6
6
|
import type { BlogCategory, Collection, Page, Pagination, Post } from './page';
|
|
7
7
|
import type { MetaobjectEntry, Product } from './product';
|
|
8
8
|
import type { DesignTokenAxisPath, DesignTokens, StorefrontConfig, VendorProfile } from './vendor';
|
|
9
|
+
import type { ThemeStringsDictionary } from '../strings/theme-strings';
|
|
9
10
|
|
|
10
11
|
export interface HomePageProps {
|
|
11
12
|
blocks: Block[];
|
|
@@ -350,6 +351,26 @@ export interface ThemeManifest {
|
|
|
350
351
|
* injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
|
|
351
352
|
*/
|
|
352
353
|
tokens?: DesignTokens;
|
|
354
|
+
/**
|
|
355
|
+
* The theme's English default dictionary — the theme's own English
|
|
356
|
+
* locale file (authored as `en.default.json` in the theme's `locales/`
|
|
357
|
+
* dir and imported as an object) referenced here.
|
|
358
|
+
* Same shape as the kit core English default
|
|
359
|
+
* (nested objects by dot-path, or flat dotted keys; CLDR plural maps
|
|
360
|
+
* allowed); keys follow the shared grammar (dotted lowercase, <= 40
|
|
361
|
+
* chars) because the backend overlay stores `<theme-slug>.<key>` in a
|
|
362
|
+
* varchar(64).
|
|
363
|
+
*
|
|
364
|
+
* The kit layers it between the host-loaded locale dictionaries and the
|
|
365
|
+
* kit core English, so ANY host that mounts the theme (vendor layout,
|
|
366
|
+
* theme preview, CLI dev preview) renders the theme's English with zero
|
|
367
|
+
* host cooperation — a host that passes nothing still shows English,
|
|
368
|
+
* never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
|
|
369
|
+
* per locale and are never bundled into the theme. Optional during the
|
|
370
|
+
* migration — a manifest without it behaves exactly as before (kit core
|
|
371
|
+
* English, then `''` for unknown keys).
|
|
372
|
+
*/
|
|
373
|
+
strings?: ThemeStringsDictionary;
|
|
353
374
|
}
|
|
354
375
|
|
|
355
376
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the "Powered by Queek" destination is built, so every theme's
|
|
3
|
+
* footer link carries the same attribution and the affiliate/referral program
|
|
4
|
+
* can trace a signup back to the store it came from.
|
|
5
|
+
*/
|
|
6
|
+
export const POWERED_BY_QUEEK_URL = 'https://usequeek.com/business';
|
|
7
|
+
|
|
8
|
+
export const POWERED_BY_UTM_SOURCE = 'powered_by';
|
|
9
|
+
export const POWERED_BY_UTM_MEDIUM = 'storefront';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `campaign` is the store's public slug (never an internal UUID) so signups
|
|
13
|
+
* are attributable per store. Existing query parameters on the base
|
|
14
|
+
* URL are kept; the `utm_*` keys are always ours. A blank campaign omits
|
|
15
|
+
* `utm_campaign` rather than sending an empty value.
|
|
16
|
+
*/
|
|
17
|
+
export function poweredByQueekUrl(campaign?: string | null, base: string = POWERED_BY_QUEEK_URL): string {
|
|
18
|
+
const url = new URL(base);
|
|
19
|
+
|
|
20
|
+
url.searchParams.set('utm_source', POWERED_BY_UTM_SOURCE);
|
|
21
|
+
url.searchParams.set('utm_medium', POWERED_BY_UTM_MEDIUM);
|
|
22
|
+
|
|
23
|
+
const trimmed = campaign?.trim();
|
|
24
|
+
if (trimmed) {
|
|
25
|
+
url.searchParams.set('utm_campaign', trimmed);
|
|
26
|
+
} else {
|
|
27
|
+
url.searchParams.delete('utm_campaign');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|