@akinon/pz-theme 2.0.152-rc.3 → 2.0.153-beta.0

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.
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useSyncExternalStore } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+
6
+ /**
7
+ * The sold-out option a shopper picked to be told about, handed from the
8
+ * variant selector to the add-to-cart button (ZERO-5233).
9
+ *
10
+ * Commerce makes every in-stock option its own product URL, so picking one is
11
+ * a navigation and the button simply reads the new page's product. A sold-out
12
+ * option has no page to go to (its url redirects away), so "the shopper picked
13
+ * L, which is gone" has to live somewhere both blocks can see without a
14
+ * navigation: here. The two blocks sit in different sections of a composition
15
+ * with no common parent of their own, which is why this is a module store
16
+ * rather than a context.
17
+ *
18
+ * Every pick names the page product it was made on and is only ever read back
19
+ * against that same product. It is also dropped on every navigation (see
20
+ * `useStockAlertPick`), so going to another colour and coming back with the
21
+ * browser's back button never brings an old pick back.
22
+ *
23
+ * It survives one thing on purpose: a guest sent to sign in. The way back
24
+ * carries the pick in `STOCK_ALERT_PICK_PARAM`, and the variant selector picks
25
+ * that option again when the page opens (ZERO-5233).
26
+ */
27
+
28
+ /** The query key the picked sold-out option's product id travels under. */
29
+ export const STOCK_ALERT_PICK_PARAM = 'stock_alert';
30
+
31
+ export interface StockAlertPick {
32
+ /** The page product the pick was made on. */
33
+ pagePk: number;
34
+ /** The sold-out option's own product — what the alert is registered for. */
35
+ productPk: number;
36
+ optionLabel: string;
37
+ attributeKey: string;
38
+ }
39
+
40
+ let current: StockAlertPick | null = null;
41
+ const listeners = new Set<() => void>();
42
+
43
+ const notify = () => listeners.forEach((listener) => listener());
44
+
45
+ export const setStockAlertPick = (pick: StockAlertPick | null): void => {
46
+ if (
47
+ current === pick ||
48
+ (current &&
49
+ pick &&
50
+ current.pagePk === pick.pagePk &&
51
+ current.productPk === pick.productPk &&
52
+ current.attributeKey === pick.attributeKey)
53
+ ) {
54
+ return;
55
+ }
56
+
57
+ current = pick;
58
+ notify();
59
+ };
60
+
61
+ export const getStockAlertPick = (): StockAlertPick | null => current;
62
+
63
+ const subscribe = (listener: () => void) => {
64
+ listeners.add(listener);
65
+
66
+ return () => {
67
+ listeners.delete(listener);
68
+ };
69
+ };
70
+
71
+ // The server never has a pick: it cannot know what a shopper clicked.
72
+ const getServerSnapshot = (): StockAlertPick | null => null;
73
+
74
+ /**
75
+ * The path the store last saw. Module-level rather than a ref, because a
76
+ * navigation can remount every block that reads the pick: a ref would start
77
+ * over on the new page and never see the change.
78
+ */
79
+ let lastPathname: string | null = null;
80
+
81
+ /** The live pick for `pagePk`, or null when there is none for this page. */
82
+ export const useStockAlertPick = (pagePk: number): StockAlertPick | null => {
83
+ const pathname = usePathname();
84
+
85
+ // A pick belongs to the page it was made on: any navigation drops it.
86
+ useEffect(() => {
87
+ if (lastPathname !== null && lastPathname !== pathname) {
88
+ setStockAlertPick(null);
89
+ }
90
+ lastPathname = pathname;
91
+ }, [pathname]);
92
+
93
+ const pick = useSyncExternalStore(
94
+ subscribe,
95
+ getStockAlertPick,
96
+ getServerSnapshot
97
+ );
98
+
99
+ return pick && pick.pagePk === pagePk ? pick : null;
100
+ };
101
+
102
+ export const __resetStockAlertPickForTests = () => {
103
+ current = null;
104
+ lastPathname = null;
105
+ listeners.clear();
106
+ };
@@ -17,10 +17,17 @@ import {
17
17
  getVariantOptionColor,
18
18
  getVariantOptionImage,
19
19
  getVariantOptionStock,
20
+ isOptionOfProductSelection,
20
21
  pickProductField,
21
22
  readToggle,
22
23
  useThemeProduct
23
24
  } from './product-block-utils';
25
+ import {
26
+ STOCK_ALERT_PICK_PARAM,
27
+ getStockAlertPick,
28
+ setStockAlertPick,
29
+ useStockAlertPick
30
+ } from './stock-alert-pick';
24
31
 
25
32
  const getOptionUrl = (option: ThemeVariantOption): string =>
26
33
  String(option?.product?.absolute_url || '').trim();
@@ -210,7 +217,10 @@ const collapseOptions = <T,>(
210
217
  *
211
218
  * A sold-out option is a dead end by default and stays one unless a brand turns
212
219
  * `stockAlert` on: then it becomes a real control that opens a back-in-stock
213
- * form bound to THAT option's own product id.
220
+ * form bound to THAT option's own product id. With `stockAlertPresentation`
221
+ * set to `add-to-cart` it opens nothing: the option becomes a pick that the
222
+ * page's Add to Cart block (with its own `stockAlert` on) turns into its
223
+ * "notify me" button for that option.
214
224
  *
215
225
  * Parts: group, label, selected-value, dropdown, option-list, option, swatch,
216
226
  * view-all, strike, low-stock, alert-backdrop, alert-panel, alert-title,
@@ -394,10 +404,16 @@ const VariantSelectorBlock = ({
394
404
 
395
405
  // Every word a shopper reads is the brand's, never ours: these markets
396
406
  // attach legal text to collecting an email and only the brand knows it.
407
+ // `add-to-cart` draws no form at all: a sold-out option becomes a pick the
408
+ // page's Add to Cart button turns into its own "notify me" (ZERO-5233).
409
+ const alertPresentationValue = alertProp('stockAlertPresentation', 'inline');
397
410
  const alertPresentation =
398
- alertProp('stockAlertPresentation', 'inline') === 'modal'
399
- ? 'modal'
411
+ alertPresentationValue === 'modal' ||
412
+ alertPresentationValue === 'add-to-cart'
413
+ ? alertPresentationValue
400
414
  : 'inline';
415
+ const pagePk = Number(pickProductField(product, 'pk'));
416
+ const stockAlertPick = useStockAlertPick(pagePk);
401
417
  const alertTriggerLabel = alertProp(
402
418
  'stockAlertTriggerLabel',
403
419
  'Notify me when back in stock'
@@ -532,6 +548,58 @@ const VariantSelectorBlock = ({
532
548
  const groups = (Array.isArray(rawGroups) ? rawGroups : []).filter(
533
549
  (group) => Array.isArray(group?.options) && group.options.length > 0
534
550
  );
551
+ const handsAlertToButton =
552
+ stockAlertEnabled && alertPresentation === 'add-to-cart';
553
+
554
+ /**
555
+ * A guest sent to sign in from "notify me" comes back with the sold-out
556
+ * option they had picked in the url, so the button is back on THAT size and
557
+ * one more press registers it. The key is taken out of the address again so
558
+ * a reload or a shared link does not pick it a second time.
559
+ */
560
+ React.useEffect(() => {
561
+ if (isDesigner || !handsAlertToButton || typeof window === 'undefined') {
562
+ return;
563
+ }
564
+
565
+ const params = new URLSearchParams(window.location.search);
566
+ const wanted = Number(params.get(STOCK_ALERT_PICK_PARAM));
567
+
568
+ if (!Number.isFinite(wanted) || wanted <= 0) return;
569
+
570
+ const leaf = groups[groups.length - 1];
571
+ const options = leaf?.options || [];
572
+ const ownSize = (entry: ThemeVariantOption) =>
573
+ isOptionOfProductSelection(entry, product, groups);
574
+ const option = options.find(
575
+ (entry) => getOptionProductPk(entry) === wanted && isOptionSoldOut(entry)
576
+ );
577
+
578
+ if (
579
+ option &&
580
+ !getStockAlertPick() &&
581
+ options.some((entry) => !isOptionSoldOut(entry) && ownSize(entry))
582
+ ) {
583
+ setStockAlertPick({
584
+ pagePk,
585
+ productPk: wanted,
586
+ optionLabel: String(option.label || option.value || ''),
587
+ attributeKey: String(leaf?.attribute_key || '')
588
+ });
589
+ }
590
+
591
+ params.delete(STOCK_ALERT_PICK_PARAM);
592
+ const query = params.toString();
593
+ window.history.replaceState(
594
+ window.history.state,
595
+ '',
596
+ `${window.location.pathname}${query ? `?${query}` : ''}${
597
+ window.location.hash
598
+ }`
599
+ );
600
+ // Once per page: the url is read when the page opens, not on every render.
601
+ // eslint-disable-next-line react-hooks/exhaustive-deps
602
+ }, [pagePk, handsAlertToButton, isDesigner]);
535
603
 
536
604
  if (groups.length === 0) {
537
605
  return isDesigner ? (
@@ -818,9 +886,35 @@ const VariantSelectorBlock = ({
818
886
  >
819
887
  {groups.map((group, groupIndex) => {
820
888
  const options = group.options || [];
821
- const selectedOption = options.find(
822
- (option) => option?.is_selected === true
823
- );
889
+ // A sold-out option the shopper picked for the button's alert stands
890
+ // in for the group's selection until they pick something else.
891
+ const pickedKey =
892
+ stockAlertEnabled &&
893
+ alertPresentation === 'add-to-cart' &&
894
+ stockAlertPick?.attributeKey === String(group.attribute_key || '')
895
+ ? stockAlertPick.productPk
896
+ : null;
897
+ const isPickedOption = (option: ThemeVariantOption) =>
898
+ pickedKey !== null && getOptionProductPk(option) === pickedKey;
899
+ // Only the LEAF group (the sizes of the colour on the page) hands a
900
+ // pick to the button, and only while one of them is still in stock:
901
+ // that is the same question the button asks before it offers the
902
+ // alert, so the two can never disagree.
903
+ const isLeafGroup = groupIndex === groups.length - 1;
904
+ // Commerce fills a colour's missing sizes with other colours' ones;
905
+ // only the colour's own sizes say whether it has any left.
906
+ const isOwnOption = (option: ThemeVariantOption) =>
907
+ isOptionOfProductSelection(option, product, groups);
908
+ const groupHasStock =
909
+ isLeafGroup &&
910
+ options.some(
911
+ (option) => !isOptionSoldOut(option) && isOwnOption(option)
912
+ );
913
+ const selectedOption =
914
+ options.find(isPickedOption) ||
915
+ (pickedKey === null
916
+ ? options.find((option) => option?.is_selected === true)
917
+ : undefined);
824
918
  // The <select> is controlled by URL, so an empty value needs a real
825
919
  // matching option or React drops the selection on every render.
826
920
  const selectedUrl = selectedOption ? getOptionUrl(selectedOption) : '';
@@ -877,7 +971,7 @@ const VariantSelectorBlock = ({
877
971
  const label = String(option?.label || option?.value || '');
878
972
  const url = getOptionUrl(option);
879
973
  const disabled = isOptionDisabled(option);
880
- const selected = option?.is_selected === true;
974
+ const selected = option === selectedOption;
881
975
  const struck = disabled && disabledStyle === 'line-through';
882
976
  const showStrike = disabled && disabledStyle === 'diagonal';
883
977
  // A number commerce actually sent, above zero (zero is sold out, not
@@ -1049,7 +1143,63 @@ const VariantSelectorBlock = ({
1049
1143
  isOptionSoldOut(option) &&
1050
1144
  optionPk !== null;
1051
1145
 
1052
- if (alertable) {
1146
+ // With every option of the group gone the button says sold out,
1147
+ // so a pick would offer an alert the button will not register.
1148
+ // A sold-out size commerce filled from another colour is pickable
1149
+ // too, and its alert goes to the product commerce sent for it —
1150
+ // what gant_zero did (`data-notice`), at GANT's request.
1151
+ if (
1152
+ alertable &&
1153
+ alertPresentation === 'add-to-cart' &&
1154
+ groupHasStock
1155
+ ) {
1156
+ const groupAttributeKey = String(group.attribute_key || '');
1157
+
1158
+ return (
1159
+ <button
1160
+ key={String(option?.value || optionIndex)}
1161
+ type="button"
1162
+ data-variant-option={selected ? 'picked' : 'disabled'}
1163
+ {...named}
1164
+ // `selected` as well as `disabled`, so the brand's selected
1165
+ // underline lands on the picked size while its sold-out
1166
+ // strike stays drawn.
1167
+ {...partAttrs('option', { disabled: true, selected })}
1168
+ aria-label={[label, alertTriggerLabel]
1169
+ .filter(Boolean)
1170
+ .join(' — ')}
1171
+ title={alertTriggerLabel || label}
1172
+ aria-pressed={selected}
1173
+ onClick={() => {
1174
+ if (isDesigner) return;
1175
+
1176
+ // A second press hands the button back to the page's own
1177
+ // product rather than leaving no way to undo the pick.
1178
+ setStockAlertPick(
1179
+ selected
1180
+ ? null
1181
+ : {
1182
+ pagePk,
1183
+ productPk: optionPk,
1184
+ optionLabel: label,
1185
+ attributeKey: groupAttributeKey
1186
+ }
1187
+ );
1188
+ }}
1189
+ style={{
1190
+ ...optionStyle,
1191
+ background: 'transparent',
1192
+ font: 'inherit',
1193
+ textAlign: 'center',
1194
+ cursor: 'pointer'
1195
+ }}
1196
+ >
1197
+ {content}
1198
+ </button>
1199
+ );
1200
+ }
1201
+
1202
+ if (alertable && alertPresentation !== 'add-to-cart') {
1053
1203
  const optionKey = `${groupKey}::${String(
1054
1204
  option?.value || optionIndex
1055
1205
  )}`;
@@ -1133,7 +1283,15 @@ const VariantSelectorBlock = ({
1133
1283
  {...named}
1134
1284
  {...partAttrs('option', { selected })}
1135
1285
  onClick={(event) => {
1136
- if (isDesigner) event.preventDefault();
1286
+ if (isDesigner) {
1287
+ event.preventDefault();
1288
+ return;
1289
+ }
1290
+
1291
+ // Picking a size that IS in stock ends the sold-out pick, even
1292
+ // the one already on the page — its link goes nowhere new, so
1293
+ // no navigation would drop the pick for it.
1294
+ if (stockAlertPick) setStockAlertPick(null);
1137
1295
  }}
1138
1296
  style={optionStyle}
1139
1297
  >
@@ -41,7 +41,7 @@ const orderPayload = {
41
41
  orderitem_set: [
42
42
  {
43
43
  price: '649.95',
44
- status: { value: '500', label: 'Shipped' },
44
+ status: { label: 'Shipped' },
45
45
  product: {
46
46
  name: 'Spring Sneaker',
47
47
  image: '/i.jpg',
@@ -63,19 +63,6 @@ describe('buildOrderPageContext', () => {
63
63
  expect(context.order.currency).toBe('TRY');
64
64
  expect(context.order.items).toHaveLength(1);
65
65
  expect(context.order.items[0].title).toBe('Spring Sneaker');
66
- expect(context.order.items[0].status).toBe('Shipped');
67
- expect(context.order.items[0].statusValue).toBe('500');
68
- });
69
-
70
- it('stringifies a numeric order item status code', () => {
71
- const context = buildOrderPageContext({
72
- order: {
73
- ...orderPayload,
74
- orderitem_set: [{ status: { value: 550, label: 'Delivered' } }]
75
- }
76
- });
77
-
78
- expect(context.order.items[0].statusValue).toBe('550');
79
66
  });
80
67
 
81
68
  it('does not publish the identity fields the raw payload carries', () => {
@@ -59,9 +59,6 @@ const toRecord = (value: unknown): UnknownRecord =>
59
59
  const toStringValue = (value: unknown, fallback = ''): string =>
60
60
  typeof value === 'string' ? value : fallback;
61
61
 
62
- const toCodeValue = (value: unknown): string =>
63
- typeof value === 'number' ? String(value) : toStringValue(value);
64
-
65
62
  const humanizeSlug = (value: string) =>
66
63
  value
67
64
  .split('-')
@@ -482,7 +479,6 @@ export const buildOrderPageContext = ({
482
479
  unitPrice: toStringValue(item.price),
483
480
  quantity: 1,
484
481
  status: toStringValue(toRecord(item.status).label),
485
- statusValue: toCodeValue(toRecord(item.status).value),
486
482
  currency
487
483
  };
488
484
  });
@@ -303,26 +303,6 @@ attached". The editor's **Use preview** toggle turns it off while the editor
303
303
  stays open, so the canvas behaves like the live site: whatever you held open
304
304
  closes and links navigate again.
305
305
 
306
- ## Reading the row a block is placed in
307
-
308
- A block dropped into a list's row layout (the child a designer marked as the
309
- row template of an order list, a product grid, an address list, …) renders once
310
- per row. The product lists open the product scope `useThemeProduct()` reads;
311
- every list also opens a row scope that carries the row itself:
312
-
313
- ```ts
314
- import { useThemeRowScope } from '@akinon/pz-theme/src/blocks/row-scope';
315
-
316
- const scope = useThemeRowScope();
317
- ```
318
-
319
- Inside a row it returns `{ listType, row, index }`, for example
320
- `listType: 'order-list'` with the order's `number` and `items` on `row`; anywhere
321
- else it returns `null`. `row` is the same record the row's bindings resolve
322
- against, so whatever a text block can bind to (`item.number`), code can read.
323
- Import it from that deep path for the same reason as `useThemePageContext`: the
324
- package root is not importable from a client module.
325
-
326
306
  ## Reporting what happened
327
307
 
328
308
  A custom unit can report a moment — a strip was seen, a lookbook was opened, a
@@ -325,6 +325,12 @@ export interface OverlayCloseButtonProps {
325
325
  textSide?: 'before' | 'after';
326
326
  /** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
327
327
  markerAttrs?: Record<string, string>;
328
+ /**
329
+ * Drawn instead of the built-in cross — a brand's own icon, e.g. the icon
330
+ * font glyph its other drawers close with. Absent draws the cross exactly
331
+ * as before, so `panel-legacy-golden.json` cannot move.
332
+ */
333
+ icon?: React.ReactNode;
328
334
  }
329
335
 
330
336
  /** px unless the author wrote a length. */
@@ -349,7 +355,8 @@ export const OverlayCloseButton = ({
349
355
  elevate,
350
356
  text,
351
357
  textSide,
352
- markerAttrs
358
+ markerAttrs,
359
+ icon
353
360
  }: OverlayCloseButtonProps) => (
354
361
  <button
355
362
  type="button"
@@ -394,17 +401,19 @@ export const OverlayCloseButton = ({
394
401
  : null)
395
402
  }}
396
403
  >
397
- <svg
398
- width={size === undefined ? '18' : asCssLength(size, '18px')}
399
- height={size === undefined ? '18' : asCssLength(size, '18px')}
400
- viewBox="0 0 24 24"
401
- fill="none"
402
- stroke="currentColor"
403
- strokeWidth="1.6"
404
- strokeLinecap="round"
405
- >
406
- <path d="M6 6l12 12M18 6L6 18" />
407
- </svg>
404
+ {icon ?? (
405
+ <svg
406
+ width={size === undefined ? '18' : asCssLength(size, '18px')}
407
+ height={size === undefined ? '18' : asCssLength(size, '18px')}
408
+ viewBox="0 0 24 24"
409
+ fill="none"
410
+ stroke="currentColor"
411
+ strokeWidth="1.6"
412
+ strokeLinecap="round"
413
+ >
414
+ <path d="M6 6l12 12M18 6L6 18" />
415
+ </svg>
416
+ )}
408
417
  {text ? <span {...partAttrs('close-text')}>{text}</span> : null}
409
418
  </button>
410
419
  );
@@ -1,118 +0,0 @@
1
- import React from 'react';
2
- import { render, screen } from '@testing-library/react';
3
- import { ItemTemplateList } from '../blocks/item-template';
4
- import { useThemeProduct } from '../blocks/product-block-utils';
5
- import { useThemeRowScope } from '../blocks/row-scope';
6
- import blockRendererRegistry from '../blocks/block-renderer-registry';
7
- import { resetCustomCatalog } from '../custom/catalog';
8
- import { resetCustomCollection } from '../custom/collect';
9
- import { isCustomThemeType, registerCustomBlocks } from '../custom';
10
- import type { Block } from '../theme-block';
11
-
12
- const READER = 'custom:acme:row-reader';
13
-
14
- function RowReader() {
15
- const scope = useThemeRowScope();
16
- const product = useThemeProduct();
17
-
18
- return (
19
- <span
20
- data-testid="reader"
21
- data-list={scope?.listType ?? ''}
22
- data-index={scope?.index ?? ''}
23
- data-number={String(scope?.row.number ?? '')}
24
- data-product={String(product?.pk ?? '')}
25
- />
26
- );
27
- }
28
-
29
- const template = (): Block =>
30
- ({
31
- id: 'tpl',
32
- type: 'group',
33
- label: 'Row',
34
- order: 0,
35
- hidden: false,
36
- value: '',
37
- properties: { role: { desktop: 'item-template' } },
38
- styles: {},
39
- blocks: [
40
- {
41
- id: 'tpl-reader',
42
- type: READER,
43
- label: 'Reader',
44
- order: 0,
45
- hidden: false,
46
- value: '',
47
- properties: {},
48
- styles: {},
49
- blocks: []
50
- }
51
- ]
52
- } as Block);
53
-
54
- const list = (type: string): Block =>
55
- ({
56
- id: 'list',
57
- type,
58
- label: 'List',
59
- order: 0,
60
- hidden: false,
61
- value: '',
62
- properties: {},
63
- styles: {},
64
- blocks: [template()]
65
- } as Block);
66
-
67
- const renderRows = (type: string, items: Record<string, unknown>[]) =>
68
- render(
69
- <ItemTemplateList
70
- block={list(type)}
71
- items={items}
72
- placeholderId="p1"
73
- sectionId="s1"
74
- />
75
- );
76
-
77
- beforeAll(() => {
78
- registerCustomBlocks('acme', { 'row-reader': RowReader });
79
- });
80
-
81
- afterAll(() => {
82
- const map = (
83
- blockRendererRegistry as unknown as { renderers: Map<string, unknown> }
84
- ).renderers;
85
- Array.from(map.keys())
86
- .filter(isCustomThemeType)
87
- .forEach((key) => map.delete(key));
88
- resetCustomCatalog();
89
- resetCustomCollection();
90
- });
91
-
92
- describe('the row scope', () => {
93
- it('hands every repeated row to the blocks inside it, on any list', () => {
94
- renderRows('order-list', [{ number: 'ORD-1' }, { number: 'ORD-2' }]);
95
-
96
- const readers = screen.getAllByTestId('reader');
97
- expect(readers).toHaveLength(2);
98
- expect(readers[0]).toHaveAttribute('data-list', 'order-list');
99
- expect(readers[0]).toHaveAttribute('data-index', '0');
100
- expect(readers[0]).toHaveAttribute('data-number', 'ORD-1');
101
- expect(readers[1]).toHaveAttribute('data-number', 'ORD-2');
102
- expect(readers[0]).toHaveAttribute('data-product', '');
103
- });
104
-
105
- it('keeps the product scope on product lists next to the row scope', () => {
106
- renderRows('product-grid', [{ pk: 7, name: 'Sneaker' }]);
107
-
108
- const reader = screen.getByTestId('reader');
109
- expect(reader).toHaveAttribute('data-list', 'product-grid');
110
- expect(reader).toHaveAttribute('data-product', '7');
111
- });
112
-
113
- it('is closed outside a repeated row', () => {
114
- render(<RowReader />);
115
-
116
- expect(screen.getByTestId('reader')).toHaveAttribute('data-list', '');
117
- });
118
- });