@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import {
7
+ DesignerPlaceholder,
8
+ getProductName,
9
+ pickProductField,
10
+ readToggle,
11
+ useThemeProduct
12
+ } from './product-block-utils';
13
+
14
+ const ALLOWED_TAGS = ['h1', 'h2', 'h3', 'div', 'span'];
15
+ const ALLOWED_BRAND_POSITIONS = ['before', 'after'];
16
+
17
+ /**
18
+ * Commerce sends the brand as a bare string on some payloads and as a
19
+ * `{ name }` record on others, so both shapes resolve to the same caption.
20
+ */
21
+ const readBrandName = (value: unknown): string => {
22
+ if (typeof value === 'string') return value.trim();
23
+ if (value && typeof value === 'object') {
24
+ const record = value as Record<string, unknown>;
25
+ const name = record.name ?? record.label ?? record.title;
26
+ if (typeof name === 'string') return name.trim();
27
+ }
28
+ return '';
29
+ };
30
+
31
+ /**
32
+ * Functional PDP block: the product name. Content stays commerce-driven; the
33
+ * editor owns the heading level (one h1 per page), the optional brand caption,
34
+ * line clamping and — through named parts — the styling.
35
+ * Properties: tag, showBrand, brandPosition, maxLines.
36
+ */
37
+ const ProductTitleBlock = ({
38
+ block,
39
+ currentBreakpoint = 'desktop',
40
+ isDesigner = false
41
+ }: BlockRendererProps) => {
42
+ const product = useThemeProduct();
43
+
44
+ const tagValue = String(
45
+ getResponsiveValue(block.properties?.tag, currentBreakpoint, 'h1')
46
+ );
47
+ const Tag = (
48
+ ALLOWED_TAGS.includes(tagValue) ? tagValue : 'h1'
49
+ ) as React.ElementType;
50
+
51
+ const showBrand = readToggle(
52
+ block.properties?.showBrand,
53
+ currentBreakpoint,
54
+ false
55
+ );
56
+ const brandPositionValue = String(
57
+ getResponsiveValue(block.properties?.brandPosition, currentBreakpoint, 'before')
58
+ );
59
+ const brandPosition = ALLOWED_BRAND_POSITIONS.includes(brandPositionValue)
60
+ ? brandPositionValue
61
+ : 'before';
62
+ const maxLines = Number(
63
+ getResponsiveValue(block.properties?.maxLines, currentBreakpoint, 0)
64
+ );
65
+
66
+ const title = getProductName(product);
67
+ const brand = showBrand
68
+ ? readBrandName(pickProductField(product, 'brand'))
69
+ : '';
70
+
71
+ if (!title) {
72
+ return isDesigner ? (
73
+ <DesignerPlaceholder
74
+ label="Product title (no product)"
75
+ attribute="data-product-title"
76
+ />
77
+ ) : null;
78
+ }
79
+
80
+ // 0 means "no clamp": the clamp declarations are only added when a brand
81
+ // asked for one, so an unclamped title keeps its original box model.
82
+ const clampLines = Number.isFinite(maxLines) && maxLines > 0 ? maxLines : 0;
83
+ const clampStyle: React.CSSProperties = clampLines
84
+ ? {
85
+ display: '-webkit-box',
86
+ WebkitBoxOrient: 'vertical',
87
+ WebkitLineClamp: clampLines,
88
+ overflow: 'hidden'
89
+ }
90
+ : {};
91
+
92
+ const brandNode = brand ? (
93
+ <span
94
+ data-product-title-brand=""
95
+ {...partAttrs('brand')}
96
+ style={{
97
+ display: 'block',
98
+ fontSize: '0.75em',
99
+ fontWeight: 'inherit',
100
+ opacity: 0.7
101
+ }}
102
+ >
103
+ {brand}
104
+ </span>
105
+ ) : null;
106
+
107
+ return (
108
+ <Tag
109
+ data-product-title=""
110
+ {...partAttrs('title')}
111
+ style={{
112
+ // Reset the browser heading defaults so the editor styles on the block
113
+ // wrapper are what actually decides size, weight and spacing.
114
+ margin: 0,
115
+ color: 'inherit',
116
+ fontSize: 'inherit',
117
+ fontWeight: 'inherit',
118
+ lineHeight: 'inherit',
119
+ ...clampStyle
120
+ }}
121
+ >
122
+ {brandPosition === 'before' && brandNode}
123
+ {title}
124
+ {brandPosition === 'after' && brandNode}
125
+ </Tag>
126
+ );
127
+ };
128
+
129
+ export default ProductTitleBlock;
@@ -0,0 +1,297 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { useSession } from 'next-auth/react';
5
+ import {
6
+ useGetProfileInfoQuery,
7
+ useUpdateProfileMutation
8
+ } from '@akinon/next/data/client/account';
9
+ import { getResponsiveValue, partAttrs } from '../utils';
10
+ import { BlockRendererProps } from './block-renderer-registry';
11
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
12
+
13
+ interface ProfileLike {
14
+ first_name?: string;
15
+ last_name?: string;
16
+ email?: string;
17
+ phone?: string;
18
+ date_of_birth?: string;
19
+ gender?: string | null;
20
+ sms_allowed?: boolean;
21
+ email_allowed?: boolean;
22
+ }
23
+
24
+ const EMPTY_FORM = {
25
+ first_name: '',
26
+ last_name: '',
27
+ phone: '',
28
+ date_of_birth: ''
29
+ };
30
+
31
+ /**
32
+ * Where the submit button sits in the form's flex column. `stretch` maps to no
33
+ * declaration at all: the button has always inherited the container's
34
+ * alignment, and writing the value out would override a brand that centres its
35
+ * form from custom CSS.
36
+ */
37
+ const SUBMIT_ALIGNMENTS: Record<string, string> = {
38
+ left: 'flex-start',
39
+ center: 'center',
40
+ right: 'flex-end'
41
+ };
42
+
43
+ /**
44
+ * Functional account block: the shopper's profile form. Profile CONTENT stays
45
+ * commerce-driven (account profile service); the editor owns every caption,
46
+ * which fields are exposed and the styling.
47
+ *
48
+ * E-mail is rendered read-only on purpose: `updateProfile` has no e-mail field
49
+ * — commerce moves it through a separate verified `updateEmail` flow — so an
50
+ * editable input here would silently discard what the shopper typed.
51
+ * Properties: submitLabel, submitAlign, showPhone, showEmail, showBirthDate,
52
+ * showLabels, firstNameLabel, lastNameLabel, emailLabel, phoneLabel,
53
+ * birthDateLabel, helpLabel, errorMessage, successMessage.
54
+ * Parts: form, field, label, input, help, error, success, submit.
55
+ */
56
+ const ProfileFormBlock = ({
57
+ block,
58
+ currentBreakpoint = 'desktop',
59
+ isDesigner = false
60
+ }: BlockRendererProps) => {
61
+ const text = (key: string, fallback: string) =>
62
+ String(
63
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
64
+ );
65
+
66
+ const submitLabel = text('submitLabel', 'Save changes');
67
+ const submitAlign = text('submitAlign', 'stretch');
68
+ const firstNameLabel = text('firstNameLabel', 'First name');
69
+ const lastNameLabel = text('lastNameLabel', 'Last name');
70
+ const emailLabel = text('emailLabel', 'E-mail');
71
+ const phoneLabel = text('phoneLabel', 'Phone');
72
+ const birthDateLabel = text('birthDateLabel', 'Date of birth');
73
+ const helpLabel = text('helpLabel', '');
74
+ const errorMessage = text('errorMessage', 'Your profile could not be saved.');
75
+ const successMessage = text(
76
+ 'successMessage',
77
+ 'Your profile has been saved.'
78
+ );
79
+ const showPhone = readToggle(
80
+ block.properties?.showPhone,
81
+ currentBreakpoint,
82
+ true
83
+ );
84
+ const showEmail = readToggle(
85
+ block.properties?.showEmail,
86
+ currentBreakpoint,
87
+ true
88
+ );
89
+ const showBirthDate = readToggle(
90
+ block.properties?.showBirthDate,
91
+ currentBreakpoint,
92
+ true
93
+ );
94
+ const showLabels = readToggle(
95
+ block.properties?.showLabels,
96
+ currentBreakpoint,
97
+ true
98
+ );
99
+
100
+ const { status: sessionStatus } = useSession();
101
+ const isAuthenticated = sessionStatus === 'authenticated';
102
+
103
+ // Guests have no profile endpoint; skipping avoids a guaranteed 403 on every
104
+ // page that hosts this block.
105
+ const { data: profile, isLoading } = useGetProfileInfoQuery(undefined, {
106
+ skip: !isAuthenticated
107
+ });
108
+ const [updateProfile, { isLoading: isSaving }] = useUpdateProfileMutation();
109
+
110
+ const [values, setValues] = useState(EMPTY_FORM);
111
+ const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
112
+
113
+ const loadedProfile = (profile ?? null) as ProfileLike | null;
114
+
115
+ useEffect(() => {
116
+ if (!loadedProfile) return;
117
+
118
+ setValues({
119
+ first_name: loadedProfile.first_name || '',
120
+ last_name: loadedProfile.last_name || '',
121
+ phone: loadedProfile.phone || '',
122
+ // A date input's value must be ISO 8601 per the HTML spec, so it is fed
123
+ // the raw commerce value rather than a display-formatted date; the
124
+ // control localises its own presentation.
125
+ date_of_birth: String(loadedProfile.date_of_birth || '').slice(0, 10)
126
+ });
127
+ }, [loadedProfile]);
128
+
129
+ const setField =
130
+ (key: keyof typeof EMPTY_FORM) =>
131
+ (event: React.ChangeEvent<HTMLInputElement>) => {
132
+ const nextValue = event.target.value;
133
+ setStatus('idle');
134
+ setValues((current) => ({ ...current, [key]: nextValue }));
135
+ };
136
+
137
+ const submit = async (event: React.FormEvent) => {
138
+ event.preventDefault();
139
+
140
+ // A submit inside the designer must never write to the shopper's account.
141
+ if (isDesigner || isSaving) return;
142
+
143
+ setStatus('idle');
144
+
145
+ try {
146
+ await updateProfile({
147
+ first_name: values.first_name,
148
+ last_name: values.last_name,
149
+ phone: values.phone,
150
+ date_of_birth: values.date_of_birth,
151
+ // The endpoint replaces the whole profile record, so the fields this
152
+ // block does not render are echoed back untouched — otherwise saving a
153
+ // name would silently clear the shopper's notification consents.
154
+ gender: loadedProfile?.gender ?? '',
155
+ sms_allowed: loadedProfile?.sms_allowed ?? false,
156
+ email_allowed: loadedProfile?.email_allowed ?? false
157
+ }).unwrap();
158
+
159
+ setStatus('saved');
160
+ } catch {
161
+ setStatus('error');
162
+ }
163
+ };
164
+
165
+ if (!isDesigner && !isAuthenticated) return null;
166
+
167
+ if (isDesigner && !loadedProfile && !isLoading) {
168
+ return (
169
+ <DesignerPlaceholder
170
+ label="Profile form (no profile data)"
171
+ attribute="data-profile-form"
172
+ />
173
+ );
174
+ }
175
+
176
+ const inputStyle: React.CSSProperties = {
177
+ width: '100%',
178
+ border: '1px solid var(--profile-form-border, #d1d5db)',
179
+ background: 'transparent',
180
+ color: 'inherit',
181
+ padding: '10px 12px',
182
+ outline: 'none'
183
+ };
184
+
185
+ const field = (
186
+ key: keyof typeof EMPTY_FORM,
187
+ label: string,
188
+ type: string,
189
+ autoComplete: string
190
+ ) => (
191
+ <label
192
+ data-profile-form-field={key}
193
+ {...partAttrs('field')}
194
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
195
+ >
196
+ {showLabels && <span {...partAttrs('label')}>{label}</span>}
197
+ <input
198
+ type={type}
199
+ name={key}
200
+ value={values[key]}
201
+ onChange={setField(key)}
202
+ autoComplete={autoComplete}
203
+ // The caption is still announced when it is hidden visually, otherwise
204
+ // hiding labels would leave a row of unnamed inputs.
205
+ aria-label={showLabels ? undefined : label}
206
+ {...partAttrs('input')}
207
+ style={inputStyle}
208
+ />
209
+ </label>
210
+ );
211
+
212
+ return (
213
+ <form
214
+ data-profile-form=""
215
+ data-profile-form-state={isSaving ? 'busy' : status}
216
+ onSubmit={submit}
217
+ {...partAttrs('form', { loading: isSaving, invalid: status === 'error' })}
218
+ style={{
219
+ display: 'flex',
220
+ flexDirection: 'column',
221
+ gap: 'var(--profile-form-gap, 12px)'
222
+ }}
223
+ >
224
+ {field('first_name', firstNameLabel, 'text', 'given-name')}
225
+ {field('last_name', lastNameLabel, 'text', 'family-name')}
226
+
227
+ {showEmail && (
228
+ <label
229
+ data-profile-form-field="email"
230
+ {...partAttrs('field')}
231
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
232
+ >
233
+ {showLabels && <span {...partAttrs('label')}>{emailLabel}</span>}
234
+ <input
235
+ type="email"
236
+ name="email"
237
+ value={loadedProfile?.email || ''}
238
+ readOnly
239
+ disabled
240
+ autoComplete="email"
241
+ aria-label={showLabels ? undefined : emailLabel}
242
+ {...partAttrs('input', { disabled: true })}
243
+ style={{ ...inputStyle, opacity: 0.6 }}
244
+ />
245
+ </label>
246
+ )}
247
+
248
+ {showPhone && field('phone', phoneLabel, 'tel', 'tel')}
249
+ {showBirthDate && field('date_of_birth', birthDateLabel, 'date', 'bday')}
250
+
251
+ {helpLabel && (
252
+ <div data-profile-form-help="" {...partAttrs('help')}>
253
+ {helpLabel}
254
+ </div>
255
+ )}
256
+
257
+ {status === 'error' && (
258
+ <div
259
+ data-profile-form-error=""
260
+ role="alert"
261
+ {...partAttrs('error', { invalid: true })}
262
+ >
263
+ {errorMessage}
264
+ </div>
265
+ )}
266
+
267
+ {status === 'saved' && (
268
+ <div data-profile-form-success="" role="status" {...partAttrs('success')}>
269
+ {successMessage}
270
+ </div>
271
+ )}
272
+
273
+ <button
274
+ type="submit"
275
+ data-profile-form-submit=""
276
+ disabled={isSaving}
277
+ aria-busy={isSaving}
278
+ {...partAttrs('submit', { loading: isSaving })}
279
+ style={{
280
+ padding: '12px 20px',
281
+ background: 'var(--profile-form-cta-bg, #111827)',
282
+ color: 'var(--profile-form-cta-color, #ffffff)',
283
+ border: 'none',
284
+ cursor: isSaving ? 'not-allowed' : 'pointer',
285
+ opacity: isSaving ? 0.7 : 1,
286
+ ...(SUBMIT_ALIGNMENTS[submitAlign]
287
+ ? { alignSelf: SUBMIT_ALIGNMENTS[submitAlign] }
288
+ : null)
289
+ }}
290
+ >
291
+ {submitLabel}
292
+ </button>
293
+ </form>
294
+ );
295
+ };
296
+
297
+ export default ProfileFormBlock;
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The browser's own "recently viewed" list.
5
+ *
6
+ * WHY A COPY OF THE PRODUCT IS STORED, NOT JUST ITS ID
7
+ * ----------------------------------------------------
8
+ * The right thing is to store ids and resolve them at read time, so the strip
9
+ * always shows today's price, name and photo. v1/v2 core exposes exactly one
10
+ * product read endpoint — `/product/{pk}/` (`useGetProductByPkQuery`) — and it
11
+ * takes ONE pk. There is no `pk__in`, no product-list-by-id, and the
12
+ * collection/widget endpoint the block's other source uses is addressed by
13
+ * slug, not by pk. Resolving a strip of up to 24 cards would therefore mean up
14
+ * to 24 separate PDP-sized round trips fired from every page the strip sits
15
+ * on, from a `useEffect` whose length is not known until storage has been
16
+ * read (so it cannot even be expressed as a fixed set of hooks). That is not a
17
+ * trade a storefront can make for a secondary strip.
18
+ *
19
+ * So the minimum a card needs is copied instead: name, url, image, the RAW
20
+ * price amount and its currency.
21
+ *
22
+ * WHAT GOES STALE, AND HOW IT IS CONTAINED
23
+ * ----------------------------------------
24
+ * - price: a discount that started after the view is not reflected. The RAW
25
+ * amount is stored rather than a formatted string, so at least the money is
26
+ * re-formatted at read time with the CURRENT locale and money format and a
27
+ * shopper who switches language never sees a frozen "1,299.00" string.
28
+ * - name / image: a re-merchandised product shows its old title or photo.
29
+ * - availability and the URL: a delisted product can still appear, and its
30
+ * link can 404.
31
+ * All four are bounded by `maxAgeDays`: entries older than the author's window
32
+ * are dropped on read, so the copy has a shelf life rather than living
33
+ * forever. The card is a navigation aid; the PDP it links to is the source of
34
+ * truth and re-reads everything from commerce.
35
+ *
36
+ * Every storage access is wrapped: reading `window.localStorage` THROWS (it
37
+ * does not return null) in a Safari private window and under a "block all
38
+ * cookies" policy, `getItem` can hold another app's payload, and `setItem`
39
+ * throws `QuotaExceededError` on a full origin.
40
+ */
41
+
42
+ export interface RecentlyViewedEntry {
43
+ /** Commerce pk, as text — the identity used for dedupe and exclusion. */
44
+ pk: string;
45
+ name: string;
46
+ url: string;
47
+ image: string;
48
+ /** RAW commerce amount. Formatting happens at read time, never here. */
49
+ price: string;
50
+ currency: string;
51
+ /** Epoch ms of the view, so `maxAgeDays` can expire the entry. */
52
+ viewedAt: number;
53
+ }
54
+
55
+ export const DEFAULT_RECENTLY_VIEWED_KEY = 'pz-recently-viewed';
56
+
57
+ const DAY_MS = 24 * 60 * 60 * 1000;
58
+
59
+ const toText = (value: unknown): string =>
60
+ typeof value === 'string'
61
+ ? value
62
+ : typeof value === 'number' && Number.isFinite(value)
63
+ ? String(value)
64
+ : '';
65
+
66
+ const getStorage = (): Storage | null => {
67
+ try {
68
+ // The property access itself is what throws when storage is blocked, so it
69
+ // has to be inside the try rather than guarded by a typeof check.
70
+ return typeof window === 'undefined' ? null : window.localStorage ?? null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
76
+ const normalizeEntry = (value: unknown): RecentlyViewedEntry | null => {
77
+ if (!value || typeof value !== 'object') return null;
78
+
79
+ const record = value as Record<string, unknown>;
80
+ const pk = toText(record.pk);
81
+ // Without a pk there is nothing to dedupe on and nothing to exclude the
82
+ // viewed product by, so the row is unusable rather than merely incomplete.
83
+ if (!pk) return null;
84
+
85
+ const viewedAt = Number(record.viewedAt);
86
+
87
+ return {
88
+ pk,
89
+ name: toText(record.name),
90
+ url: toText(record.url),
91
+ image: toText(record.image),
92
+ price: toText(record.price),
93
+ currency: toText(record.currency),
94
+ viewedAt: Number.isFinite(viewedAt) ? viewedAt : 0
95
+ };
96
+ };
97
+
98
+ /**
99
+ * Most recent first. `maxAgeDays` of 0 disables expiry — used internally by
100
+ * the writer, which must not silently drop the entries it is about to rewrite.
101
+ */
102
+ export const readRecentlyViewed = (
103
+ storageKey: string,
104
+ maxAgeDays = 0
105
+ ): RecentlyViewedEntry[] => {
106
+ const storage = getStorage();
107
+ if (!storage || !storageKey) return [];
108
+
109
+ let raw: string | null = null;
110
+ try {
111
+ raw = storage.getItem(storageKey);
112
+ } catch {
113
+ return [];
114
+ }
115
+ if (!raw) return [];
116
+
117
+ let parsed: unknown;
118
+ try {
119
+ parsed = JSON.parse(raw);
120
+ } catch {
121
+ // Someone else's key, or a half-written value: treat as no history rather
122
+ // than taking the page down.
123
+ return [];
124
+ }
125
+ if (!Array.isArray(parsed)) return [];
126
+
127
+ const cutoff = maxAgeDays > 0 ? Date.now() - maxAgeDays * DAY_MS : null;
128
+
129
+ return parsed
130
+ .map(normalizeEntry)
131
+ .filter((entry): entry is RecentlyViewedEntry => {
132
+ if (!entry) return false;
133
+ // viewedAt 0 means "written by an older format"; it is kept rather than
134
+ // expired, because dropping it would empty every existing strip at once.
135
+ if (cutoff === null || !entry.viewedAt) return true;
136
+ return entry.viewedAt >= cutoff;
137
+ });
138
+ };
139
+
140
+ /** Newest first, deduped by pk, capped at `capacity`. Never throws. */
141
+ export const recordRecentlyViewed = (
142
+ storageKey: string,
143
+ entry: RecentlyViewedEntry,
144
+ capacity: number
145
+ ): void => {
146
+ const storage = getStorage();
147
+ if (!storage || !storageKey || !entry.pk || capacity < 1) return;
148
+
149
+ const previous = readRecentlyViewed(storageKey);
150
+ const next = [
151
+ entry,
152
+ ...previous.filter((existing) => existing.pk !== entry.pk)
153
+ ].slice(0, capacity);
154
+
155
+ try {
156
+ storage.setItem(storageKey, JSON.stringify(next));
157
+ } catch {
158
+ // Quota exceeded. Our own key is the likeliest bloat, so one retry with
159
+ // just this view reclaims it; if the origin is full because of something
160
+ // else, the view is simply not recorded.
161
+ try {
162
+ storage.setItem(storageKey, JSON.stringify([entry]));
163
+ } catch {
164
+ /* Recording history is never worth an exception on a page the shopper
165
+ is already reading. */
166
+ }
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Runs `task` off the critical path and returns its canceller. The recording
172
+ * is a side effect on a page the shopper came to read, so it waits for idle
173
+ * time; the timeout fallback covers browsers without `requestIdleCallback`
174
+ * (Safari) and guarantees the write still happens on a busy page.
175
+ */
176
+ export const deferIdleTask = (task: () => void): (() => void) => {
177
+ const scope = typeof window === 'undefined' ? null : window;
178
+ if (!scope) return () => undefined;
179
+
180
+ const idle = (
181
+ scope as Window & {
182
+ requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
183
+ cancelIdleCallback?: (handle: number) => void;
184
+ }
185
+ ).requestIdleCallback;
186
+
187
+ if (typeof idle === 'function') {
188
+ const handle = idle.call(scope, task, { timeout: 2000 });
189
+
190
+ return () => {
191
+ const cancel = (
192
+ scope as Window & { cancelIdleCallback?: (handle: number) => void }
193
+ ).cancelIdleCallback;
194
+ if (typeof cancel === 'function') cancel.call(scope, handle);
195
+ };
196
+ }
197
+
198
+ const timer = scope.setTimeout(task, 0);
199
+
200
+ return () => scope.clearTimeout(timer);
201
+ };