@usequeek/theme-kit 0.1.25 → 0.1.27

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
@@ -480,7 +480,7 @@ store's languages.
480
480
  | Prop | Default | |
481
481
  | --- | --- | --- |
482
482
  | `className` | | Extra class on the root, to position or restyle it. |
483
- | `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works and is unchanged, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
483
+ | `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
484
484
  | `side` | `'bottom'` | `menu` only. `'top'` opens above the trigger, for footers. Flips by itself when the preferred side does not fit. |
485
485
  | `align` | `'end'` | `menu` only. Line the popover up with the trigger's inline `'end'` or `'start'` edge (mirrors in right-to-left). Flips and shifts to stay inside the viewport. |
486
486
  | `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
@@ -684,8 +684,8 @@ fails here instead of after publishing.
684
684
  ## Versioning
685
685
 
686
686
  The kit is pre-1.0 (`0.x`). A change to the surface the barrels declare that can
687
- break a theme ships in a minor version and is listed in
688
- [CHANGELOG.md](./CHANGELOG.md); patch versions are fixes and additions. Pin a
687
+ break a theme ships in a minor version; patch versions are fixes and
688
+ additions. Pin a
689
689
  minor version (`~0.1.23`) if a theme must not move on its own.
690
690
 
691
691
  ## Release
@@ -694,7 +694,7 @@ Publishing runs on tags via `.github/workflows/publish.yml`, through npm
694
694
  trusted publishing (no stored token), with provenance:
695
695
 
696
696
  ```bash
697
- # 1. Bump version in package.json, add the CHANGELOG entry, commit
697
+ # 1. Bump version in package.json, commit
698
698
  # 2. Tag and push the tag. The workflow typechecks, tests, runs verify-package,
699
699
  # then `npm publish --provenance --access public`:
700
700
  git tag vX.Y.Z
@@ -706,8 +706,7 @@ git push origin main --tags
706
706
  Open an issue or a pull request at
707
707
  [usequeek/theme-kit](https://github.com/usequeek/theme-kit). Run `npm run typecheck`
708
708
  and `npm test` before you push; CI runs both plus `verify-package`. Keep a change
709
- to the public surface small, documented with a JSDoc `@example`, and recorded in
710
- the CHANGELOG.
709
+ to the public surface small and documented with a JSDoc `@example`.
711
710
 
712
711
  ## Security
713
712
 
package/client.ts CHANGED
@@ -57,6 +57,7 @@ export * from './stores/location-store';
57
57
  export * from './stores/address-modal-store';
58
58
  export * from './stores/auth-modal-store';
59
59
  export * from './stores/cart-store';
60
+ export * from './stores/line-text-store';
60
61
 
61
62
  // Mounting a theme — the piece a theme developer cannot build without.
62
63
  export * from './theme-mount';
@@ -11,6 +11,7 @@ import { useRelatedProducts } from '../../hooks/use-related-products';
11
11
  import { useCart } from '../../hooks/use-cart';
12
12
  import { formatMoney } from '../../utils/format';
13
13
  import { productRequiresVariant } from '../../utils/match-variant';
14
+ import { visibleLineProperties } from '../../utils/line-properties';
14
15
  import { Image } from '../image';
15
16
 
16
17
  /**
@@ -76,6 +77,9 @@ export function DefaultCartShell({
76
77
  )}
77
78
  <div className="core-cart-page__item-info">
78
79
  <span className="core-cart-page__item-title">{item.title}</span>
80
+ {visibleLineProperties(item).map((row) => (
81
+ <span key={row.key} className="core-cart-page__item-price">{`${row.label}: ${row.value}`}</span>
82
+ ))}
79
83
  <span className="core-cart-page__item-price">{t('cart.item.each', { price: formatMoney(item.unit_price, currency) })}</span>
80
84
  </div>
81
85
  <div className="core-cart-page__item-qty">
package/index.ts CHANGED
@@ -42,6 +42,7 @@ export * from './utils/contact-location';
42
42
  export * from './utils/benefit-icon';
43
43
  export * from './utils/video-embed';
44
44
  export * from './utils/match-variant';
45
+ export * from './utils/line-properties';
45
46
  export * from './utils/announcement';
46
47
  export * from './utils/locale';
47
48
  export * from './utils/locale-switch';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The runtime a Queek storefront theme is built on: hooks, stores, page renderer, navigation, shared blocks and locale strings.",
@@ -41,7 +41,6 @@
41
41
  "locales/*.json",
42
42
  "README.md",
43
43
  "LICENSE",
44
- "CHANGELOG.md",
45
44
  "!tests",
46
45
  "!scripts"
47
46
  ],
@@ -9,6 +9,8 @@ import { normalizeCurrencyCode, PLATFORM_DEFAULT_CURRENCY } from '../utils/forma
9
9
  import { pushAddToCart } from '../apps/analytics-events';
10
10
  import { getActivePlatform, platformScopedStorage } from '../sdk/platform';
11
11
  import { productRequiresVariant } from '../utils/match-variant';
12
+ import { readLineText } from './line-text-store';
13
+ import { isBlankText } from '../utils/blank-text';
12
14
 
13
15
  interface CartState {
14
16
  items: CartItem[];
@@ -38,14 +40,57 @@ interface CartState {
38
40
  * unchanged (existing localStorage carts, in-flight server carts) rather than
39
41
  * reshaping ids for products that were never affected by this gap.
40
42
  */
41
- export function makeItemId(productId: string, addons: CartAddon[], variantId?: string | null): string {
43
+ export function makeItemId(
44
+ productId: string,
45
+ addons: CartAddon[],
46
+ variantId?: string | null,
47
+ properties?: Record<string, unknown> | null,
48
+ ): string {
42
49
  const base = variantId ? `${productId}::${variantId}` : productId;
50
+ const withAddons = addons.length === 0 ? base : `${base}-${addons.map((addon) => addon.item_id).sort().join(',')}`;
51
+ const suffix = propertiesSuffix(properties);
43
52
 
44
- if (addons.length === 0) {
45
- return base;
53
+ return suffix === '' ? withAddons : `${withAddons}${suffix}`;
54
+ }
55
+
56
+ /** FNV-1a (32 bit) over the UTF-8 bytes of `text`, as 8 hex digits. */
57
+ function fnv1a(text: string, seed: number): string {
58
+ let hash = seed >>> 0;
59
+
60
+ for (const byte of new TextEncoder().encode(text)) {
61
+ hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
46
62
  }
47
63
 
48
- return `${base}-${addons.map((addon) => addon.item_id).sort().join(',')}`;
64
+ return hash.toString(16).padStart(8, '0');
65
+ }
66
+
67
+ /**
68
+ * `~p-` plus 12 hex digits over the canonical JSON (keys sorted) of the
69
+ * non-blank string values, or an empty string when there are none, so a line
70
+ * without text keeps the id it has always had. Callers pass labelled keys only.
71
+ */
72
+ function propertiesSuffix(properties?: Record<string, unknown> | null): string {
73
+ if (!properties) {
74
+ return '';
75
+ }
76
+
77
+ const canonical: Record<string, string> = {};
78
+
79
+ for (const key of Object.keys(properties).sort()) {
80
+ const value = properties[key];
81
+
82
+ if (typeof value === 'string' && !isBlankText(value)) {
83
+ canonical[key] = value;
84
+ }
85
+ }
86
+
87
+ if (Object.keys(canonical).length === 0) {
88
+ return '';
89
+ }
90
+
91
+ const json = JSON.stringify(canonical);
92
+
93
+ return `~p-${(fnv1a(json, 0x811c9dc5) + fnv1a(json, 0x9e3779b9)).slice(0, 12)}`;
49
94
  }
50
95
 
51
96
  /**
@@ -77,7 +122,20 @@ export const useCartStore = create<CartState>()(
77
122
  return;
78
123
  }
79
124
 
80
- const itemId = makeItemId(product.id, addons, variant?.id);
125
+ // Text an app asked for on this product. A product with nothing
126
+ // registered reads as empty, which is the behaviour of a kit without line text.
127
+ const lineText = readLineText(product.shop_id, product.id);
128
+
129
+ if (lineText.missingRequired.length > 0) {
130
+ const missing = lineText.missingRequired[0];
131
+
132
+ toast({ title: `Add your ${missing.label} before adding to cart` });
133
+ console.error('[cart] refused a product added without its required line text:', product.id, missing.key);
134
+ return;
135
+ }
136
+
137
+ const hasLineText = Object.keys(lineText.properties).length > 0;
138
+ const itemId = makeItemId(product.id, addons, variant?.id, hasLineText ? lineText.properties : null);
81
139
  const addonTotal = addons.reduce((sum, addon) => sum + addon.price, 0);
82
140
  // A variant's own price/stock override the base product's — the same
83
141
  // precedence the quick-add flow uses.
@@ -133,6 +191,7 @@ export const useCartStore = create<CartState>()(
133
191
  addons,
134
192
  variant_id: variant?.id ?? null,
135
193
  variant_title: variant?.title ?? null,
194
+ ...(hasLineText ? { properties: lineText.properties, property_labels: lineText.labels } : {}),
136
195
  },
137
196
  ],
138
197
  };
@@ -0,0 +1,230 @@
1
+ 'use client';
2
+
3
+ import { create } from 'zustand';
4
+ import { isBlankText } from '../utils/blank-text';
5
+
6
+ /** The longest text a line field may carry. Mirrors the platform limit. */
7
+ export const LINE_TEXT_MAX_LENGTH = 500;
8
+ /** Used when a registered field declares no usable `maxLength`. */
9
+ export const LINE_TEXT_DEFAULT_MAX_LENGTH = 100;
10
+
11
+ /** One text field an app asks the shopper to fill for a product line. */
12
+ export interface LineTextField {
13
+ /** The full line property key, e.g. `app.engraving.text`. */
14
+ key: string;
15
+ /** The label the shopper sees, e.g. `Engraving text`. */
16
+ label: string;
17
+ required: boolean;
18
+ maxLength: number;
19
+ }
20
+
21
+ /** One live registration: what one block or app declared for a product. */
22
+ interface LineTextRegistration {
23
+ /** Identity of this registration; its unregister removes exactly this one. */
24
+ id: object;
25
+ /** Optional caller-chosen name: registering again under it replaces its earlier registration. */
26
+ owner: string | null;
27
+ fields: LineTextField[];
28
+ }
29
+
30
+ interface LineTextEntry {
31
+ /** Every live registration for the product, oldest first. */
32
+ registrations: LineTextRegistration[];
33
+ /** What the shopper typed, by field key. */
34
+ values: Record<string, string>;
35
+ }
36
+
37
+ interface LineTextState {
38
+ /** Keyed by `lineTextKey(shopId, productId)`. Never persisted. */
39
+ entries: Record<string, LineTextEntry>;
40
+ /**
41
+ * Declares the fields a product page renders. Call it only from an effect and
42
+ * only for fields that are on screen; the returned function removes this
43
+ * registration only and is meant as the effect cleanup. Several registrations
44
+ * can be live for one product (two apps, or one block in two places): the
45
+ * product's fields are their union, and when two declare the same key the most
46
+ * recent declaration wins. Typed values are kept for every key that is still
47
+ * declared. Pass `owner` to make a later call with the same name replace this
48
+ * registration instead of adding another.
49
+ */
50
+ registerLineText: (shopId: string, productId: string, fields: LineTextField[], owner?: string) => () => void;
51
+ /** Stores what the shopper typed. The text is cleaned when it is read, not here. */
52
+ setLineText: (shopId: string, productId: string, key: string, value: string) => void;
53
+ /** Empties the typed values of a product and keeps every live registration. */
54
+ clearLineText: (shopId: string, productId: string) => void;
55
+ }
56
+
57
+ /** The text a line carries once cleaned, ready to write on a cart line. */
58
+ export interface ReadLineText {
59
+ /** Non-blank cleaned values, keys sorted ascending. */
60
+ properties: Record<string, string>;
61
+ /** `{ key: label }` for exactly the keys in `properties`. */
62
+ labels: Record<string, string>;
63
+ /** Required fields whose cleaned value is blank, in key order. */
64
+ missingRequired: LineTextField[];
65
+ }
66
+
67
+ export function lineTextKey(shopId: string, productId: string): string {
68
+ return `${shopId}:${productId}`;
69
+ }
70
+
71
+ // Control characters, line and paragraph separators, and bidirectional overrides.
72
+ const UNSAFE_CHARACTERS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g;
73
+
74
+ function effectiveMaxLength(maxLength: number): number {
75
+ if (!Number.isInteger(maxLength) || maxLength <= 0) {
76
+ return LINE_TEXT_DEFAULT_MAX_LENGTH;
77
+ }
78
+
79
+ return Math.min(maxLength, LINE_TEXT_MAX_LENGTH);
80
+ }
81
+
82
+ function cleanValue(raw: unknown, maxLength: number): string {
83
+ if (typeof raw !== 'string') {
84
+ return '';
85
+ }
86
+
87
+ // Cut before deciding blank, so invisible leading characters cannot hide that
88
+ // the text kept nothing visible. Ordinary surrounding whitespace is trimmed first
89
+ // so it does not use up the length budget.
90
+ const cut = Array.from(raw.replace(UNSAFE_CHARACTERS, '').trim())
91
+ .slice(0, effectiveMaxLength(maxLength))
92
+ .join('')
93
+ .trimEnd();
94
+
95
+ return isBlankText(cut) ? '' : cut;
96
+ }
97
+
98
+ /** The product's fields: the union of its live registrations, the latest declaration of a key winning. */
99
+ function effectiveFields(registrations: LineTextRegistration[]): LineTextField[] {
100
+ const byKey = new Map<string, LineTextField>();
101
+
102
+ for (const registration of registrations) {
103
+ for (const field of registration.fields) {
104
+ byKey.set(field.key, field);
105
+ }
106
+ }
107
+
108
+ return [...byKey.values()];
109
+ }
110
+
111
+ /** The entry for these registrations, keeping only the values of keys still declared; null when none are live. */
112
+ function entryFor(registrations: LineTextRegistration[], values: Record<string, string>): LineTextEntry | null {
113
+ if (registrations.length === 0) {
114
+ return null;
115
+ }
116
+
117
+ const kept: Record<string, string> = {};
118
+
119
+ for (const field of effectiveFields(registrations)) {
120
+ if (typeof values[field.key] === 'string') {
121
+ kept[field.key] = values[field.key];
122
+ }
123
+ }
124
+
125
+ return { registrations, values: kept };
126
+ }
127
+
128
+ function withEntry(
129
+ entries: Record<string, LineTextEntry>,
130
+ entryKey: string,
131
+ entry: LineTextEntry | null,
132
+ ): Record<string, LineTextEntry> {
133
+ const { [entryKey]: removed, ...rest } = entries;
134
+
135
+ return entry === null ? rest : { ...rest, [entryKey]: entry };
136
+ }
137
+
138
+ /**
139
+ * ONE store for what a shopper typed into app-declared line text fields. It is
140
+ * deliberately not persisted: a reload starts empty, and only the cart lines
141
+ * (which already carry the text) are kept.
142
+ */
143
+ export const useLineTextStore = create<LineTextState>()((set, get) => ({
144
+ entries: {},
145
+ registerLineText: (shopId, productId, fields, owner) => {
146
+ const entryKey = lineTextKey(shopId, productId);
147
+ const registration: LineTextRegistration = {
148
+ id: {},
149
+ owner: owner ?? null,
150
+ fields: fields.filter((field) => typeof field.key === 'string' && field.key.trim() !== ''),
151
+ };
152
+
153
+ set((state) => {
154
+ const previous = state.entries[entryKey];
155
+ const live = (previous?.registrations ?? []).filter((entry) => owner == null || entry.owner !== owner);
156
+
157
+ return { entries: withEntry(state.entries, entryKey, entryFor([...live, registration], previous?.values ?? {})) };
158
+ });
159
+
160
+ return () => {
161
+ set((state) => {
162
+ const previous = state.entries[entryKey];
163
+
164
+ if (!previous || !previous.registrations.some((entry) => entry.id === registration.id)) {
165
+ return state;
166
+ }
167
+
168
+ const live = previous.registrations.filter((entry) => entry.id !== registration.id);
169
+
170
+ return { entries: withEntry(state.entries, entryKey, entryFor(live, previous.values)) };
171
+ });
172
+ };
173
+ },
174
+ setLineText: (shopId, productId, key, value) => {
175
+ const entryKey = lineTextKey(shopId, productId);
176
+ const entry = get().entries[entryKey];
177
+
178
+ if (!entry || !effectiveFields(entry.registrations).some((field) => field.key === key)) {
179
+ return;
180
+ }
181
+
182
+ set((state) => ({
183
+ entries: { ...state.entries, [entryKey]: { ...entry, values: { ...entry.values, [key]: value } } },
184
+ }));
185
+ },
186
+ clearLineText: (shopId, productId) => {
187
+ const entryKey = lineTextKey(shopId, productId);
188
+ const entry = get().entries[entryKey];
189
+
190
+ if (!entry || Object.keys(entry.values).length === 0) {
191
+ return;
192
+ }
193
+
194
+ set((state) => ({ entries: { ...state.entries, [entryKey]: { ...entry, values: {} } } }));
195
+ },
196
+ }));
197
+
198
+ /**
199
+ * The cleaned text for a product line: trimmed, control characters stripped and
200
+ * cut to each field's `maxLength` in code points. A product with nothing
201
+ * registered gives empty results, which is the behaviour of a kit with no line
202
+ * text at all.
203
+ */
204
+ export function readLineText(shopId: string, productId: string): ReadLineText {
205
+ const entry = useLineTextStore.getState().entries[lineTextKey(shopId, productId)];
206
+ const result: ReadLineText = { properties: {}, labels: {}, missingRequired: [] };
207
+
208
+ if (!entry) {
209
+ return result;
210
+ }
211
+
212
+ const fields = effectiveFields(entry.registrations).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
213
+
214
+ for (const field of fields) {
215
+ const value = cleanValue(entry.values[field.key], field.maxLength);
216
+
217
+ if (value === '') {
218
+ if (field.required) {
219
+ result.missingRequired.push(field);
220
+ }
221
+
222
+ continue;
223
+ }
224
+
225
+ result.properties[field.key] = value;
226
+ result.labels[field.key] = field.label;
227
+ }
228
+
229
+ return result;
230
+ }
package/types/cart.ts CHANGED
@@ -18,4 +18,15 @@ export interface CartItem {
18
18
  variant_id?: string | null;
19
19
  /** e.g. "Denim" — the selected variant's display name, when this line has one. */
20
20
  variant_title?: string | null;
21
+ /**
22
+ * Text the shopper typed for an app-declared line field, `{ key: text }` with
23
+ * keys in ascending order. Absent on a line with no such text.
24
+ */
25
+ properties?: Record<string, unknown>;
26
+ /**
27
+ * `{ key: label }` for the declared keys in `properties`. A key without a label
28
+ * is never shown to the shopper. Kept on the line only; the kit never sends it
29
+ * to a server.
30
+ */
31
+ property_labels?: Record<string, string>;
21
32
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Whether text is blank: empty, or made only of whitespace, separator and format
3
+ * characters (zero-width spaces and joiners, directional marks, the word joiner,
4
+ * the byte order mark, the soft hyphen, tag characters). Matches the server's
5
+ * whitespace, separator and format-character rule. Internal: not part of the
6
+ * public barrels. It only classifies text and never edits it: any character
7
+ * outside that class (letters, digits, punctuation, emoji) makes the text
8
+ * non-blank, and emoji sequences that use a zero-width joiner stay intact.
9
+ */
10
+ const BLANK_CHARACTERS = new RegExp('[\\s\\p{Cf}]', 'gu');
11
+
12
+ export function isBlankText(text: unknown): boolean {
13
+ return typeof text !== 'string' || text.replace(BLANK_CHARACTERS, '') === '';
14
+ }
@@ -0,0 +1,62 @@
1
+ import { isBlankText } from './blank-text';
2
+
3
+ /**
4
+ * Key endings that carry a bearer link or token, dot-prefixed so a derived key
5
+ * `app.<ns>.<field key>` ends in one only when the field key is exactly `claim`,
6
+ * `reschedule_url` or `cancel_url`. Parity constant: keep it equal to the
7
+ * platform list. Internal: not part of the public barrels.
8
+ */
9
+ const BEARER_PROPERTY_SUFFIXES = ['.claim', '.reschedule_url', '.cancel_url'] as const;
10
+
11
+ export interface LineProperty {
12
+ key: string;
13
+ label: string;
14
+ value: string;
15
+ }
16
+
17
+ interface LinePropertiesSource {
18
+ properties?: Record<string, unknown> | null;
19
+ property_labels?: Record<string, unknown> | null;
20
+ }
21
+
22
+ /**
23
+ * The text a shopper typed on a line, as rows ready to render: only keys that
24
+ * carry a declared label, with a non-blank string value, sorted by key. A key
25
+ * without a label, a bearer key, a blank value and a non-string value never
26
+ * appear, so a line from any source shows nothing it was not declared to show.
27
+ * Render `label` and `value` as text.
28
+ *
29
+ * @example
30
+ * visibleLineProperties(item).map(({ key, label, value }) => <p key={key}>{label}: {value}</p>)
31
+ */
32
+ export function visibleLineProperties(source: LinePropertiesSource): LineProperty[] {
33
+ const properties = source.properties;
34
+ const labels = source.property_labels;
35
+
36
+ if (!properties || !labels) {
37
+ return [];
38
+ }
39
+
40
+ const rows: LineProperty[] = [];
41
+
42
+ for (const key of Object.keys(properties).sort()) {
43
+ const value = properties[key];
44
+ const label = labels[key];
45
+
46
+ if (typeof value !== 'string' || isBlankText(value)) {
47
+ continue;
48
+ }
49
+
50
+ if (typeof label !== 'string' || isBlankText(label)) {
51
+ continue;
52
+ }
53
+
54
+ if (BEARER_PROPERTY_SUFFIXES.some((suffix) => key.endsWith(suffix))) {
55
+ continue;
56
+ }
57
+
58
+ rows.push({ key, label, value });
59
+ }
60
+
61
+ return rows;
62
+ }
package/CHANGELOG.md DELETED
@@ -1,45 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `@usequeek/theme-kit`. See the README for the versioning
4
- policy. Versions before this file started are in the git tags.
5
-
6
- ## 0.1.25 - 2026-10-14
7
-
8
- ### Packaging
9
-
10
- - The customer SDK moved to the Queek scope: the peer dependency is now
11
- `@usequeek/client-sdk` `^0.4.0` (was `@queekai/client-sdk`, which is deprecated).
12
- Install `@usequeek/client-sdk` alongside the kit and remove `@queekai/client-sdk`.
13
-
14
- ## 0.1.24 - 2026-10-12
15
-
16
- ### Added
17
-
18
- - `ApiRequestError` and `ApiTimeoutError` carry a stable `code` (`unauthenticated`,
19
- `forbidden`, `not_found`, `rate_limited`, `client_error`, `server_error`,
20
- `timeout`) and a `retryable` flag. `ApiRequestError` also carries `requestId`,
21
- `retryAfterSeconds` and `detail`. Their messages say what happened and what to do
22
- next, and never include the query string.
23
- - The fetching hooks (`useProducts`, `useProductBySlug`, `useShop`, `usePosts`,
24
- `useReviews`, `useProductQuestions`, `useCategories`, `useRelatedProducts`,
25
- `useProductDetail`) return an `error` field (`Error | null`).
26
- - One warning, in production builds only, when `QUEEK_API_URL` is not set.
27
- - JSDoc with examples on the hooks, providers, clients, stores and the main
28
- components.
29
-
30
- ### Fixed
31
-
32
- - `useProducts` and `useShop` no longer leave an unhandled promise rejection when
33
- the request fails.
34
- - Slugs and ids are URL-encoded when they are placed in a request path
35
- (products, collections, related products, questions, orders).
36
- - Google sign-in no longer logs the sign-in URL or the response body.
37
- - Hook errors raised outside a provider name the missing provider and the fix.
38
-
39
- ### Packaging
40
-
41
- - The published package no longer contains `tests/` or `scripts/`.
42
- - Peer dependency `@queekai/client-sdk` is `^0.3.1 || ^0.4.0` (was `*`); `engines.node` is
43
- `>=22`; `bugs`, `homepage`, `author` and `keywords` are set; releases publish
44
- with npm provenance.
45
- - The licence names the legal entity, Queekx Technology LTD.