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

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.
@@ -9,6 +9,7 @@ import { useLogoutMutation } from '@akinon/next/data/client/user';
9
9
  import { emitCommerceEvent } from '../utils/analytics';
10
10
  import { getResponsiveValue, partAttrs } from '../utils';
11
11
  import { hasItemTemplate, ItemTemplateList } from './item-template';
12
+ import { readResponsiveArray } from './responsive-array';
12
13
  import { BlockRendererProps } from './block-renderer-registry';
13
14
 
14
15
  /**
@@ -23,6 +24,56 @@ const DEFAULT_ITEM_ORDER = [
23
24
  'coupons'
24
25
  ];
25
26
 
27
+ type ActiveMatch = 'exact' | 'startsWith';
28
+
29
+ /**
30
+ * One entry of the editor's item list (`menuItems`).
31
+ *
32
+ * `match` overrides the block-wide Active Item Match for this entry, and a
33
+ * blank one inherits it. `activePaths` names further pages that keep the entry
34
+ * selected — a "Personal details" tab that stays selected on the address and
35
+ * password pages — and each of them is matched with the entry's own rule.
36
+ */
37
+ interface AccountMenuListEntry {
38
+ id?: unknown;
39
+ label?: unknown;
40
+ href?: unknown;
41
+ match?: unknown;
42
+ activePaths?: unknown;
43
+ }
44
+
45
+ interface ResolvedMenuItem {
46
+ key: string;
47
+ href: string;
48
+ label: string;
49
+ match: ActiveMatch;
50
+ activePaths: string[];
51
+ }
52
+
53
+ const toActiveMatch = (value: unknown, fallback: ActiveMatch): ActiveMatch =>
54
+ value === 'exact' || value === 'startsWith' ? value : fallback;
55
+
56
+ const toText = (value: unknown) =>
57
+ typeof value === 'string' || typeof value === 'number'
58
+ ? String(value).trim()
59
+ : '';
60
+
61
+ // Commas, spaces and line breaks all separate paths: a path never contains
62
+ // one, and the editor field is free text.
63
+ const toPathList = (value: unknown) =>
64
+ typeof value === 'string'
65
+ ? value
66
+ .split(/[\s,]+/)
67
+ .map((path) => path.trim())
68
+ .filter(Boolean)
69
+ : [];
70
+
71
+ // `/account/orders/` and `/account/orders` are the same page; whether the
72
+ // pathname carries the slash depends on the brand's trailingSlash setting,
73
+ // and the configured href is typed by hand.
74
+ const trimTrailingSlash = (path: string) =>
75
+ path.length > 1 ? path.replace(/\/+$/, '') : path;
76
+
26
77
  /**
27
78
  * Functional account block: account area navigation.
28
79
  *
@@ -30,9 +81,14 @@ const DEFAULT_ITEM_ORDER = [
30
81
  * import the brand alias, so every target is an editor property. The
31
82
  * defaults are the conventional '/account/...' paths — brands whose ROUTES
32
83
  * differ (superstep uses '/users/orders' for orders) override them here.
33
- * Properties: showLogout, itemOrder, orientation, activeMatch, emptyLabel,
34
- * ordersHref, addressesHref, profileHref, favouritesHref, couponsHref,
35
- * logoutLabel and the matching *Label keys.
84
+ *
85
+ * Items come from `menuItems` — the list the designer adds to with + and
86
+ * removes from with − — whenever the block has one. Every composition saved
87
+ * before that list existed has none and keeps rendering from the five fixed
88
+ * keys: itemOrder, ordersHref, addressesHref, profileHref, favouritesHref,
89
+ * couponsHref and the matching *Label keys.
90
+ * Other properties: showLogout, orientation, overflow, activeMatch,
91
+ * emptyLabel, logoutLabel, logoutRedirect.
36
92
  * Parts: menu, item (selected), logout, empty.
37
93
  */
38
94
  const AccountMenuBlock = ({
@@ -63,74 +119,124 @@ const AccountMenuBlock = ({
63
119
  const showLogout = boolProp('showLogout', true);
64
120
  const orientation =
65
121
  prop('orientation', 'vertical') === 'horizontal' ? 'horizontal' : 'vertical';
66
- const activeMatch =
67
- prop('activeMatch', 'exact') === 'startsWith' ? 'startsWith' : 'exact';
122
+ // A horizontal menu that does not fit either wraps onto a new line (what it
123
+ // has always done) or stays on one line and scrolls sideways — the tab strip
124
+ // most brands put above the account pages on a phone.
125
+ const scrollsSideways =
126
+ orientation === 'horizontal' && prop('overflow', 'wrap') === 'scroll';
127
+ const activeMatch = toActiveMatch(prop('activeMatch', 'exact'), 'exact');
68
128
  const emptyLabel = prop('emptyLabel', '');
69
129
 
70
130
  const pathname = usePathname();
71
131
  const localize = useLocalizeHref();
72
132
  const [logout, { isLoading: isLoggingOut }] = useLogoutMutation();
73
133
 
74
- const definitions: Record<
75
- string,
76
- { key: string; href: string; label: string }
77
- > = {
78
- orders: {
79
- key: 'orders',
80
- href: prop('ordersHref', '/account/orders'),
81
- label: prop('ordersLabel', 'Orders')
82
- },
83
- addresses: {
84
- key: 'addresses',
85
- href: prop('addressesHref', '/account/address'),
86
- label: prop('addressesLabel', 'Addresses')
87
- },
88
- profile: {
89
- key: 'profile',
90
- href: prop('profileHref', '/account/profile'),
91
- label: prop('profileLabel', 'Profile')
92
- },
93
- favourites: {
94
- key: 'favourites',
95
- href: prop('favouritesHref', '/account/favourite-products'),
96
- label: prop('favouritesLabel', 'Favourites')
97
- },
98
- coupons: {
99
- key: 'coupons',
100
- href: prop('couponsHref', '/account/coupons'),
101
- label: prop('couponsLabel', 'Coupons')
102
- }
134
+ const legacyItems = (): ResolvedMenuItem[] => {
135
+ const definitions: Record<string, { href: string; label: string }> = {
136
+ orders: {
137
+ href: prop('ordersHref', '/account/orders'),
138
+ label: prop('ordersLabel', 'Orders')
139
+ },
140
+ addresses: {
141
+ href: prop('addressesHref', '/account/address'),
142
+ label: prop('addressesLabel', 'Addresses')
143
+ },
144
+ profile: {
145
+ href: prop('profileHref', '/account/profile'),
146
+ label: prop('profileLabel', 'Profile')
147
+ },
148
+ favourites: {
149
+ href: prop('favouritesHref', '/account/favourite-products'),
150
+ label: prop('favouritesLabel', 'Favourites')
151
+ },
152
+ coupons: {
153
+ href: prop('couponsHref', '/account/coupons'),
154
+ label: prop('couponsLabel', 'Coupons')
155
+ }
156
+ };
157
+
158
+ const requestedOrder = prop('itemOrder', DEFAULT_ITEM_ORDER.join(','))
159
+ .split(',')
160
+ .map((key) => key.trim())
161
+ .filter(Boolean);
162
+ // A repeated key would render the same route twice and collide on the
163
+ // React key, so the list is de-duplicated before it is resolved.
164
+ const resolvedOrder = Array.from(
165
+ new Set(requestedOrder.length > 0 ? requestedOrder : DEFAULT_ITEM_ORDER)
166
+ );
167
+
168
+ return resolvedOrder
169
+ .filter((key) => Boolean(definitions[key]))
170
+ .map((key) => ({
171
+ key,
172
+ href: definitions[key].href,
173
+ label: definitions[key].label,
174
+ match: activeMatch,
175
+ activePaths: []
176
+ }))
177
+ .filter((item) => Boolean(item.href) && Boolean(item.label));
103
178
  };
104
179
 
105
- const requestedOrder = prop('itemOrder', DEFAULT_ITEM_ORDER.join(','))
106
- .split(',')
107
- .map((key) => key.trim())
108
- .filter(Boolean);
109
- // A repeated key would render the same route twice and collide on the React
110
- // key, so the list is de-duplicated before it is resolved.
111
- const resolvedOrder = Array.from(
112
- new Set(requestedOrder.length > 0 ? requestedOrder : DEFAULT_ITEM_ORDER)
113
- );
180
+ const listItems = (rawList: unknown): ResolvedMenuItem[] => {
181
+ const seenKeys = new Set<string>();
182
+
183
+ return readResponsiveArray<AccountMenuListEntry | null>(
184
+ rawList,
185
+ currentBreakpoint
186
+ )
187
+ .map((entry, index) => {
188
+ const id = toText(entry?.id) || `item-${index + 1}`;
189
+ // Two entries sharing an id (one duplicated by hand in the JSON) would
190
+ // collide on the React key, so the second one is told apart.
191
+ const key = seenKeys.has(id) ? `${id}-${index + 1}` : id;
192
+ seenKeys.add(key);
114
193
 
115
- const items = resolvedOrder
116
- .map((key) => definitions[key])
117
- .filter((item) => Boolean(item?.href) && Boolean(item?.label));
194
+ return {
195
+ key,
196
+ href: toText(entry?.href),
197
+ label: toText(entry?.label),
198
+ match: toActiveMatch(entry?.match, activeMatch),
199
+ activePaths: toPathList(entry?.activePaths)
200
+ };
201
+ })
202
+ .filter((item) => Boolean(item.href) && Boolean(item.label));
203
+ };
204
+
205
+ // A list is any array, flat or per breakpoint. An emptied list is still a
206
+ // list: the designer removed every entry, so the menu shows none. An object
207
+ // holding no array is not a list: a block dropped in the editor can carry
208
+ // `{desktop: undefined}` (`{}` once saved), and it must keep the five fixed
209
+ // entries the editor's panel shows for it, not render an empty menu.
210
+ const rawMenuItems = block.properties?.menuItems;
211
+ const hasItemList =
212
+ Array.isArray(rawMenuItems) ||
213
+ (rawMenuItems !== null &&
214
+ typeof rawMenuItems === 'object' &&
215
+ Object.values(rawMenuItems).some(Array.isArray));
216
+
217
+ const items = hasItemList ? listItems(rawMenuItems) : legacyItems();
118
218
 
119
219
  // The pathname carries the locale (`/en/account/orders`) and the configured
120
220
  // hrefs do not, so each item is matched in the localized form its link renders.
121
- const isActive = (href: string) => {
221
+ const matchesPage = (href: string, match: ActiveMatch) => {
122
222
  if (!pathname || !href) return false;
123
- const target = localize(href);
124
- if (activeMatch === 'startsWith') {
223
+ const current = trimTrailingSlash(pathname);
224
+ const target = trimTrailingSlash(localize(href));
225
+ if (match === 'startsWith') {
125
226
  return (
126
- pathname === target ||
127
- pathname.startsWith(`${target.replace(/\/$/, '')}/`)
227
+ current === target ||
228
+ current.startsWith(`${target.replace(/\/$/, '')}/`)
128
229
  );
129
230
  }
130
231
 
131
- return pathname === target;
232
+ return current === target;
132
233
  };
133
234
 
235
+ const isActive = (item: ResolvedMenuItem) =>
236
+ [item.href, ...item.activePaths].some((href) =>
237
+ matchesPage(href, item.match)
238
+ );
239
+
134
240
  const handleLogout = async () => {
135
241
  if (isDesigner) return;
136
242
 
@@ -189,7 +295,10 @@ const AccountMenuBlock = ({
189
295
  padding: '8px 0',
190
296
  color: 'inherit',
191
297
  textDecoration: 'none',
192
- fontWeight: active ? 600 : 400
298
+ fontWeight: active ? 600 : 400,
299
+ // On a scrolling strip an entry keeps its full width instead of being
300
+ // squeezed until its label breaks.
301
+ ...(scrollsSideways ? { flexShrink: 0 } : null)
193
302
  });
194
303
 
195
304
  // Both spellings of each field are published so a designer can bind either
@@ -201,7 +310,7 @@ const AccountMenuBlock = ({
201
310
  label: item.label,
202
311
  href: item.href,
203
312
  url: item.href,
204
- active: isActive(item.href)
313
+ active: isActive(item)
205
314
  }));
206
315
 
207
316
  // A designer who marked a child as the row layout gets that layout repeated
@@ -219,21 +328,34 @@ const AccountMenuBlock = ({
219
328
  />
220
329
  ) : null;
221
330
 
331
+ const horizontalLayout: React.CSSProperties = scrollsSideways
332
+ ? {
333
+ flexWrap: 'nowrap',
334
+ overflowX: 'auto',
335
+ // `nowrap` is inherited by row layouts too, so a templated row is as
336
+ // wide as its content and the strip scrolls instead of squeezing it.
337
+ whiteSpace: 'nowrap',
338
+ scrollbarWidth: 'none',
339
+ WebkitOverflowScrolling: 'touch'
340
+ }
341
+ : { flexWrap: 'wrap' };
342
+
222
343
  return (
223
344
  <nav
224
345
  data-account-menu=""
225
346
  data-account-menu-orientation={orientation}
347
+ data-account-menu-overflow={scrollsSideways ? 'scroll' : undefined}
226
348
  {...partAttrs('menu')}
227
349
  style={{
228
350
  display: 'flex',
229
351
  flexDirection: orientation === 'horizontal' ? 'row' : 'column',
230
- ...(orientation === 'horizontal' ? { flexWrap: 'wrap' } : null),
352
+ ...(orientation === 'horizontal' ? horizontalLayout : null),
231
353
  gap: 'var(--account-menu-gap, 4px)'
232
354
  }}
233
355
  >
234
356
  {customItems ??
235
357
  items.map((item) => {
236
- const active = isActive(item.href);
358
+ const active = isActive(item);
237
359
 
238
360
  return (
239
361
  <NextLink
@@ -13,7 +13,6 @@ import {
13
13
  ITEM_TEMPLATE_ROLE,
14
14
  ItemTemplateList
15
15
  } from './item-template';
16
- import { renderOrderLineActions } from './order-line-actions';
17
16
  import { BlockRendererProps } from './block-renderer-registry';
18
17
  import type { Block } from '../theme-block';
19
18
  import {
@@ -30,7 +29,7 @@ import {
30
29
  useOrderActionRules
31
30
  } from '../utils/order-action-rules';
32
31
 
33
- type OrderItemLike = {
32
+ interface OrderItemLike {
34
33
  id?: number;
35
34
  price?: string;
36
35
  quantity?: string | number;
@@ -52,7 +51,7 @@ type OrderItemLike = {
52
51
  absolute_url?: string;
53
52
  attributes?: Record<string, unknown>;
54
53
  } | null;
55
- };
54
+ }
56
55
 
57
56
  interface AddressLike {
58
57
  first_name?: string;
@@ -431,7 +430,7 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
431
430
  * currencyText, currencyPosition.
432
431
  * Parts: detail, header, number, date, header-total, status,
433
432
  * invoice-link, cancel-link, return-link, list, item, image, name, quantity,
434
- * color, size, item-status, item-tracking, item-return-code, item-actions,
433
+ * color, size, item-status, item-tracking, item-return-code,
435
434
  * item-return-method, item-return-carrier, item-pickup-date, price, shipment,
436
435
  * delivery, tracking, addresses, address, address-label, totals, total-row,
437
436
  * total-label, total-value, grand-total, payment-info, payment-info-label.
@@ -1132,21 +1131,6 @@ const OrderDetailBlock = ({
1132
1131
  showPickupDate && returnDetails.pickupDate
1133
1132
  ? formatPickupDate(returnDetails.pickupDate, locale)
1134
1133
  : '';
1135
- const lineActions = renderOrderLineActions({
1136
- order,
1137
- line,
1138
- isDesigner
1139
- });
1140
- const showLineMeta =
1141
- showShipmentStatus &&
1142
- Boolean(
1143
- item.status?.label ||
1144
- trackingValue ||
1145
- returnCodeValue ||
1146
- returnMethodValue ||
1147
- returnCarrierValue ||
1148
- pickupDateValue
1149
- );
1150
1134
 
1151
1135
  return (
1152
1136
  <li
@@ -1262,100 +1246,98 @@ const OrderDetailBlock = ({
1262
1246
  </div>
1263
1247
  </div>
1264
1248
 
1265
- {(showLineMeta || lineActions) && (
1266
- <div
1267
- style={{
1268
- display: 'flex',
1269
- flexDirection: 'column',
1270
- alignItems: isMobile ? 'flex-start' : 'flex-end',
1271
- textAlign: isMobile ? 'left' : 'right',
1272
- gap: '4px',
1273
- flex: isMobile ? '1 1 100%' : '0 0 auto'
1274
- }}
1275
- >
1276
- {lineActions && (
1277
- <div
1278
- data-order-detail-item-actions=""
1279
- {...partAttrs('item-actions')}
1280
- >
1281
- {lineActions}
1282
- </div>
1283
- )}
1284
- {showLineMeta && item.status?.label && (
1285
- <span
1286
- data-order-detail-item-status=""
1287
- data-order-status-value={item.status.value}
1288
- {...partAttrs('item-status')}
1289
- style={{ display: 'inline-block' }}
1290
- >
1291
- {item.status.label}
1292
- </span>
1293
- )}
1294
- {/* Label and value in their own spans, so a
1295
- composition can colour them apart. */}
1296
- {showLineMeta && returnMethodValue && (
1297
- <span
1298
- data-order-detail-item-return-method=""
1299
- {...partAttrs('item-return-method')}
1300
- style={{ opacity: 0.8 }}
1301
- >
1302
- <span data-label="">{returnMethodLabel}</span>{' '}
1303
- <span data-value="">{returnMethodValue}</span>
1304
- </span>
1305
- )}
1306
- {showLineMeta && returnCarrierValue && (
1307
- <span
1308
- data-order-detail-item-return-carrier=""
1309
- {...partAttrs('item-return-carrier')}
1310
- style={{ opacity: 0.8 }}
1311
- >
1312
- <span data-label="">{returnCarrierLabel}</span>{' '}
1313
- <span data-value="">{returnCarrierValue}</span>
1314
- </span>
1315
- )}
1316
- {showLineMeta && pickupDateValue && (
1317
- <span
1318
- data-order-detail-item-pickup-date=""
1319
- {...partAttrs('item-pickup-date')}
1320
- style={{ opacity: 0.8 }}
1321
- >
1322
- <span data-label="">{pickupDateLabel}</span>{' '}
1323
- <span data-value="">{pickupDateValue}</span>
1324
- </span>
1325
- )}
1326
- {showLineMeta && trackingValue && (
1327
- <span
1328
- data-order-detail-item-tracking=""
1329
- {...partAttrs('item-tracking')}
1330
- style={{ opacity: 0.8 }}
1331
- >
1332
- {lineTrackingLabel}{' '}
1333
- {item.tracking_url ? (
1334
- <a
1335
- href={isDesigner ? '#' : item.tracking_url}
1336
- onClick={guard}
1337
- target={isDesigner ? undefined : '_blank'}
1338
- rel="noreferrer"
1339
- style={{ color: 'inherit' }}
1340
- >
1341
- {item.tracking_number || item.tracking_url}
1342
- </a>
1343
- ) : (
1344
- trackingValue
1345
- )}
1346
- </span>
1347
- )}
1348
- {showLineMeta && returnCodeValue && (
1349
- <span
1350
- data-order-detail-item-return-code=""
1351
- {...partAttrs('item-return-code')}
1352
- style={{ opacity: 0.8 }}
1353
- >
1354
- {returnCodeLabel} {returnCodeValue}
1355
- </span>
1356
- )}
1357
- </div>
1358
- )}
1249
+ {showShipmentStatus &&
1250
+ (item.status?.label ||
1251
+ trackingValue ||
1252
+ returnCodeValue ||
1253
+ returnMethodValue ||
1254
+ returnCarrierValue ||
1255
+ pickupDateValue) && (
1256
+ <div
1257
+ style={{
1258
+ display: 'flex',
1259
+ flexDirection: 'column',
1260
+ alignItems: isMobile ? 'flex-start' : 'flex-end',
1261
+ textAlign: isMobile ? 'left' : 'right',
1262
+ gap: '4px',
1263
+ flex: isMobile ? '1 1 100%' : '0 0 auto'
1264
+ }}
1265
+ >
1266
+ {item.status?.label && (
1267
+ <span
1268
+ data-order-detail-item-status=""
1269
+ data-order-status-value={item.status.value}
1270
+ {...partAttrs('item-status')}
1271
+ style={{ display: 'inline-block' }}
1272
+ >
1273
+ {item.status.label}
1274
+ </span>
1275
+ )}
1276
+ {/* Label and value in their own spans, so a
1277
+ composition can colour them apart. */}
1278
+ {returnMethodValue && (
1279
+ <span
1280
+ data-order-detail-item-return-method=""
1281
+ {...partAttrs('item-return-method')}
1282
+ style={{ opacity: 0.8 }}
1283
+ >
1284
+ <span data-label="">{returnMethodLabel}</span>{' '}
1285
+ <span data-value="">{returnMethodValue}</span>
1286
+ </span>
1287
+ )}
1288
+ {returnCarrierValue && (
1289
+ <span
1290
+ data-order-detail-item-return-carrier=""
1291
+ {...partAttrs('item-return-carrier')}
1292
+ style={{ opacity: 0.8 }}
1293
+ >
1294
+ <span data-label="">{returnCarrierLabel}</span>{' '}
1295
+ <span data-value="">{returnCarrierValue}</span>
1296
+ </span>
1297
+ )}
1298
+ {pickupDateValue && (
1299
+ <span
1300
+ data-order-detail-item-pickup-date=""
1301
+ {...partAttrs('item-pickup-date')}
1302
+ style={{ opacity: 0.8 }}
1303
+ >
1304
+ <span data-label="">{pickupDateLabel}</span>{' '}
1305
+ <span data-value="">{pickupDateValue}</span>
1306
+ </span>
1307
+ )}
1308
+ {trackingValue && (
1309
+ <span
1310
+ data-order-detail-item-tracking=""
1311
+ {...partAttrs('item-tracking')}
1312
+ style={{ opacity: 0.8 }}
1313
+ >
1314
+ {lineTrackingLabel}{' '}
1315
+ {item.tracking_url ? (
1316
+ <a
1317
+ href={isDesigner ? '#' : item.tracking_url}
1318
+ onClick={guard}
1319
+ target={isDesigner ? undefined : '_blank'}
1320
+ rel="noreferrer"
1321
+ style={{ color: 'inherit' }}
1322
+ >
1323
+ {item.tracking_number || item.tracking_url}
1324
+ </a>
1325
+ ) : (
1326
+ trackingValue
1327
+ )}
1328
+ </span>
1329
+ )}
1330
+ {returnCodeValue && (
1331
+ <span
1332
+ data-order-detail-item-return-code=""
1333
+ {...partAttrs('item-return-code')}
1334
+ style={{ opacity: 0.8 }}
1335
+ >
1336
+ {returnCodeLabel} {returnCodeValue}
1337
+ </span>
1338
+ )}
1339
+ </div>
1340
+ )}
1359
1341
  </div>
1360
1342
  </li>
1361
1343
  );
@@ -7,7 +7,6 @@ import { getInnerRootInlineStyles, partAttrs } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
  import { useLocalizeHref } from '../hooks/use-localize-href';
9
9
  import { localizeHtmlLinks } from '../utils/localize-href';
10
- import { useValueLocale } from '../utils/localized-value';
11
10
  // Rich-text element defaults from the theme settings.
12
11
  import {
13
12
  THEME_RICH_TEXT_ATTRIBUTE,
@@ -20,7 +19,7 @@ const TextBlock = ({
20
19
  }: BlockRendererProps) => {
21
20
  const { locale } = useLocalization();
22
21
  const localize = useLocalizeHref();
23
- const { defaultLocale } = useValueLocale();
22
+ const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
24
23
  const themeSettings = useThemeSettingsContext();
25
24
 
26
25
  const getLocalizedContent = (): string => {
@@ -36,10 +35,7 @@ const TextBlock = ({
36
35
 
37
36
  if (typeof value === 'object' && value !== null) {
38
37
  return (
39
- value[locale] ||
40
- (defaultLocale && value[defaultLocale]) ||
41
- Object.values(value)[0] ||
42
- ''
38
+ value[locale] || value[defaultLocale] || Object.values(value)[0] || ''
43
39
  );
44
40
  }
45
41