@usequeek/theme-kit 0.1.25 → 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/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.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.",
@@ -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
+ }