@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.
- package/CHANGELOG.md +7 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- 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
|
+
};
|