@akinon/pz-theme 2.0.64-rc.0 → 2.0.65-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.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React from 'react';
3
+ import React, { useEffect, useState } 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,6 +11,10 @@ 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
+
14
18
  interface OrderLike {
15
19
  id?: number;
16
20
  number?: string;
@@ -18,9 +22,15 @@ interface OrderLike {
18
22
  created_date?: string;
19
23
  status?: { value?: string; label?: string };
20
24
  currency?: { value?: string; label?: string };
21
- orderitem_set?: unknown[];
25
+ orderitem_set?: OrderItemLike[];
22
26
  }
23
27
 
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
+
24
34
  /** Newest/oldest first without mutating the array RTK Query handed us. */
25
35
  const sortOrders = (orders: OrderLike[], sort: string): OrderLike[] => {
26
36
  if (sort !== 'newest' && sort !== 'oldest') return orders;
@@ -42,8 +52,11 @@ const sortOrders = (orders: OrderLike[], sort: string): OrderLike[] => {
42
52
  * rows are shown, which fields the row carries, where a row links to and the
43
53
  * styling.
44
54
  * Properties: limit, orderHrefBase, sort, showDate, showStatus, showAmount,
45
- * emptyLabel.
46
- * Parts: list, item, link, date, total, status, empty.
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.
47
60
  */
48
61
  const OrderListBlock = ({
49
62
  block,
@@ -83,23 +96,108 @@ const OrderListBlock = ({
83
96
  currentBreakpoint,
84
97
  true
85
98
  );
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
+ );
86
132
  const emptyLabel = String(
87
133
  getResponsiveValue(block.properties?.emptyLabel, currentBreakpoint, '')
88
134
  );
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';
89
171
 
90
172
  const { locale, currency: localeCurrency } = useLocalization();
91
173
  const { status } = useSession();
92
174
  const isAuthenticated = status === 'authenticated';
93
175
 
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
+
94
188
  // Guests have no orders endpoint; skipping avoids a guaranteed 403 on every
95
189
  // page that hosts this block.
96
190
  const { data, isLoading } = useGetOrdersQuery(
97
- { limit, page: 1 },
191
+ { limit: effectiveLimit, page: showPagination ? currentPage : 1 },
98
192
  { skip: !isAuthenticated }
99
193
  );
100
194
 
101
195
  const orders: OrderLike[] =
102
196
  (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;
103
201
 
104
202
  // Navigating inside the designer iframe would drop the author out of the
105
203
  // page they are editing, so the order links are inert there.
@@ -108,9 +206,11 @@ const OrderListBlock = ({
108
206
  };
109
207
 
110
208
  const hrefFor = (order: OrderLike) =>
111
- order.id ? `${orderHrefBase.replace(/\/$/, '')}/${order.id}` : orderHrefBase;
209
+ order.id
210
+ ? `${orderHrefBase.replace(/\/$/, '')}/${order.id}`
211
+ : orderHrefBase;
112
212
 
113
- const visibleOrders = sortOrders(orders, sort).slice(0, limit);
213
+ const visibleOrders = sortOrders(orders, sort).slice(0, effectiveLimit);
114
214
 
115
215
  const rows = visibleOrders.map((order, index) => ({
116
216
  id: order.id ?? '',
@@ -133,6 +233,15 @@ const OrderListBlock = ({
133
233
  itemCount: Array.isArray(order.orderitem_set)
134
234
  ? order.orderitem_set.length
135
235
  : 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
+ ),
136
245
  url: hrefFor(order),
137
246
  href: hrefFor(order)
138
247
  }));
@@ -151,123 +260,390 @@ const OrderListBlock = ({
151
260
  />
152
261
  ) : null;
153
262
 
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
+
154
279
  if (orders.length === 0 && !customRows) {
155
280
  if (isDesigner) {
156
281
  return (
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>
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
+ </>
166
295
  );
167
296
  }
168
297
 
169
298
  // The shopper-facing empty copy is only honest once the account query has
170
299
  // resolved; before that (and without a message) the block stays absent,
171
300
  // which is what every published composition renders today.
172
- if (!isAuthenticated || isLoading || !emptyLabel) return null;
301
+ if (!isAuthenticated || isLoading || !emptyLabel) return titleNode;
173
302
 
174
303
  return (
175
- <div
176
- data-order-list=""
177
- data-order-list-state="empty"
178
- {...partAttrs('empty', { empty: true })}
179
- >
180
- {emptyLabel}
181
- </div>
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
+ </>
182
315
  );
183
316
  }
184
317
 
185
318
  return (
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' }}
223
- >
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>
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>
234
362
  )}
235
- </div>
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
+ }}
378
+ >
379
+ {value}
380
+ </span>
381
+ </React.Fragment>
382
+ );
236
383
 
237
- <div
384
+ return (
385
+ <li
386
+ key={order.id ?? order.number ?? index}
387
+ data-order-list-item=""
388
+ {...partAttrs('item')}
238
389
  style={{
239
390
  display: 'flex',
240
- flexDirection: 'column',
241
- alignItems: 'flex-end'
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)'
242
396
  }}
243
397
  >
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>
253
- )}
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 }}
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
+ }}
262
420
  >
263
- {order.status.label}
264
- </span>
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>
265
457
  )}
266
- </div>
267
- </li>
268
- );
269
- })}
270
- </ul>
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
+ }}
493
+ >
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
+ </>
271
647
  );
272
648
  };
273
649