@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,510 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { useSession } from 'next-auth/react';
|
|
6
|
+
import {
|
|
7
|
+
useGetFavoritesQuery,
|
|
8
|
+
useRemoveFavoriteMutation
|
|
9
|
+
} from '@akinon/next/data/client/wishlist';
|
|
10
|
+
import { useAddProductMutation } from '@akinon/next/data/client/product';
|
|
11
|
+
import { basketApi } from '@akinon/next/data/client/basket';
|
|
12
|
+
import { useAppDispatch } from '@akinon/next/redux/hooks';
|
|
13
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
14
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
15
|
+
import {
|
|
16
|
+
MOBILE_MEDIA_QUERY_COMPACT,
|
|
17
|
+
TABLET_MEDIA_QUERY_COMPACT
|
|
18
|
+
} from '../utils/breakpoints';
|
|
19
|
+
import { formatMoney, parseAmount } from './commerce-format';
|
|
20
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
21
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
22
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
23
|
+
|
|
24
|
+
/** Commerce paginates the wishlist; one page is enough for a grid. */
|
|
25
|
+
const FAVOURITES_PAGE_SIZE = 100;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The remove control's glyph. The cross is the icon the block shipped with, so
|
|
29
|
+
* it stays the default; the others are the two a brand asks for next.
|
|
30
|
+
*/
|
|
31
|
+
const REMOVE_ICONS: Record<string, string> = {
|
|
32
|
+
x: 'M6 6l12 12M18 6L6 18',
|
|
33
|
+
trash: 'M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6',
|
|
34
|
+
heart:
|
|
35
|
+
'M12 20s-7-4.35-7-9a4 4 0 0 1 7-2.65A4 4 0 0 1 19 11c0 4.65-7 9-7 9z'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface FavouriteProductLike {
|
|
39
|
+
pk?: number;
|
|
40
|
+
name?: string;
|
|
41
|
+
absolute_url?: string;
|
|
42
|
+
image?: string;
|
|
43
|
+
price?: string | number;
|
|
44
|
+
retail_price?: string | number;
|
|
45
|
+
currency_type?: string;
|
|
46
|
+
stock?: number;
|
|
47
|
+
in_stock?: boolean;
|
|
48
|
+
productimage_set?: Array<{ image?: string }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface FavouriteLike {
|
|
52
|
+
pk?: number;
|
|
53
|
+
product?: FavouriteProductLike | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clampColumns = (
|
|
57
|
+
value: unknown,
|
|
58
|
+
fallback: number,
|
|
59
|
+
min: number,
|
|
60
|
+
max: number
|
|
61
|
+
) => {
|
|
62
|
+
const numeric = Math.round(Number(value));
|
|
63
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
64
|
+
return Math.min(max, Math.max(min, numeric));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isInStock = (product: FavouriteProductLike): boolean => {
|
|
68
|
+
if (typeof product.in_stock === 'boolean') return product.in_stock;
|
|
69
|
+
|
|
70
|
+
const stock = Number(product.stock);
|
|
71
|
+
if (Number.isFinite(stock)) return stock > 0;
|
|
72
|
+
|
|
73
|
+
// Unknown stock must not block the CTA — commerce still validates on add.
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Functional account block: the wishlist as a product grid, matching the
|
|
79
|
+
* product-grid card look. Wishlist CONTENT stays commerce-driven (wishlist
|
|
80
|
+
* service); the editor owns the column counts, which fields the card carries,
|
|
81
|
+
* every caption and the styling.
|
|
82
|
+
* Properties: columnsDesktop, columnsMobile, limit, showRemove, removeIcon,
|
|
83
|
+
* showAddToCart, showPrice, showRetailPrice, imageRatio, nameLines, addLabel,
|
|
84
|
+
* outOfStockLabel, removeLabel, emptyLabel.
|
|
85
|
+
* Parts: list, item, link, image, name, price, retail-price, add, remove,
|
|
86
|
+
* empty.
|
|
87
|
+
*/
|
|
88
|
+
const FavouritesGridBlock = ({
|
|
89
|
+
block,
|
|
90
|
+
placeholderId,
|
|
91
|
+
sectionId,
|
|
92
|
+
selectedBlockId = null,
|
|
93
|
+
currentBreakpoint = 'desktop',
|
|
94
|
+
isDesigner = false
|
|
95
|
+
}: BlockRendererProps) => {
|
|
96
|
+
const columnsDesktop = clampColumns(
|
|
97
|
+
getResponsiveValue(block.properties?.columnsDesktop, currentBreakpoint, 4),
|
|
98
|
+
4,
|
|
99
|
+
2,
|
|
100
|
+
6
|
|
101
|
+
);
|
|
102
|
+
const columnsMobile = clampColumns(
|
|
103
|
+
getResponsiveValue(block.properties?.columnsMobile, currentBreakpoint, 2),
|
|
104
|
+
2,
|
|
105
|
+
1,
|
|
106
|
+
3
|
|
107
|
+
);
|
|
108
|
+
// Unset means "whatever the mobile count is", which is what a tablet has
|
|
109
|
+
// been shown since `max-width: 1023px` was the only query — so a grid that
|
|
110
|
+
// authors nothing here emits the CSS it emitted before.
|
|
111
|
+
const columnsTabletRaw = block.properties?.columnsTablet;
|
|
112
|
+
const columnsTabletResolved =
|
|
113
|
+
columnsTabletRaw === undefined ||
|
|
114
|
+
columnsTabletRaw === null ||
|
|
115
|
+
columnsTabletRaw === ''
|
|
116
|
+
? null
|
|
117
|
+
: Number(getResponsiveValue(columnsTabletRaw, currentBreakpoint, 0));
|
|
118
|
+
// 0 is the descriptor's "keep the mobile count" setting, and so is a value
|
|
119
|
+
// that is missing or unparseable — all three mean no tablet rule.
|
|
120
|
+
const columnsTablet =
|
|
121
|
+
columnsTabletResolved === null ||
|
|
122
|
+
!Number.isFinite(columnsTabletResolved) ||
|
|
123
|
+
columnsTabletResolved < 1
|
|
124
|
+
? null
|
|
125
|
+
: clampColumns(
|
|
126
|
+
getResponsiveValue(columnsTabletRaw, currentBreakpoint, columnsMobile),
|
|
127
|
+
columnsMobile,
|
|
128
|
+
1,
|
|
129
|
+
6
|
|
130
|
+
);
|
|
131
|
+
const showRemove = readToggle(
|
|
132
|
+
block.properties?.showRemove,
|
|
133
|
+
currentBreakpoint,
|
|
134
|
+
true
|
|
135
|
+
);
|
|
136
|
+
const showAddToCart = readToggle(
|
|
137
|
+
block.properties?.showAddToCart,
|
|
138
|
+
currentBreakpoint,
|
|
139
|
+
true
|
|
140
|
+
);
|
|
141
|
+
const showPrice = readToggle(
|
|
142
|
+
block.properties?.showPrice,
|
|
143
|
+
currentBreakpoint,
|
|
144
|
+
true
|
|
145
|
+
);
|
|
146
|
+
const showRetailPrice = readToggle(
|
|
147
|
+
block.properties?.showRetailPrice,
|
|
148
|
+
currentBreakpoint,
|
|
149
|
+
true
|
|
150
|
+
);
|
|
151
|
+
const text = (key: string, fallback: string) =>
|
|
152
|
+
String(
|
|
153
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
154
|
+
);
|
|
155
|
+
const addLabel = text('addLabel', 'Add to Cart');
|
|
156
|
+
const outOfStockLabel = text('outOfStockLabel', 'Out of Stock');
|
|
157
|
+
const removeLabel = text('removeLabel', 'Remove from favourites');
|
|
158
|
+
const emptyLabel = text('emptyLabel', '');
|
|
159
|
+
const removeIcon = REMOVE_ICONS[text('removeIcon', 'x')] || REMOVE_ICONS.x;
|
|
160
|
+
const imageRatio = text('imageRatio', 'auto');
|
|
161
|
+
const rawNameLines = Math.round(
|
|
162
|
+
Number(getResponsiveValue(block.properties?.nameLines, currentBreakpoint, 0))
|
|
163
|
+
);
|
|
164
|
+
// 0 leaves the name unclamped, which is what the card has always rendered.
|
|
165
|
+
const nameLines =
|
|
166
|
+
Number.isFinite(rawNameLines) && rawNameLines > 0 ? rawNameLines : 0;
|
|
167
|
+
const rawLimit = Math.round(
|
|
168
|
+
Number(getResponsiveValue(block.properties?.limit, currentBreakpoint, 0))
|
|
169
|
+
);
|
|
170
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
|
|
171
|
+
|
|
172
|
+
const { locale, currency: localeCurrency } = useLocalization();
|
|
173
|
+
const dispatch = useAppDispatch();
|
|
174
|
+
const { status } = useSession();
|
|
175
|
+
const isAuthenticated = status === 'authenticated';
|
|
176
|
+
|
|
177
|
+
// Guests have no wishlist endpoint; skipping avoids a guaranteed 403 on every
|
|
178
|
+
// page that hosts this block.
|
|
179
|
+
const { data, isLoading } = useGetFavoritesQuery(
|
|
180
|
+
{ limit: FAVOURITES_PAGE_SIZE, page: 1 },
|
|
181
|
+
{ skip: !isAuthenticated }
|
|
182
|
+
);
|
|
183
|
+
const [removeFavorite, { isLoading: isRemoving }] =
|
|
184
|
+
useRemoveFavoriteMutation();
|
|
185
|
+
const [addProduct, { isLoading: isAdding }] = useAddProductMutation();
|
|
186
|
+
|
|
187
|
+
const allFavourites: FavouriteLike[] =
|
|
188
|
+
(data as { results?: FavouriteLike[] } | undefined)?.results ?? [];
|
|
189
|
+
const favourites = limit > 0 ? allFavourites.slice(0, limit) : allFavourites;
|
|
190
|
+
const isBusy = isRemoving || isAdding;
|
|
191
|
+
|
|
192
|
+
const remove = (favouritePk?: number) => {
|
|
193
|
+
// A click inside the designer must never touch the shopper's real wishlist.
|
|
194
|
+
if (isDesigner || isBusy || favouritePk === undefined) return;
|
|
195
|
+
|
|
196
|
+
// The endpoint keys on the wishlist ENTRY pk, not the product pk.
|
|
197
|
+
removeFavorite(favouritePk);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const addToCart = async (product: FavouriteProductLike) => {
|
|
201
|
+
const productPk = Number(product.pk);
|
|
202
|
+
|
|
203
|
+
if (isDesigner || isBusy || !Number.isFinite(productPk)) return;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await addProduct({
|
|
207
|
+
product: productPk,
|
|
208
|
+
quantity: 1,
|
|
209
|
+
attributes: {}
|
|
210
|
+
}).unwrap();
|
|
211
|
+
|
|
212
|
+
// addProduct only invalidates MiniBasket, so the full basket query (used
|
|
213
|
+
// by the mini-basket chrome block) has to be refreshed explicitly.
|
|
214
|
+
dispatch(basketApi.util.invalidateTags(['Basket', 'MiniBasket']));
|
|
215
|
+
} catch {
|
|
216
|
+
// Commerce owns the failure message (out of stock, variant required);
|
|
217
|
+
// the grid stays as it was rather than inventing an error state.
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Everything the card (and a designer's own layout) reads off one entry. */
|
|
222
|
+
const describe = (favourite: FavouriteLike, index: number) => {
|
|
223
|
+
const product = favourite.product as FavouriteProductLike;
|
|
224
|
+
const name = product.name || '';
|
|
225
|
+
const href = product.absolute_url || '';
|
|
226
|
+
const image = product.image || product.productimage_set?.[0]?.image || '';
|
|
227
|
+
// Commerce sends the currency per product; the active currency is only
|
|
228
|
+
// the fallback for payloads that omit it.
|
|
229
|
+
const currency = product.currency_type || localeCurrency;
|
|
230
|
+
const price = formatMoney(product.price, { locale, currency });
|
|
231
|
+
const retailPrice = formatMoney(product.retail_price, {
|
|
232
|
+
locale,
|
|
233
|
+
currency
|
|
234
|
+
});
|
|
235
|
+
// Parsed with the shared parser, not Number(): "1.299,90" is NaN to
|
|
236
|
+
// Number() and would silently drop the discount flag.
|
|
237
|
+
const numericPrice = parseAmount(product.price);
|
|
238
|
+
const numericRetail = parseAmount(product.retail_price);
|
|
239
|
+
const hasDiscount =
|
|
240
|
+
numericPrice !== null &&
|
|
241
|
+
numericRetail !== null &&
|
|
242
|
+
numericRetail > numericPrice;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
key: String(favourite.pk ?? product.pk ?? index),
|
|
246
|
+
pk: favourite.pk ?? '',
|
|
247
|
+
productPk: product.pk ?? '',
|
|
248
|
+
name,
|
|
249
|
+
url: href,
|
|
250
|
+
href,
|
|
251
|
+
image,
|
|
252
|
+
price,
|
|
253
|
+
retailPrice,
|
|
254
|
+
hasDiscount,
|
|
255
|
+
inStock: isInStock(product)
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const rows = favourites
|
|
260
|
+
.filter((favourite) => Boolean(favourite.product))
|
|
261
|
+
.map(describe);
|
|
262
|
+
|
|
263
|
+
// A designer who marked a child as the card layout gets that layout repeated
|
|
264
|
+
// per saved product; otherwise the built-in card renders unchanged.
|
|
265
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
266
|
+
<ItemTemplateList
|
|
267
|
+
block={block}
|
|
268
|
+
items={rows}
|
|
269
|
+
placeholderId={placeholderId}
|
|
270
|
+
sectionId={sectionId}
|
|
271
|
+
isDesigner={isDesigner}
|
|
272
|
+
selectedBlockId={selectedBlockId}
|
|
273
|
+
currentBreakpoint={currentBreakpoint}
|
|
274
|
+
designerPlaceholderCount={4}
|
|
275
|
+
/>
|
|
276
|
+
) : null;
|
|
277
|
+
|
|
278
|
+
if (favourites.length === 0 && !customCards) {
|
|
279
|
+
if (isDesigner) {
|
|
280
|
+
return (
|
|
281
|
+
<DesignerPlaceholder
|
|
282
|
+
label={
|
|
283
|
+
isLoading
|
|
284
|
+
? 'Favourites (loading)'
|
|
285
|
+
: 'Favourites (no saved products)'
|
|
286
|
+
}
|
|
287
|
+
attribute="data-favourites-grid"
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Without an empty message the block stays absent, which is what every
|
|
293
|
+
// published composition renders today.
|
|
294
|
+
if (!isAuthenticated || isLoading || !emptyLabel) return null;
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
data-favourites-grid=""
|
|
299
|
+
data-favourites-grid-state="empty"
|
|
300
|
+
{...partAttrs('empty', { empty: true })}
|
|
301
|
+
>
|
|
302
|
+
{emptyLabel}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Column counts are block PROPERTIES, and properties are resolved with the
|
|
308
|
+
// designer breakpoint only — on the live storefront currentBreakpoint is
|
|
309
|
+
// always 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
310
|
+
// generator uses keeps real mobile viewports in sync with the editor.
|
|
311
|
+
const gridId = String(block.id || 'favourites').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
312
|
+
const baseColumns =
|
|
313
|
+
currentBreakpoint === 'tablet'
|
|
314
|
+
? columnsTablet ?? columnsMobile
|
|
315
|
+
: currentBreakpoint === 'mobile'
|
|
316
|
+
? columnsMobile
|
|
317
|
+
: columnsDesktop;
|
|
318
|
+
|
|
319
|
+
// Navigating inside the designer iframe would drop the author out of the page
|
|
320
|
+
// they are editing, so the cards are inert there.
|
|
321
|
+
const guard = (event: React.MouseEvent) => {
|
|
322
|
+
if (isDesigner) event.preventDefault();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const imageStyle: React.CSSProperties = {
|
|
326
|
+
width: '100%',
|
|
327
|
+
height: 'auto',
|
|
328
|
+
display: 'block',
|
|
329
|
+
objectFit: 'cover',
|
|
330
|
+
...(imageRatio !== 'auto' ? { aspectRatio: imageRatio } : null)
|
|
331
|
+
};
|
|
332
|
+
const nameStyle: React.CSSProperties | undefined =
|
|
333
|
+
nameLines > 0
|
|
334
|
+
? {
|
|
335
|
+
display: '-webkit-box',
|
|
336
|
+
WebkitBoxOrient: 'vertical',
|
|
337
|
+
// A string, so React cannot decide the value needs a px suffix.
|
|
338
|
+
WebkitLineClamp: String(nameLines),
|
|
339
|
+
overflow: 'hidden'
|
|
340
|
+
}
|
|
341
|
+
: undefined;
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
data-favourites-grid=""
|
|
346
|
+
data-favourites-grid-id={gridId}
|
|
347
|
+
aria-busy={isBusy}
|
|
348
|
+
{...partAttrs('list')}
|
|
349
|
+
style={{ display: 'grid', gap: 'var(--favourites-grid-gap, 16px)' }}
|
|
350
|
+
>
|
|
351
|
+
<style>
|
|
352
|
+
{`[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
353
|
+
`${MOBILE_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}` +
|
|
354
|
+
// After the mobile query, and only when authored.
|
|
355
|
+
(columnsTablet !== null
|
|
356
|
+
? `${TABLET_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}}`
|
|
357
|
+
: '')}
|
|
358
|
+
</style>
|
|
359
|
+
|
|
360
|
+
{customCards ??
|
|
361
|
+
favourites.map((favourite, index) => {
|
|
362
|
+
const product = favourite.product;
|
|
363
|
+
|
|
364
|
+
if (!product) return null;
|
|
365
|
+
|
|
366
|
+
const card = describe(favourite, index);
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div
|
|
370
|
+
key={favourite.pk ?? product.pk ?? index}
|
|
371
|
+
data-favourites-card=""
|
|
372
|
+
{...partAttrs('item')}
|
|
373
|
+
style={{
|
|
374
|
+
display: 'flex',
|
|
375
|
+
flexDirection: 'column',
|
|
376
|
+
gap: '8px',
|
|
377
|
+
position: 'relative'
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<NextLink
|
|
381
|
+
href={isDesigner ? '#' : card.href || '#'}
|
|
382
|
+
onClick={guard}
|
|
383
|
+
data-favourites-card-link=""
|
|
384
|
+
{...partAttrs('link')}
|
|
385
|
+
style={{
|
|
386
|
+
display: 'flex',
|
|
387
|
+
flexDirection: 'column',
|
|
388
|
+
gap: '8px',
|
|
389
|
+
color: 'inherit',
|
|
390
|
+
textDecoration: 'none'
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
{card.image ? (
|
|
394
|
+
// Plain img: wishlist thumbnails come from arbitrary commerce
|
|
395
|
+
// CDNs that the brand next.config may not allow.
|
|
396
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
397
|
+
<img
|
|
398
|
+
src={card.image}
|
|
399
|
+
alt={card.name}
|
|
400
|
+
loading="lazy"
|
|
401
|
+
data-favourites-card-image=""
|
|
402
|
+
{...partAttrs('image')}
|
|
403
|
+
style={imageStyle}
|
|
404
|
+
/>
|
|
405
|
+
) : (
|
|
406
|
+
<span
|
|
407
|
+
data-favourites-card-image=""
|
|
408
|
+
{...partAttrs('image', { empty: true })}
|
|
409
|
+
style={{
|
|
410
|
+
display: 'block',
|
|
411
|
+
width: '100%',
|
|
412
|
+
aspectRatio: imageRatio === 'auto' ? '3 / 4' : imageRatio,
|
|
413
|
+
background: '#f1f5f9'
|
|
414
|
+
}}
|
|
415
|
+
/>
|
|
416
|
+
)}
|
|
417
|
+
|
|
418
|
+
<span
|
|
419
|
+
data-favourites-card-name=""
|
|
420
|
+
{...partAttrs('name')}
|
|
421
|
+
style={nameStyle}
|
|
422
|
+
>
|
|
423
|
+
{card.name}
|
|
424
|
+
</span>
|
|
425
|
+
</NextLink>
|
|
426
|
+
|
|
427
|
+
{showPrice && card.price && (
|
|
428
|
+
<span
|
|
429
|
+
data-favourites-card-price=""
|
|
430
|
+
{...partAttrs('price')}
|
|
431
|
+
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
|
432
|
+
>
|
|
433
|
+
{showRetailPrice && card.hasDiscount && card.retailPrice && (
|
|
434
|
+
<s
|
|
435
|
+
data-favourites-card-retail-price=""
|
|
436
|
+
{...partAttrs('retail-price')}
|
|
437
|
+
style={{ opacity: 0.6, fontSize: '0.9em' }}
|
|
438
|
+
>
|
|
439
|
+
{card.retailPrice}
|
|
440
|
+
</s>
|
|
441
|
+
)}
|
|
442
|
+
<span>{card.price}</span>
|
|
443
|
+
</span>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{showAddToCart && (
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
data-favourites-card-add=""
|
|
450
|
+
disabled={isBusy || !card.inStock}
|
|
451
|
+
onClick={() => addToCart(product)}
|
|
452
|
+
{...partAttrs('add', {
|
|
453
|
+
disabled: !card.inStock,
|
|
454
|
+
loading: isBusy
|
|
455
|
+
})}
|
|
456
|
+
style={{
|
|
457
|
+
padding: '10px 16px',
|
|
458
|
+
border: '1px solid currentColor',
|
|
459
|
+
background: 'transparent',
|
|
460
|
+
color: 'inherit',
|
|
461
|
+
font: 'inherit',
|
|
462
|
+
cursor:
|
|
463
|
+
isBusy || !card.inStock ? 'not-allowed' : 'pointer',
|
|
464
|
+
opacity: !card.inStock ? 0.6 : isBusy ? 0.7 : 1
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
{card.inStock ? addLabel : outOfStockLabel}
|
|
468
|
+
</button>
|
|
469
|
+
)}
|
|
470
|
+
|
|
471
|
+
{showRemove && (
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
data-favourites-card-remove=""
|
|
475
|
+
aria-label={removeLabel}
|
|
476
|
+
disabled={isBusy}
|
|
477
|
+
onClick={() => remove(favourite.pk)}
|
|
478
|
+
{...partAttrs('remove', { loading: isBusy })}
|
|
479
|
+
style={{
|
|
480
|
+
position: 'absolute',
|
|
481
|
+
top: '8px',
|
|
482
|
+
right: '8px',
|
|
483
|
+
background: 'none',
|
|
484
|
+
border: 'none',
|
|
485
|
+
padding: 0,
|
|
486
|
+
color: 'inherit',
|
|
487
|
+
cursor: isBusy ? 'not-allowed' : 'pointer'
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
<svg
|
|
491
|
+
width="18"
|
|
492
|
+
height="18"
|
|
493
|
+
viewBox="0 0 24 24"
|
|
494
|
+
fill="none"
|
|
495
|
+
stroke="currentColor"
|
|
496
|
+
strokeWidth="1.6"
|
|
497
|
+
strokeLinecap="round"
|
|
498
|
+
>
|
|
499
|
+
<path d={removeIcon} />
|
|
500
|
+
</svg>
|
|
501
|
+
</button>
|
|
502
|
+
)}
|
|
503
|
+
</div>
|
|
504
|
+
);
|
|
505
|
+
})}
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
export default FavouritesGridBlock;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { useForgotPasswordMutation } from '@akinon/next/data/client/user';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { readToggle } from './product-block-utils';
|
|
7
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Functional auth block: e-mail input that triggers the password reset mail
|
|
11
|
+
* through the v1 `forgotPassword` mutation. Behaviour is fixed (commerce sends
|
|
12
|
+
* the mail); the editor owns the labels and the styling.
|
|
13
|
+
* Properties: submitLabel, busyLabel, successLabel, emailLabel,
|
|
14
|
+
* emailPlaceholder, showLabels, errorLabel, previewSuccess.
|
|
15
|
+
* Parts: form, field, label, input, error, submit, success.
|
|
16
|
+
*/
|
|
17
|
+
const ForgotPasswordBlock = ({
|
|
18
|
+
block,
|
|
19
|
+
currentBreakpoint = 'desktop',
|
|
20
|
+
isDesigner = false
|
|
21
|
+
}: BlockRendererProps) => {
|
|
22
|
+
const prop = (key: string, fallback: string) =>
|
|
23
|
+
String(
|
|
24
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const submitLabel = prop('submitLabel', 'Send reset link');
|
|
28
|
+
// Empty on purpose: without a busy label the submit caption stays put while
|
|
29
|
+
// the request runs, which is what published blocks render today.
|
|
30
|
+
const busyLabel = prop('busyLabel', '');
|
|
31
|
+
const successLabel = prop(
|
|
32
|
+
'successLabel',
|
|
33
|
+
'If the address is registered, a reset link is on its way.'
|
|
34
|
+
);
|
|
35
|
+
const emailLabel = prop('emailLabel', 'E-mail');
|
|
36
|
+
const emailPlaceholder = prop('emailPlaceholder', '');
|
|
37
|
+
const showLabels = readToggle(
|
|
38
|
+
block.properties?.showLabels,
|
|
39
|
+
currentBreakpoint,
|
|
40
|
+
true
|
|
41
|
+
);
|
|
42
|
+
const errorLabel = prop(
|
|
43
|
+
'errorLabel',
|
|
44
|
+
'Could not send the reset mail. Please try again.'
|
|
45
|
+
);
|
|
46
|
+
// The success copy replaces the form, so on the storefront it is unreachable
|
|
47
|
+
// from the designer — this shows it there without submitting anything.
|
|
48
|
+
const previewSuccess = readToggle(
|
|
49
|
+
block.properties?.previewSuccess,
|
|
50
|
+
currentBreakpoint,
|
|
51
|
+
false
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const [email, setEmail] = useState('');
|
|
55
|
+
const [error, setError] = useState('');
|
|
56
|
+
const [forgotPassword, { isLoading, isSuccess }] = useForgotPasswordMutation();
|
|
57
|
+
|
|
58
|
+
const submit = async (event: React.FormEvent) => {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
setError('');
|
|
61
|
+
|
|
62
|
+
// Submitting inside the designer would send a real reset mail.
|
|
63
|
+
if (isDesigner || isLoading) return;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await forgotPassword({ email }).unwrap();
|
|
67
|
+
} catch {
|
|
68
|
+
setError(errorLabel);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// The success copy replaces the form so the shopper is not invited to submit
|
|
73
|
+
// the same address twice; the root attribute is kept so styling still applies.
|
|
74
|
+
if ((isSuccess && !isDesigner) || (isDesigner && previewSuccess)) {
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
data-forgot-password=""
|
|
78
|
+
data-forgot-password-state="success"
|
|
79
|
+
{...partAttrs('success')}
|
|
80
|
+
role="status"
|
|
81
|
+
>
|
|
82
|
+
{successLabel}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<form
|
|
89
|
+
data-forgot-password=""
|
|
90
|
+
data-forgot-password-state={isLoading ? 'busy' : 'idle'}
|
|
91
|
+
{...partAttrs('form')}
|
|
92
|
+
onSubmit={submit}
|
|
93
|
+
style={{
|
|
94
|
+
display: 'flex',
|
|
95
|
+
flexDirection: 'column',
|
|
96
|
+
gap: 'var(--forgot-password-gap, 12px)'
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<label
|
|
100
|
+
data-forgot-password-field="email"
|
|
101
|
+
{...partAttrs('field')}
|
|
102
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
103
|
+
>
|
|
104
|
+
{/* Hiding the caption must not cost the input its accessible name, so
|
|
105
|
+
the label text moves onto aria-label instead of disappearing. */}
|
|
106
|
+
{showLabels && <span {...partAttrs('label')}>{emailLabel}</span>}
|
|
107
|
+
<input
|
|
108
|
+
type="email"
|
|
109
|
+
name="email"
|
|
110
|
+
value={email}
|
|
111
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
112
|
+
autoComplete="email"
|
|
113
|
+
placeholder={emailPlaceholder || undefined}
|
|
114
|
+
aria-label={showLabels ? undefined : emailLabel}
|
|
115
|
+
required
|
|
116
|
+
{...partAttrs('input')}
|
|
117
|
+
style={{
|
|
118
|
+
width: '100%',
|
|
119
|
+
border: '1px solid var(--forgot-password-border, #d1d5db)',
|
|
120
|
+
background: 'transparent',
|
|
121
|
+
color: 'inherit',
|
|
122
|
+
padding: '10px 12px',
|
|
123
|
+
outline: 'none'
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
</label>
|
|
127
|
+
|
|
128
|
+
{error && (
|
|
129
|
+
<div data-forgot-password-error="" {...partAttrs('error')} role="alert">
|
|
130
|
+
{error}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<button
|
|
135
|
+
type="submit"
|
|
136
|
+
data-forgot-password-submit=""
|
|
137
|
+
{...partAttrs('submit', { loading: isLoading, disabled: isLoading })}
|
|
138
|
+
disabled={isLoading}
|
|
139
|
+
aria-busy={isLoading}
|
|
140
|
+
style={{
|
|
141
|
+
padding: '12px 20px',
|
|
142
|
+
background: 'var(--forgot-password-cta-bg, #111827)',
|
|
143
|
+
color: 'var(--forgot-password-cta-color, #ffffff)',
|
|
144
|
+
border: 'none',
|
|
145
|
+
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
146
|
+
opacity: isLoading ? 0.7 : 1
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{isLoading && busyLabel ? busyLabel : submitLabel}
|
|
150
|
+
</button>
|
|
151
|
+
</form>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export default ForgotPasswordBlock;
|