@usequeek/theme-kit 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/client.ts +1 -0
- package/components/cart/default-cart-shell.tsx +4 -0
- package/hooks/use-auth-flow.tsx +1 -1
- package/index.ts +1 -0
- package/package.json +6 -6
- package/sdk/error-handler.ts +1 -1
- package/sdk/queek-client.ts +1 -1
- 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
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to `@usequeek/theme-kit`. See the README for the versioning
|
|
4
4
|
policy. Versions before this file started are in the git tags.
|
|
5
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
|
+
|
|
6
14
|
## 0.1.24 - 2026-10-12
|
|
7
15
|
|
|
8
16
|
### Added
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ end to end.
|
|
|
12
12
|
- **Next.js 15 or later (App Router)**, `react` and `react-dom` 19 or later. The
|
|
13
13
|
kit imports `next` itself (links, navigation, server request headers), so it
|
|
14
14
|
runs inside a Next.js app, not in a bare React app.
|
|
15
|
-
- `@
|
|
15
|
+
- `@usequeek/client-sdk` `^0.4.0`, the Queek customer SDK the auth and checkout
|
|
16
16
|
hooks call.
|
|
17
17
|
- Node.js 22 or later (the version CI tests on).
|
|
18
18
|
- TypeScript with `moduleResolution: bundler` and `jsx: react-jsx`.
|
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/hooks/use-auth-flow.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
5
|
-
import { QueekSdkError, type EmailVerifyOtpResponse } from '@
|
|
5
|
+
import { QueekSdkError, type EmailVerifyOtpResponse } from '@usequeek/client-sdk';
|
|
6
6
|
import { useStorefront, useThemeStrings } from '../provider';
|
|
7
7
|
import { getQueekClient, setAuthTokens, QUEEK_AUTH_URL } from '../sdk/queek-client';
|
|
8
8
|
import { useAuthModalStore } from '../stores/auth-modal-store';
|
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.26",
|
|
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.",
|
|
@@ -46,25 +46,25 @@
|
|
|
46
46
|
"!scripts"
|
|
47
47
|
],
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@usequeek/client-sdk": "^0.4.0",
|
|
49
50
|
"next": ">=15",
|
|
50
51
|
"react": ">=19",
|
|
51
|
-
"react-dom": ">=19"
|
|
52
|
-
"@queekai/client-sdk": "^0.3.1 || ^0.4.0"
|
|
52
|
+
"react-dom": ">=19"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=22"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"
|
|
58
|
+
"html-to-image": "^1.11.0",
|
|
59
59
|
"react-markdown": "^10.0.0",
|
|
60
60
|
"remark-gfm": "^4.0.0",
|
|
61
|
-
"
|
|
61
|
+
"zustand": "^5.0.11"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@queekai/client-sdk": "^0.3.0",
|
|
65
64
|
"@types/node": "^20",
|
|
66
65
|
"@types/react": "^19",
|
|
67
66
|
"@types/react-dom": "^19",
|
|
67
|
+
"@usequeek/client-sdk": "^0.4.0",
|
|
68
68
|
"jsdom": "^28.1.0",
|
|
69
69
|
"next": "^16.0.0",
|
|
70
70
|
"react": "^19.2.3",
|
package/sdk/error-handler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { QueekSdkError } from '@
|
|
3
|
+
import { QueekSdkError } from '@usequeek/client-sdk';
|
|
4
4
|
import { useAuthModalStore } from '../stores/auth-modal-store';
|
|
5
5
|
import { useUserStore } from '../stores/user-store';
|
|
6
6
|
import { isReauthSuppressed } from './reauth-guard';
|
package/sdk/queek-client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@
|
|
3
|
+
import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@usequeek/client-sdk';
|
|
4
4
|
import { triggerReauth } from './error-handler';
|
|
5
5
|
import { getActivePlatform } from './platform';
|
|
6
6
|
|
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
|
+
}
|