@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 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 → theme → core → English
74
- default); first hit wins. The last fallback is the English default
75
- *value* — never the raw key, never "translation missing". Absent
76
- everywhere renders `""` (and warns once per key in development only —
77
- an empty string would otherwise leak internal key names into the UI).
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
- import type { JSX } from 'react';
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. Markup carries `core-powered-by` classes so each theme
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="https://usequeek.com/business"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
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
- strings: ThemeStringsDictionary[];
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 the English default — never the raw key). Works in
156
- * themes mounted by `ThemeMount` (which renders inside the host's provider).
157
- * No `strings` prop → the English defaults, so kit components render exactly
158
- * as before and third-party themes keep working untranslated.
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
- function render(props?: { className?: string; hidden?: boolean }): string {
7
- return renderToStaticMarkup(React.createElement(PoweredByQueek, props));
7
+ const BASE = 'https://usequeek.com/business?utm_source=powered_by&amp;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('href="https://usequeek.com/business"');
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}&amp;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
- expect(key, `grammar: ${key}`).toMatch(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/);
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
+ }