@akinon/pz-theme 2.0.65-beta.0 → 2.0.65-rc.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.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useState } from 'react';
3
+ import React from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { useSession } from 'next-auth/react';
6
6
  import { useGetOrdersQuery } from '@akinon/next/data/client/account';
@@ -11,10 +11,6 @@ import { hasItemTemplate, ItemTemplateList } from './item-template';
11
11
  import { readToggle } from './product-block-utils';
12
12
  import { BlockRendererProps } from './block-renderer-registry';
13
13
 
14
- interface OrderItemLike {
15
- product?: { image?: string; name?: string } | null;
16
- }
17
-
18
14
  interface OrderLike {
19
15
  id?: number;
20
16
  number?: string;
@@ -22,15 +18,9 @@ interface OrderLike {
22
18
  created_date?: string;
23
19
  status?: { value?: string; label?: string };
24
20
  currency?: { value?: string; label?: string };
25
- orderitem_set?: OrderItemLike[];
21
+ orderitem_set?: unknown[];
26
22
  }
27
23
 
28
- const readCount = (value: unknown, fallback: number): number => {
29
- const numeric = Math.round(Number(value));
30
-
31
- return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
32
- };
33
-
34
24
  /** Newest/oldest first without mutating the array RTK Query handed us. */
35
25
  const sortOrders = (orders: OrderLike[], sort: string): OrderLike[] => {
36
26
  if (sort !== 'newest' && sort !== 'oldest') return orders;
@@ -52,11 +42,8 @@ const sortOrders = (orders: OrderLike[], sort: string): OrderLike[] => {
52
42
  * rows are shown, which fields the row carries, where a row links to and the
53
43
  * styling.
54
44
  * Properties: limit, orderHrefBase, sort, showDate, showStatus, showAmount,
55
- * showItemImages, imageWidth, imageHeight, imageGap, imageAlign, maxImages,
56
- * numberLabel, dateLabel, totalLabel, detailLinkLabel, showTitle, title,
57
- * showPagination, perPage, emptyLabel.
58
- * Parts: title, list, item, images, image-overflow, link, date, total,
59
- * detail-link, status, pagination, empty.
45
+ * emptyLabel.
46
+ * Parts: list, item, link, date, total, status, empty.
60
47
  */
61
48
  const OrderListBlock = ({
62
49
  block,
@@ -96,108 +83,23 @@ const OrderListBlock = ({
96
83
  currentBreakpoint,
97
84
  true
98
85
  );
99
- const showItemImages = readToggle(
100
- block.properties?.showItemImages,
101
- currentBreakpoint,
102
- true
103
- );
104
- const imageWidth = readCount(
105
- getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 64),
106
- 64
107
- );
108
- const imageHeight = readCount(
109
- getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 96),
110
- 96
111
- );
112
- const imageGap = readCount(
113
- getResponsiveValue(block.properties?.imageGap, currentBreakpoint, 8),
114
- 8
115
- );
116
- const imageAlignRaw = String(
117
- getResponsiveValue(block.properties?.imageAlign, currentBreakpoint, 'left')
118
- );
119
- const imageJustify =
120
- imageAlignRaw === 'center'
121
- ? 'center'
122
- : imageAlignRaw === 'right'
123
- ? 'flex-end'
124
- : 'flex-start';
125
- const maxImages = Math.max(
126
- 1,
127
- readCount(
128
- getResponsiveValue(block.properties?.maxImages, currentBreakpoint, 3),
129
- 3
130
- )
131
- );
132
86
  const emptyLabel = String(
133
87
  getResponsiveValue(block.properties?.emptyLabel, currentBreakpoint, '')
134
88
  );
135
- // Empty keeps the built-in row exactly as it always rendered — a brand
136
- // opts into labels and a standalone detail link by setting these.
137
- const numberLabel = String(
138
- getResponsiveValue(block.properties?.numberLabel, currentBreakpoint, '')
139
- );
140
- const dateLabel = String(
141
- getResponsiveValue(block.properties?.dateLabel, currentBreakpoint, '')
142
- );
143
- const totalLabel = String(
144
- getResponsiveValue(block.properties?.totalLabel, currentBreakpoint, '')
145
- );
146
- const detailLinkLabel = String(
147
- getResponsiveValue(block.properties?.detailLinkLabel, currentBreakpoint, '')
148
- );
149
- const showTitle = readToggle(
150
- block.properties?.showTitle,
151
- currentBreakpoint,
152
- false
153
- );
154
- const title = String(
155
- getResponsiveValue(block.properties?.title, currentBreakpoint, '')
156
- );
157
- const showPagination = readToggle(
158
- block.properties?.showPagination,
159
- currentBreakpoint,
160
- false
161
- );
162
- const perPage = Math.max(
163
- 1,
164
- readCount(
165
- getResponsiveValue(block.properties?.perPage, currentBreakpoint, 10),
166
- 10
167
- )
168
- );
169
-
170
- const isMobile = currentBreakpoint === 'mobile';
171
89
 
172
90
  const { locale, currency: localeCurrency } = useLocalization();
173
91
  const { status } = useSession();
174
92
  const isAuthenticated = status === 'authenticated';
175
93
 
176
- // Paged mode reads perPage/currentPage from the server; unpaged mode keeps
177
- // the original single fetch capped by `limit`.
178
- const [currentPage, setCurrentPage] = useState(1);
179
-
180
- // A brand switching pageSize (or sort) mid-session should not strand the
181
- // shopper on a page number that no longer exists.
182
- useEffect(() => {
183
- setCurrentPage(1);
184
- }, [perPage, sort, showPagination]);
185
-
186
- const effectiveLimit = showPagination ? perPage : limit;
187
-
188
94
  // Guests have no orders endpoint; skipping avoids a guaranteed 403 on every
189
95
  // page that hosts this block.
190
96
  const { data, isLoading } = useGetOrdersQuery(
191
- { limit: effectiveLimit, page: showPagination ? currentPage : 1 },
97
+ { limit, page: 1 },
192
98
  { skip: !isAuthenticated }
193
99
  );
194
100
 
195
101
  const orders: OrderLike[] =
196
102
  (data as { results?: OrderLike[] } | undefined)?.results ?? [];
197
- const totalCount = (data as { count?: number } | undefined)?.count ?? 0;
198
- const totalPages = showPagination
199
- ? Math.max(1, Math.ceil(totalCount / perPage))
200
- : 1;
201
103
 
202
104
  // Navigating inside the designer iframe would drop the author out of the
203
105
  // page they are editing, so the order links are inert there.
@@ -206,11 +108,9 @@ const OrderListBlock = ({
206
108
  };
207
109
 
208
110
  const hrefFor = (order: OrderLike) =>
209
- order.id
210
- ? `${orderHrefBase.replace(/\/$/, '')}/${order.id}`
211
- : orderHrefBase;
111
+ order.id ? `${orderHrefBase.replace(/\/$/, '')}/${order.id}` : orderHrefBase;
212
112
 
213
- const visibleOrders = sortOrders(orders, sort).slice(0, effectiveLimit);
113
+ const visibleOrders = sortOrders(orders, sort).slice(0, limit);
214
114
 
215
115
  const rows = visibleOrders.map((order, index) => ({
216
116
  id: order.id ?? '',
@@ -233,15 +133,6 @@ const OrderListBlock = ({
233
133
  itemCount: Array.isArray(order.orderitem_set)
234
134
  ? order.orderitem_set.length
235
135
  : 0,
236
- images: (Array.isArray(order.orderitem_set) ? order.orderitem_set : [])
237
- .slice(0, maxImages)
238
- .map((item) => item?.product?.image || '')
239
- .filter(Boolean),
240
- imageOverflowCount: Math.max(
241
- 0,
242
- (Array.isArray(order.orderitem_set) ? order.orderitem_set.length : 0) -
243
- maxImages
244
- ),
245
136
  url: hrefFor(order),
246
137
  href: hrefFor(order)
247
138
  }));
@@ -260,390 +151,123 @@ const OrderListBlock = ({
260
151
  />
261
152
  ) : null;
262
153
 
263
- const titleNode =
264
- showTitle && title ? (
265
- <div
266
- data-order-list-title=""
267
- {...partAttrs('title')}
268
- style={{
269
- width: '100%',
270
- fontSize: '16px',
271
- fontWeight: 600,
272
- marginBottom: '16px'
273
- }}
274
- >
275
- {title}
276
- </div>
277
- ) : null;
278
-
279
154
  if (orders.length === 0 && !customRows) {
280
155
  if (isDesigner) {
281
156
  return (
282
- <>
283
- {titleNode}
284
- <div
285
- data-order-list=""
286
- data-order-list-state="empty"
287
- {...partAttrs('empty', { empty: true })}
288
- style={{ width: '100%' }}
289
- >
290
- {isLoading
291
- ? 'Recent orders (loading)'
292
- : emptyLabel || 'Recent orders (no orders)'}
293
- </div>
294
- </>
157
+ <div
158
+ data-order-list=""
159
+ data-order-list-state="empty"
160
+ {...partAttrs('empty', { empty: true })}
161
+ >
162
+ {isLoading
163
+ ? 'Recent orders (loading)'
164
+ : emptyLabel || 'Recent orders (no orders)'}
165
+ </div>
295
166
  );
296
167
  }
297
168
 
298
169
  // The shopper-facing empty copy is only honest once the account query has
299
170
  // resolved; before that (and without a message) the block stays absent,
300
171
  // which is what every published composition renders today.
301
- if (!isAuthenticated || isLoading || !emptyLabel) return titleNode;
172
+ if (!isAuthenticated || isLoading || !emptyLabel) return null;
302
173
 
303
174
  return (
304
- <>
305
- {titleNode}
306
- <div
307
- data-order-list=""
308
- data-order-list-state="empty"
309
- {...partAttrs('empty', { empty: true })}
310
- style={{ width: '100%' }}
311
- >
312
- {emptyLabel}
313
- </div>
314
- </>
175
+ <div
176
+ data-order-list=""
177
+ data-order-list-state="empty"
178
+ {...partAttrs('empty', { empty: true })}
179
+ >
180
+ {emptyLabel}
181
+ </div>
315
182
  );
316
183
  }
317
184
 
318
185
  return (
319
- <>
320
- {titleNode}
321
- <ul
322
- data-order-list=""
323
- {...partAttrs('list')}
324
- style={{
325
- display: 'flex',
326
- flexDirection: 'column',
327
- width: '100%',
328
- gap: 'var(--order-list-gap, 12px)',
329
- listStyle: 'none',
330
- margin: 0,
331
- padding: 0
332
- }}
333
- >
334
- {customRows ??
335
- visibleOrders.map((order, index) => {
336
- const href = hrefFor(order);
337
- const orderItems = Array.isArray(order.orderitem_set)
338
- ? order.orderitem_set
339
- : [];
340
- const images = orderItems
341
- .slice(0, maxImages)
342
- .map((item) => item?.product?.image || '')
343
- .filter(Boolean);
344
- const imageOverflowCount = Math.max(
345
- 0,
346
- orderItems.length - maxImages
347
- );
348
-
349
- // A label turns its row into a label/value grid pair; without one
350
- // the value alone spans both columns, exactly as before.
351
- const gridPair = (
352
- label: string,
353
- value: React.ReactNode,
354
- partName: string,
355
- dataAttr: string
356
- ) => (
357
- <React.Fragment>
358
- {label && (
359
- <span style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
360
- {label}
361
- </span>
362
- )}
363
- <span
364
- {...{ [dataAttr]: '' }}
365
- {...partAttrs(partName)}
366
- // No default text color: the Order Number/Date/Total style
367
- // groups already expose one, and an inline default would
368
- // sit in front of that panel value rather than behind it.
369
- // A template asking for a muted value colour sets it as a
370
- // `link|date|total/color` style, not a code default.
371
- style={{
372
- gridColumn: label ? 'auto' : '1 / span 2',
373
- // An order number is one long unbroken digit string —
374
- // without this it can't wrap at all, so shrinking the
375
- // column (above) just pushes it past the row's edge.
376
- overflowWrap: 'anywhere'
377
- }}
186
+ <ul
187
+ data-order-list=""
188
+ {...partAttrs('list')}
189
+ style={{
190
+ display: 'flex',
191
+ flexDirection: 'column',
192
+ gap: 'var(--order-list-gap, 12px)',
193
+ listStyle: 'none',
194
+ margin: 0,
195
+ padding: 0
196
+ }}
197
+ >
198
+ {customRows ??
199
+ visibleOrders.map((order, index) => {
200
+ const href = hrefFor(order);
201
+
202
+ return (
203
+ <li
204
+ key={order.id ?? order.number ?? index}
205
+ data-order-list-item=""
206
+ {...partAttrs('item')}
207
+ style={{
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ justifyContent: 'space-between',
211
+ gap: '12px',
212
+ padding: '12px 0',
213
+ borderBottom: '1px solid var(--order-list-border, #e5e7eb)'
214
+ }}
215
+ >
216
+ <div style={{ minWidth: 0 }}>
217
+ <NextLink
218
+ href={isDesigner ? '#' : href}
219
+ onClick={guard}
220
+ data-order-list-number=""
221
+ {...partAttrs('link')}
222
+ style={{ color: 'inherit', textDecoration: 'none' }}
378
223
  >
379
- {value}
380
- </span>
381
- </React.Fragment>
382
- );
224
+ {order.number || `#${order.id ?? ''}`}
225
+ </NextLink>
226
+ {showDate && (
227
+ <div
228
+ data-order-list-date=""
229
+ {...partAttrs('date')}
230
+ style={{ opacity: 0.8, marginTop: '2px' }}
231
+ >
232
+ {formatDate(order.created_date, locale)}
233
+ </div>
234
+ )}
235
+ </div>
383
236
 
384
- return (
385
- <li
386
- key={order.id ?? order.number ?? index}
387
- data-order-list-item=""
388
- {...partAttrs('item')}
237
+ <div
389
238
  style={{
390
239
  display: 'flex',
391
- flexDirection: isMobile ? 'column' : 'row',
392
- alignItems: isMobile ? 'stretch' : 'flex-start',
393
- gap: isMobile ? '16px' : '24px',
394
- padding: '20px 0',
395
- borderBottom: '1px solid var(--order-list-border, #e5e7eb)'
240
+ flexDirection: 'column',
241
+ alignItems: 'flex-end'
396
242
  }}
397
243
  >
398
- {showItemImages && images.length > 0 && (
399
- <ul
400
- data-order-list-images=""
401
- style={{
402
- display: 'flex',
403
- // Where the images sit inside their half when they
404
- // don't fill it — a single order's one thumbnail on
405
- // an otherwise 3-image row, for instance.
406
- justifyContent: imageJustify,
407
- gap: `${imageGap}px`,
408
- listStyle: 'none',
409
- margin: 0,
410
- padding: 0,
411
- minWidth: 0,
412
- // Grow/shrink together with the info half (below), not
413
- // a rigid 50% — a host narrower than the full page (a
414
- // dashboard widget next to a sidebar, say) needs both
415
- // halves to give way instead of overflowing on top of
416
- // each other.
417
- flex: isMobile ? '0 0 auto' : '1 1 50%',
418
- width: isMobile ? '100%' : undefined
419
- }}
420
- >
421
- {images.map((src, imageIndex) => (
422
- // Plain img: order thumbnails come from arbitrary commerce
423
- // CDNs that the brand next.config may not allow. The
424
- // Product Images style group targets each thumbnail
425
- // (not this wrapper), so its box controls land on an
426
- // element they actually make sense on.
427
- // eslint-disable-next-line @next/next/no-img-element
428
- <img
429
- key={imageIndex}
430
- {...partAttrs('images')}
431
- src={src}
432
- alt=""
433
- width={imageWidth}
434
- height={imageHeight}
435
- style={{
436
- objectFit: 'cover',
437
- display: 'block',
438
- flex: '0 0 auto'
439
- }}
440
- />
441
- ))}
442
- {imageOverflowCount > 0 && (
443
- <li
444
- data-order-list-image-overflow=""
445
- {...partAttrs('image-overflow')}
446
- style={{
447
- display: 'flex',
448
- alignItems: 'center',
449
- justifyContent: 'center',
450
- flex: '0 0 auto'
451
- }}
452
- >
453
- +{imageOverflowCount}
454
- </li>
455
- )}
456
- </ul>
244
+ {showAmount && (
245
+ <span data-order-list-amount="" {...partAttrs('total')}>
246
+ {/* The order carries the currency it was placed in; the
247
+ storefront currency is only the fallback. */}
248
+ {formatMoney(order.amount, {
249
+ locale,
250
+ currency: order.currency?.value || localeCurrency
251
+ })}
252
+ </span>
457
253
  )}
458
-
459
- {/* The other half of the row: number/date/total, the detail
460
- link and the status pill. Stacks full width on mobile,
461
- matches the images column's 50% on desktop. */}
462
- <div
463
- style={{
464
- display: 'flex',
465
- flexDirection: isMobile ? 'column' : 'row',
466
- alignItems: isMobile ? 'stretch' : 'flex-start',
467
- justifyContent: isMobile ? 'flex-start' : 'space-between',
468
- // A safety net for the row/status column below, on top
469
- // of the shrinking above: if there still isn't room once
470
- // both halves have given up all their slack, this drops
471
- // the status pill to its own line instead of it landing
472
- // on top of the order number.
473
- flexWrap: 'wrap',
474
- gap: isMobile ? '10px' : '24px',
475
- flex: isMobile ? '1 1 auto' : '1 1 50%',
476
- minWidth: 0,
477
- width: isMobile ? '100%' : undefined
478
- }}
479
- >
480
- <div
481
- style={{
482
- minWidth: 0,
483
- // The value column: 'auto' alone has no minimum-width
484
- // floor, so a long unbroken value (a 16-digit order
485
- // number, say) refuses to shrink and pushes the grid
486
- // itself past the row's edge instead of wrapping.
487
- display: 'grid',
488
- gridTemplateColumns: 'auto minmax(0, auto)',
489
- columnGap: '10px',
490
- rowGap: '10px',
491
- justifyContent: 'start'
492
- }}
254
+ {showStatus && order.status?.label && (
255
+ <span
256
+ data-order-list-status=""
257
+ // The raw status code is published so scoped custom CSS can
258
+ // paint one status differently from the rest.
259
+ data-order-status-value={order.status.value}
260
+ {...partAttrs('status')}
261
+ style={{ opacity: 0.8 }}
493
262
  >
494
- {gridPair(
495
- numberLabel,
496
- detailLinkLabel ? (
497
- order.number || `#${order.id ?? ''}`
498
- ) : (
499
- <NextLink
500
- href={isDesigner ? '#' : href}
501
- onClick={guard}
502
- style={{ textDecoration: 'none' }}
503
- >
504
- {order.number || `#${order.id ?? ''}`}
505
- </NextLink>
506
- ),
507
- 'link',
508
- 'data-order-list-number'
509
- )}
510
- {showDate &&
511
- gridPair(
512
- dateLabel,
513
- formatDate(order.created_date, locale),
514
- 'date',
515
- 'data-order-list-date'
516
- )}
517
- {showAmount &&
518
- totalLabel &&
519
- gridPair(
520
- totalLabel,
521
- formatMoney(order.amount, {
522
- locale,
523
- currency: order.currency?.value || localeCurrency
524
- }),
525
- 'total',
526
- 'data-order-list-total-inline'
527
- )}
528
- </div>
529
-
530
- {detailLinkLabel && (
531
- <NextLink
532
- href={isDesigner ? '#' : href}
533
- onClick={guard}
534
- data-order-list-detail-link=""
535
- {...partAttrs('detail-link')}
536
- // No inline colour: the Order Detail Link style group
537
- // owns that (an inline default would block it, same as
538
- // the value spans above).
539
- style={{
540
- flex: '0 0 auto',
541
- whiteSpace: 'nowrap',
542
- textDecoration: 'none'
543
- }}
544
- >
545
- → {detailLinkLabel}
546
- </NextLink>
547
- )}
548
-
549
- <div
550
- style={{
551
- display: 'flex',
552
- flexDirection: 'column',
553
- alignItems: isMobile ? 'flex-start' : 'flex-end',
554
- gap: '6px',
555
- flex: '0 0 auto',
556
- marginLeft: !isMobile && !detailLinkLabel ? 'auto' : 0
557
- }}
558
- >
559
- {showAmount && !totalLabel && (
560
- <span data-order-list-amount="" {...partAttrs('total')}>
561
- {/* The order carries the currency it was placed in; the
562
- storefront currency is only the fallback. */}
563
- {formatMoney(order.amount, {
564
- locale,
565
- currency: order.currency?.value || localeCurrency
566
- })}
567
- </span>
568
- )}
569
- {showStatus && order.status?.label && (
570
- <span
571
- data-order-list-status=""
572
- // The raw status code is published so scoped custom CSS can
573
- // paint one status differently from the rest.
574
- data-order-status-value={order.status.value}
575
- {...partAttrs('status')}
576
- // No background/padding/radius here: the Status Pill
577
- // style group already exposes exactly those (and an
578
- // inline default would sit in front of a panel value,
579
- // not behind it — a designer could never turn it off).
580
- // A pill look is a template's `status/*` styles, not a
581
- // code default.
582
- style={{ display: 'inline-block', fontSize: '12px' }}
583
- >
584
- {order.status.label}
585
- </span>
586
- )}
587
- </div>
588
- </div>
589
- </li>
590
- );
591
- })}
592
- </ul>
593
- {showPagination && totalPages > 1 && (
594
- <nav
595
- data-order-list-pagination=""
596
- {...partAttrs('pagination')}
597
- style={{
598
- display: 'flex',
599
- alignItems: 'center',
600
- justifyContent: 'center',
601
- gap: '16px',
602
- marginTop: 'var(--order-list-pagination-margin, 16px)'
603
- }}
604
- >
605
- <button
606
- type="button"
607
- data-order-list-pagination-prev=""
608
- disabled={currentPage <= 1}
609
- onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
610
- style={{
611
- background: 'none',
612
- border: 'none',
613
- cursor: currentPage <= 1 ? 'default' : 'pointer',
614
- opacity: currentPage <= 1 ? 0.4 : 1,
615
- padding: 0,
616
- font: 'inherit',
617
- color: 'inherit'
618
- }}
619
- >
620
- ‹
621
- </button>
622
- <span data-order-list-pagination-status="">
623
- {currentPage} / {totalPages}
624
- </span>
625
- <button
626
- type="button"
627
- data-order-list-pagination-next=""
628
- disabled={currentPage >= totalPages}
629
- onClick={() =>
630
- setCurrentPage((page) => Math.min(totalPages, page + 1))
631
- }
632
- style={{
633
- background: 'none',
634
- border: 'none',
635
- cursor: currentPage >= totalPages ? 'default' : 'pointer',
636
- opacity: currentPage >= totalPages ? 0.4 : 1,
637
- padding: 0,
638
- font: 'inherit',
639
- color: 'inherit'
640
- }}
641
- >
642
- ›
643
- </button>
644
- </nav>
645
- )}
646
- </>
263
+ {order.status.label}
264
+ </span>
265
+ )}
266
+ </div>
267
+ </li>
268
+ );
269
+ })}
270
+ </ul>
647
271
  );
648
272
  };
649
273