@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 +5 -6
- package/client.ts +1 -0
- package/components/cart/default-cart-shell.tsx +4 -0
- package/index.ts +1 -0
- package/package.json +1 -2
- package/stores/cart-store.ts +64 -5
- package/stores/line-text-store.ts +230 -0
- package/types/cart.ts +11 -0
- package/utils/blank-text.ts +14 -0
- package/utils/line-properties.ts +62 -0
- package/CHANGELOG.md +0 -45
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
|
|
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
|
|
688
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
],
|
package/stores/cart-store.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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.
|