@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.
@@ -30,10 +30,26 @@ interface OrderItemLike {
30
30
  name?: string;
31
31
  image?: string;
32
32
  absolute_url?: string;
33
+ attributes?: Record<string, unknown>;
33
34
  } | null;
34
35
  }
35
36
 
37
+ interface AddressLike {
38
+ first_name?: string;
39
+ last_name?: string;
40
+ line?: string;
41
+ township?: { name?: string } | null;
42
+ district?: { name?: string } | null;
43
+ city?: { name?: string } | null;
44
+ }
45
+
46
+ interface DiscountItemLike {
47
+ name?: string;
48
+ amount?: string;
49
+ }
50
+
36
51
  interface OrderLike {
52
+ id?: number;
37
53
  number?: string;
38
54
  created_date?: string;
39
55
  amount?: string;
@@ -45,6 +61,14 @@ interface OrderLike {
45
61
  status?: { value?: string; label?: string };
46
62
  currency?: { value?: string; label?: string };
47
63
  orderitem_set?: OrderItemLike[];
64
+ discountitem_set?: DiscountItemLike[];
65
+ is_cancellable?: boolean;
66
+ e_archive_url?: string | null;
67
+ shipping_address?: AddressLike | null;
68
+ billing_address?: AddressLike | null;
69
+ bank?: { name?: string } | null;
70
+ payment_option?: { name?: string } | null;
71
+ installment_count?: number;
48
72
  }
49
73
 
50
74
  /**
@@ -88,6 +112,135 @@ const readSize = (value: unknown, fallback: number): number => {
88
112
  return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
89
113
  };
90
114
 
115
+ /**
116
+ * pz-dynamic-widget's page picker points the designer at a fixed, almost
117
+ * certainly non-existent order id (`PREVIEW_ORDER_PK`) so the route mounts
118
+ * without depending on the preview session having a real order — deliberate,
119
+ * per that file's own comment. The trade-off lands here: without a sample
120
+ * object, every section below (header, lines, addresses, totals, payment)
121
+ * would sit behind one "order not found" box, unstylable. This mirrors
122
+ * `DESIGNER_SAMPLE_PRODUCT`'s role for item-template rows, just for the
123
+ * whole order. Never reached outside `isDesigner`, so real shoppers never
124
+ * see it. The color/size attribute keys are duplicated under a few common
125
+ * names so a brand's configured key is more likely to resolve to something
126
+ * out of the box.
127
+ */
128
+ const DESIGNER_SAMPLE_ORDER: OrderLike = {
129
+ // Never 0 — `order.id ? ... : ''` further down would read a real falsy
130
+ // zero as "no id" and hide the cancellation link.
131
+ id: 999999,
132
+ number: '20920397829',
133
+ created_date: '2026-01-15T10:00:00Z',
134
+ amount: '4099.20',
135
+ amount_without_discount: '4599.00',
136
+ discount_amount: '3497.00',
137
+ shipping_amount: '0.00',
138
+ status: { value: '10', label: 'Approved' },
139
+ currency: { value: 'try', label: 'TRY' },
140
+ is_cancellable: true,
141
+ e_archive_url: '#',
142
+ installment_count: 3,
143
+ orderitem_set: [
144
+ {
145
+ id: 1,
146
+ price: '2299.50',
147
+ quantity: 1,
148
+ total_amount: '2299.50',
149
+ status: { value: '10', label: 'Approved' },
150
+ price_currency: { value: 'try', label: 'TRY' },
151
+ product: {
152
+ pk: 1,
153
+ name: 'Sample Product',
154
+ image: 'https://placehold.co/220x300',
155
+ absolute_url: '#',
156
+ attributes: {
157
+ color: 'Navy',
158
+ size: 'M',
159
+ filterable_color: 'Navy',
160
+ integration_renk_detay: 'Navy',
161
+ filtrenebilir_beden: 'M'
162
+ }
163
+ }
164
+ },
165
+ {
166
+ id: 2,
167
+ price: '2299.50',
168
+ quantity: 1,
169
+ total_amount: '2299.50',
170
+ status: { value: '10', label: 'Approved' },
171
+ price_currency: { value: 'try', label: 'TRY' },
172
+ product: {
173
+ pk: 2,
174
+ name: 'Sample Product 2',
175
+ image: 'https://placehold.co/220x300',
176
+ absolute_url: '#',
177
+ attributes: {
178
+ color: 'Black',
179
+ size: 'L',
180
+ filterable_color: 'Black',
181
+ integration_renk_detay: 'Black',
182
+ filtrenebilir_beden: 'L'
183
+ }
184
+ }
185
+ }
186
+ ],
187
+ discountitem_set: [
188
+ { name: 'Basket discount', amount: '-2499.00' },
189
+ { name: 'Promo code', amount: '-998.00' }
190
+ ],
191
+ shipping_address: {
192
+ first_name: 'Jane',
193
+ last_name: 'Doe',
194
+ line: '123 Sample Street',
195
+ district: { name: 'Downtown' },
196
+ township: { name: 'Central' },
197
+ city: { name: 'Istanbul' }
198
+ },
199
+ billing_address: {
200
+ first_name: 'Jane',
201
+ last_name: 'Doe',
202
+ line: '123 Sample Street',
203
+ district: { name: 'Downtown' },
204
+ township: { name: 'Central' },
205
+ city: { name: 'Istanbul' }
206
+ },
207
+ bank: { name: 'Sample Bank' },
208
+ payment_option: { name: 'Credit Card' }
209
+ };
210
+
211
+ /**
212
+ * Attribute names are brand-specific (a raw commerce feed, not a fixed
213
+ * schema), so color/size read from a designer-configured key rather than a
214
+ * hardcoded one — the alternative is a block that only ever works for the
215
+ * one brand whose attribute names happened to match.
216
+ */
217
+ const resolveAttribute = (
218
+ attributes: Record<string, unknown> | undefined | null,
219
+ key: string
220
+ ): string => {
221
+ if (!key || !attributes) return '';
222
+ const value = attributes[key];
223
+
224
+ return typeof value === 'string' ? value : '';
225
+ };
226
+
227
+ const formatAddress = (address: AddressLike | null | undefined): string[] => {
228
+ if (!address) return [];
229
+ const name = [address.first_name, address.last_name]
230
+ .filter(Boolean)
231
+ .join(' ');
232
+ const locality = [
233
+ address.district?.name,
234
+ address.township?.name,
235
+ address.city?.name
236
+ ]
237
+ .filter(Boolean)
238
+ .join(' / ');
239
+ const lineWithLocality = [address.line, locality].filter(Boolean).join(' ');
240
+
241
+ return [name, lineWithLocality].filter(Boolean);
242
+ };
243
+
91
244
  /**
92
245
  * Functional account block: one order's lines, totals, status and shipment
93
246
  * info. Order CONTENT stays commerce-driven (account order service) and the
@@ -95,12 +248,22 @@ const readSize = (value: unknown, fallback: number): number => {
95
248
  * every caption and the styling.
96
249
  * A designer can mark one child as the row layout for the order lines; without
97
250
  * one the built-in line renders.
98
- * Properties: showShipmentStatus, showItems, showImage, imageWidth,
99
- * imageHeight, showQuantity, showTotals, quantityPrefix, subtotalLabel,
100
- * discountLabel, shippingLabel, totalLabel, trackingLabel.
101
- * Parts: detail, header, number, date, status, list, item, image, name,
102
- * quantity, item-status, price, shipment, delivery, tracking, totals,
103
- * total-row, total-label, total-value, grand-total.
251
+ * Properties: packageLabel, numberLabel, dateLabel, headerTotalLabel,
252
+ * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
253
+ * cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
254
+ * imageHeight, showQuantity, quantityLabel, quantityPrefix, showColor,
255
+ * colorLabel, colorAttributeKey, showSize, sizeLabel, sizeAttributeKey,
256
+ * showShippingAddress, shippingAddressLabel, showBillingAddress,
257
+ * billingAddressLabel, showTotals, showPieceCount, pieceCountLabel,
258
+ * showDiscountItems, showInstallmentInfo, installmentInfoLabel,
259
+ * subtotalLabel, discountLabel, shippingLabel, freeShippingLabel, totalLabel,
260
+ * trackingLabel,
261
+ * showPaymentInfo, paymentInfoLabel.
262
+ * Parts: detail, header, package-label, number, date, header-total, status,
263
+ * invoice-link, cancel-link, list, item, image, name, quantity, color, size,
264
+ * item-status, price, shipment, delivery, tracking, addresses, address,
265
+ * address-label, totals, total-row, total-label, total-value, grand-total,
266
+ * payment-info, payment-info-label.
104
267
  */
105
268
  const OrderDetailBlock = ({
106
269
  block,
@@ -148,25 +311,110 @@ const OrderDetailBlock = ({
148
311
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
149
312
  );
150
313
  const quantityPrefix = text('quantityPrefix', 'x');
314
+ const quantityLabel = text('quantityLabel', '');
151
315
  const subtotalLabel = text('subtotalLabel', 'Subtotal');
152
316
  const discountLabel = text('discountLabel', 'Discount');
153
317
  const shippingLabel = text('shippingLabel', 'Shipping');
318
+ const freeShippingLabel = text('freeShippingLabel', 'Free');
154
319
  const totalLabel = text('totalLabel', 'Total');
155
320
  const trackingLabel = text('trackingLabel', 'Track shipment');
156
321
 
322
+ // Header: empty keeps the original bare header (no package tag, no
323
+ // separate labelled total line, no invoice/cancel links) — every one of
324
+ // these is an opt-in addition, same as the order-list block's labels.
325
+ const packageLabel = text('packageLabel', '');
326
+ const numberLabel = text('numberLabel', '');
327
+ const dateLabel = text('dateLabel', '');
328
+ const headerTotalLabel = text('headerTotalLabel', '');
329
+ const showInvoiceLink = readToggle(
330
+ block.properties?.showInvoiceLink,
331
+ currentBreakpoint,
332
+ false
333
+ );
334
+ const invoiceLinkLabel = text('invoiceLinkLabel', 'Invoice');
335
+ const showCancelLink = readToggle(
336
+ block.properties?.showCancelLink,
337
+ currentBreakpoint,
338
+ false
339
+ );
340
+ const cancelLinkLabel = text('cancelLinkLabel', 'Easy cancellation');
341
+ const cancelHrefBase = text('cancelHrefBase', '/account/orders');
342
+
343
+ const showColor = readToggle(
344
+ block.properties?.showColor,
345
+ currentBreakpoint,
346
+ false
347
+ );
348
+ const colorLabel = text('colorLabel', 'Color:');
349
+ const colorAttributeKey = text('colorAttributeKey', '');
350
+ const showSize = readToggle(
351
+ block.properties?.showSize,
352
+ currentBreakpoint,
353
+ false
354
+ );
355
+ const sizeLabel = text('sizeLabel', 'Size:');
356
+ const sizeAttributeKey = text('sizeAttributeKey', '');
357
+
358
+ const showShippingAddress = readToggle(
359
+ block.properties?.showShippingAddress,
360
+ currentBreakpoint,
361
+ false
362
+ );
363
+ const shippingAddressLabel = text('shippingAddressLabel', 'Delivery address');
364
+ const showBillingAddress = readToggle(
365
+ block.properties?.showBillingAddress,
366
+ currentBreakpoint,
367
+ false
368
+ );
369
+ const billingAddressLabel = text('billingAddressLabel', 'Billing address');
370
+
371
+ const showPieceCount = readToggle(
372
+ block.properties?.showPieceCount,
373
+ currentBreakpoint,
374
+ false
375
+ );
376
+ const pieceCountLabel = text('pieceCountLabel', 'pieces');
377
+ const showDiscountItems = readToggle(
378
+ block.properties?.showDiscountItems,
379
+ currentBreakpoint,
380
+ false
381
+ );
382
+ const showInstallmentInfo = readToggle(
383
+ block.properties?.showInstallmentInfo,
384
+ currentBreakpoint,
385
+ false
386
+ );
387
+ const installmentInfoLabel = text('installmentInfoLabel', 'Installments');
388
+
389
+ const showPaymentInfo = readToggle(
390
+ block.properties?.showPaymentInfo,
391
+ currentBreakpoint,
392
+ false
393
+ );
394
+ const paymentInfoLabel = text('paymentInfoLabel', 'Payment information');
395
+
396
+ const isMobile = currentBreakpoint === 'mobile';
397
+
157
398
  const { locale, currency: localeCurrency } = useLocalization();
158
399
  const params = useParams();
159
400
  const searchParams = useSearchParams();
160
401
 
161
402
  const orderKey =
162
- firstString((key) => (params as Record<string, unknown>)?.[key], ORDER_PARAM_KEYS) ||
163
- firstString((key) => searchParams?.get(key), ORDER_PARAM_KEYS);
403
+ firstString(
404
+ (key) => (params as Record<string, unknown>)?.[key],
405
+ ORDER_PARAM_KEYS
406
+ ) || firstString((key) => searchParams?.get(key), ORDER_PARAM_KEYS);
164
407
 
165
408
  // Not gated on the session: brands expose the same endpoint for anonymous
166
409
  // order tracking. Without a key there is nothing to ask for at all.
167
410
  const { data, isLoading } = useGetOrderQuery(orderKey, { skip: !orderKey });
168
411
 
169
- const order = (data ?? null) as OrderLike | null;
412
+ const liveOrder = (data ?? null) as OrderLike | null;
413
+ // See DESIGNER_SAMPLE_ORDER's own comment: the preview route's order id is
414
+ // fake by design, so a designer gets a fully populated sample instead of
415
+ // the section collapsing to one "not found" box the moment it's empty.
416
+ const order: OrderLike | null =
417
+ liveOrder ?? (isDesigner && !isLoading ? DESIGNER_SAMPLE_ORDER : null);
170
418
  const items = order?.orderitem_set ?? [];
171
419
  // pz writes one order line per unit; the same shared grouping the thank-you
172
420
  // page uses keeps a three-unit order from listing as three "x1" rows here.
@@ -180,8 +428,6 @@ const OrderDetailBlock = ({
180
428
  label={
181
429
  isLoading
182
430
  ? 'Order detail (loading)'
183
- : orderKey
184
- ? 'Order detail (order not found)'
185
431
  : 'Order detail (no order in URL)'
186
432
  }
187
433
  attribute="data-order-detail"
@@ -251,6 +497,59 @@ const OrderDetailBlock = ({
251
497
  </div>
252
498
  ) : null;
253
499
 
500
+ // Matches the storefront's own account-orders page: the amount before
501
+ // shipping is what "N pieces" prices against, not the grand total.
502
+ const pieceSubtotal = money(
503
+ String(
504
+ Number(order.amount_without_discount || 0) -
505
+ Number(order.shipping_amount || 0)
506
+ )
507
+ );
508
+ const cancelHref = order.id
509
+ ? `${cancelHrefBase.replace(/\/$/, '')}/${order.id}/cancellation`
510
+ : '';
511
+ const installmentCount = Math.max(0, Number(order.installment_count) || 0);
512
+ const perInstallment =
513
+ installmentCount > 1
514
+ ? money(String(Number(order.amount || 0) / installmentCount))
515
+ : '';
516
+
517
+ const addressBlock = (
518
+ address: AddressLike | null | undefined,
519
+ label: string,
520
+ part: string
521
+ ) => {
522
+ const lines = formatAddress(address);
523
+ if (lines.length === 0) return null;
524
+
525
+ return (
526
+ <div
527
+ {...partAttrs('address')}
528
+ data-order-detail-address={part}
529
+ style={{ flex: isMobile ? '1 1 auto' : '0 1 50%', minWidth: 0 }}
530
+ >
531
+ <div
532
+ {...partAttrs('address-label')}
533
+ style={{ fontWeight: 600, marginBottom: '8px' }}
534
+ >
535
+ {label}
536
+ </div>
537
+ <div
538
+ style={{
539
+ display: 'flex',
540
+ flexDirection: 'column',
541
+ gap: '2px',
542
+ opacity: 0.8
543
+ }}
544
+ >
545
+ {lines.map((line, index) => (
546
+ <span key={index}>{line}</span>
547
+ ))}
548
+ </div>
549
+ </div>
550
+ );
551
+ };
552
+
254
553
  return (
255
554
  <div
256
555
  data-order-detail=""
@@ -264,136 +563,246 @@ const OrderDetailBlock = ({
264
563
  <header
265
564
  data-order-detail-header=""
266
565
  {...partAttrs('header')}
267
- style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
566
+ style={{
567
+ display: 'flex',
568
+ flexDirection: isMobile ? 'column' : 'row',
569
+ alignItems: isMobile ? 'stretch' : 'flex-start',
570
+ justifyContent: 'space-between',
571
+ gap: isMobile ? '12px' : '24px'
572
+ }}
268
573
  >
269
- <span
270
- data-order-detail-number=""
271
- {...partAttrs('number')}
272
- style={{ fontWeight: 600 }}
273
- >
274
- {order.number || orderKey}
275
- </span>
276
- <span
277
- data-order-detail-date=""
278
- {...partAttrs('date')}
279
- style={{ opacity: 0.8 }}
280
- >
281
- {formatDate(order.created_date, locale)}
282
- </span>
283
- {order.status?.label && (
574
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
575
+ {packageLabel && (
576
+ <span
577
+ data-order-detail-package=""
578
+ {...partAttrs('package-label')}
579
+ style={{ opacity: 0.7 }}
580
+ >
581
+ {packageLabel}
582
+ </span>
583
+ )}
284
584
  <span
285
- data-order-detail-status=""
286
- // The raw status code is published so scoped custom CSS can paint
287
- // one status differently from the rest.
288
- data-order-status-value={order.status.value}
289
- {...partAttrs('status')}
585
+ data-order-detail-number=""
586
+ {...partAttrs('number')}
587
+ style={{ fontWeight: 600 }}
290
588
  >
291
- {order.status.label}
589
+ {numberLabel ? `${numberLabel} ` : ''}
590
+ {order.number || orderKey}
292
591
  </span>
293
- )}
294
- </header>
592
+ </div>
295
593
 
296
- {showItems && (groupedItems.length > 0 || customLines) && (
297
- <ul
298
- data-order-detail-items=""
299
- {...partAttrs('list')}
594
+ <div
300
595
  style={{
301
596
  display: 'flex',
302
- flexDirection: 'column',
303
- gap: '16px',
304
- listStyle: 'none',
305
- margin: 0,
306
- padding: 0
597
+ flexDirection: isMobile ? 'column' : 'row',
598
+ alignItems: isMobile ? 'stretch' : 'flex-start',
599
+ gap: isMobile ? '8px' : '24px'
307
600
  }}
308
601
  >
309
- {customLines ??
310
- groupedItems.map((line) => {
311
- // Status, currency and thumbnails are identical across the lines of
312
- // a group, so the first line represents the whole row.
313
- const item = line.item;
314
- const name = line.name;
315
- const href = line.href;
316
- const lineCurrency = item.price_currency?.value || currency;
317
-
318
- return (
319
- <li
320
- key={line.key}
321
- data-order-detail-item=""
322
- {...partAttrs('item')}
602
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
603
+ <span data-order-detail-date="" {...partAttrs('date')}>
604
+ {dateLabel ? `${dateLabel} ` : ''}
605
+ {formatDate(order.created_date, locale)}
606
+ </span>
607
+ {headerTotalLabel && (
608
+ <span
609
+ data-order-detail-header-total=""
610
+ {...partAttrs('header-total')}
611
+ >
612
+ {headerTotalLabel} {money(order.amount)}
613
+ </span>
614
+ )}
615
+ {(showInvoiceLink || showCancelLink) && (
616
+ <div
323
617
  style={{
324
618
  display: 'flex',
325
- alignItems: 'flex-start',
326
- gap: '12px',
327
- paddingBottom: '16px',
328
- borderBottom: '1px solid var(--order-detail-border, #e5e7eb)'
619
+ gap: '16px',
620
+ marginTop: '4px'
329
621
  }}
330
622
  >
331
- {showImage && item.product?.image && (
332
- <NextLink
333
- href={isDesigner ? '#' : href || '#'}
623
+ {showInvoiceLink && order.e_archive_url && (
624
+ <a
625
+ href={isDesigner ? '#' : order.e_archive_url}
334
626
  onClick={guard}
335
- style={{ flex: '0 0 auto', display: 'block' }}
627
+ target={isDesigner ? undefined : '_blank'}
628
+ rel="noreferrer"
629
+ data-order-detail-invoice-link=""
630
+ {...partAttrs('invoice-link')}
631
+ style={{
632
+ color: 'inherit',
633
+ textDecoration: 'none',
634
+ whiteSpace: 'nowrap'
635
+ }}
336
636
  >
337
- {/* Plain img: order line thumbnails come from arbitrary
338
- commerce CDNs that the brand next.config may not allow. */}
339
- {/* eslint-disable-next-line @next/next/no-img-element */}
340
- <img
341
- src={item.product.image}
342
- alt={name}
343
- width={imageWidth}
344
- height={imageHeight}
345
- {...partAttrs('image')}
346
- style={{ objectFit: 'cover', display: 'block' }}
347
- />
348
- </NextLink>
637
+ → {invoiceLinkLabel}
638
+ </a>
349
639
  )}
350
-
351
- <div style={{ flex: '1 1 auto', minWidth: 0 }}>
640
+ {showCancelLink && order.is_cancellable && cancelHref && (
352
641
  <NextLink
353
- href={isDesigner ? '#' : href || '#'}
642
+ href={isDesigner ? '#' : cancelHref}
354
643
  onClick={guard}
355
- data-order-detail-item-name=""
356
- {...partAttrs('name')}
357
- style={{ color: 'inherit', textDecoration: 'none' }}
644
+ data-order-detail-cancel-link=""
645
+ {...partAttrs('cancel-link')}
646
+ style={{
647
+ color: 'inherit',
648
+ textDecoration: 'none',
649
+ whiteSpace: 'nowrap'
650
+ }}
358
651
  >
359
- {name}
652
+ → {cancelLinkLabel}
360
653
  </NextLink>
654
+ )}
655
+ </div>
656
+ )}
657
+ </div>
361
658
 
362
- {showQuantity && (
363
- <div
364
- data-order-detail-item-quantity=""
365
- {...partAttrs('quantity')}
366
- style={{ marginTop: '4px', opacity: 0.8 }}
367
- >
368
- {`${quantityPrefix}${line.quantity}`}
369
- </div>
370
- )}
659
+ {order.status?.label && (
660
+ <span
661
+ data-order-detail-status=""
662
+ // The raw status code is published so scoped custom CSS can paint
663
+ // one status differently from the rest.
664
+ data-order-status-value={order.status.value}
665
+ {...partAttrs('status')}
666
+ // No background/padding/radius here: the Status style group owns
667
+ // that, same reasoning as the order-list block's status pill.
668
+ style={{ display: 'inline-block', flex: '0 0 auto' }}
669
+ >
670
+ {order.status.label}
671
+ </span>
672
+ )}
673
+ </div>
674
+ </header>
371
675
 
372
- {showShipmentStatus && item.status?.label && (
373
- <div
374
- data-order-detail-item-status=""
375
- data-order-status-value={item.status.value}
376
- {...partAttrs('item-status')}
377
- style={{ marginTop: '4px', opacity: 0.8 }}
676
+ {showItems && (groupedItems.length > 0 || customLines) && (
677
+ <ul
678
+ data-order-detail-items=""
679
+ {...partAttrs('list')}
680
+ style={{
681
+ display: 'flex',
682
+ flexDirection: 'column',
683
+ gap: '16px',
684
+ listStyle: 'none',
685
+ margin: 0,
686
+ padding: 0
687
+ }}
688
+ >
689
+ {customLines ??
690
+ groupedItems.map((line) => {
691
+ // Status, currency and thumbnails are identical across the lines of
692
+ // a group, so the first line represents the whole row.
693
+ const item = line.item;
694
+ const name = line.name;
695
+ const href = line.href;
696
+ const lineCurrency = item.price_currency?.value || currency;
697
+ const colorValue = showColor
698
+ ? resolveAttribute(item.product?.attributes, colorAttributeKey)
699
+ : '';
700
+ const sizeValue = showSize
701
+ ? resolveAttribute(item.product?.attributes, sizeAttributeKey)
702
+ : '';
703
+
704
+ return (
705
+ <li
706
+ key={line.key}
707
+ data-order-detail-item=""
708
+ {...partAttrs('item')}
709
+ style={{
710
+ display: 'flex',
711
+ alignItems: 'flex-start',
712
+ gap: '12px',
713
+ paddingBottom: '16px',
714
+ borderBottom:
715
+ '1px solid var(--order-detail-border, #e5e7eb)'
716
+ }}
717
+ >
718
+ {showImage && item.product?.image && (
719
+ <NextLink
720
+ href={isDesigner ? '#' : href || '#'}
721
+ onClick={guard}
722
+ style={{ flex: '0 0 auto', display: 'block' }}
378
723
  >
379
- {item.status.label}
380
- </div>
724
+ {/* Plain img: order line thumbnails come from arbitrary
725
+ commerce CDNs that the brand next.config may not allow. */}
726
+ {/* eslint-disable-next-line @next/next/no-img-element */}
727
+ <img
728
+ src={item.product.image}
729
+ alt={name}
730
+ width={imageWidth}
731
+ height={imageHeight}
732
+ {...partAttrs('image')}
733
+ style={{ objectFit: 'cover', display: 'block' }}
734
+ />
735
+ </NextLink>
381
736
  )}
382
- </div>
383
737
 
384
- <span data-order-detail-item-price="" {...partAttrs('price')}>
385
- {/* The row now stands for every unit of the product, so the
738
+ <div style={{ flex: '1 1 auto', minWidth: 0 }}>
739
+ <NextLink
740
+ href={isDesigner ? '#' : href || '#'}
741
+ onClick={guard}
742
+ data-order-detail-item-name=""
743
+ {...partAttrs('name')}
744
+ style={{ textDecoration: 'none' }}
745
+ >
746
+ {name}
747
+ </NextLink>
748
+
749
+ {showColor && colorValue && (
750
+ <div
751
+ data-order-detail-item-color=""
752
+ {...partAttrs('color')}
753
+ style={{ marginTop: '4px' }}
754
+ >
755
+ {colorLabel} {colorValue}
756
+ </div>
757
+ )}
758
+
759
+ {showSize && sizeValue && (
760
+ <div
761
+ data-order-detail-item-size=""
762
+ {...partAttrs('size')}
763
+ style={{ marginTop: '4px' }}
764
+ >
765
+ {sizeLabel} {sizeValue}
766
+ </div>
767
+ )}
768
+
769
+ {showQuantity && (
770
+ <div
771
+ data-order-detail-item-quantity=""
772
+ {...partAttrs('quantity')}
773
+ style={{ marginTop: '4px', opacity: 0.8 }}
774
+ >
775
+ {quantityLabel
776
+ ? `${quantityLabel} ${line.quantity}`
777
+ : `${quantityPrefix}${line.quantity}`}
778
+ </div>
779
+ )}
780
+
781
+ {showShipmentStatus && item.status?.label && (
782
+ <div
783
+ data-order-detail-item-status=""
784
+ data-order-status-value={item.status.value}
785
+ {...partAttrs('item-status')}
786
+ style={{ marginTop: '4px', opacity: 0.8 }}
787
+ >
788
+ {item.status.label}
789
+ </div>
790
+ )}
791
+ </div>
792
+
793
+ <span data-order-detail-item-price="" {...partAttrs('price')}>
794
+ {/* The row now stands for every unit of the product, so the
386
795
  group's summed line total is what it must price — the
387
796
  single line's total_amount would understate an "x3" row.
388
797
  For an ungrouped row this is the value printed before. */}
389
- {formatMoney(line.total, {
390
- locale,
391
- currency: lineCurrency
392
- })}
393
- </span>
394
- </li>
395
- );
396
- })}
798
+ {formatMoney(line.total, {
799
+ locale,
800
+ currency: lineCurrency
801
+ })}
802
+ </span>
803
+ </li>
804
+ );
805
+ })}
397
806
  </ul>
398
807
  )}
399
808
 
@@ -438,33 +847,100 @@ const OrderDetailBlock = ({
438
847
  </section>
439
848
  )}
440
849
 
850
+ {(showShippingAddress || showBillingAddress) && (
851
+ <section
852
+ data-order-detail-addresses=""
853
+ {...partAttrs('addresses')}
854
+ style={{
855
+ display: 'flex',
856
+ flexDirection: isMobile ? 'column' : 'row',
857
+ gap: isMobile ? '16px' : '40px'
858
+ }}
859
+ >
860
+ {showShippingAddress &&
861
+ addressBlock(
862
+ order.shipping_address,
863
+ shippingAddressLabel,
864
+ 'shipping'
865
+ )}
866
+ {showBillingAddress &&
867
+ addressBlock(order.billing_address, billingAddressLabel, 'billing')}
868
+ </section>
869
+ )}
870
+
441
871
  {showTotals && (
442
872
  <section
443
873
  data-order-detail-totals=""
444
874
  {...partAttrs('totals')}
445
875
  style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
446
876
  >
447
- {totalRow(
448
- subtotalLabel,
449
- money(order.amount_without_discount),
450
- 'data-order-detail-subtotal'
451
- )}
452
- {totalRow(
453
- discountLabel,
454
- money(order.discount_amount),
455
- 'data-order-detail-discount'
456
- )}
877
+ {showPieceCount &&
878
+ totalRow(
879
+ `${groupedItems.reduce(
880
+ (sum, line) => sum + Number(line.quantity || 0),
881
+ 0
882
+ )} ${pieceCountLabel}`,
883
+ pieceSubtotal,
884
+ 'data-order-detail-piece-count'
885
+ )}
457
886
  {totalRow(
458
887
  shippingLabel,
459
- money(order.shipping_amount),
888
+ Number(order.shipping_amount || 0) > 0
889
+ ? money(order.shipping_amount)
890
+ : freeShippingLabel,
460
891
  'data-order-detail-shipping'
461
892
  )}
893
+ {showDiscountItems
894
+ ? (order.discountitem_set || []).map((discount, index) =>
895
+ totalRow(
896
+ discount.name || discountLabel,
897
+ money(discount.amount),
898
+ `data-order-detail-discount-${index}`
899
+ )
900
+ )
901
+ : totalRow(
902
+ discountLabel,
903
+ money(order.discount_amount),
904
+ 'data-order-detail-discount'
905
+ )}
462
906
  {totalRow(
463
907
  totalLabel,
464
908
  money(order.amount),
465
909
  'data-order-detail-total',
466
910
  'grand-total'
467
911
  )}
912
+ {showInstallmentInfo &&
913
+ installmentCount > 1 &&
914
+ totalRow(
915
+ installmentInfoLabel,
916
+ `${installmentCount} x ${perInstallment}`,
917
+ 'data-order-detail-installments'
918
+ )}
919
+ </section>
920
+ )}
921
+
922
+ {showPaymentInfo && (order.bank?.name || order.payment_option?.name) && (
923
+ <section
924
+ data-order-detail-payment=""
925
+ {...partAttrs('payment-info')}
926
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
927
+ >
928
+ <div
929
+ {...partAttrs('payment-info-label')}
930
+ style={{ fontWeight: 600, marginBottom: '4px' }}
931
+ >
932
+ {paymentInfoLabel}
933
+ </div>
934
+ {formatAddress(order.billing_address)
935
+ .slice(0, 1)
936
+ .map((line, index) => (
937
+ <span key={index}>{line}</span>
938
+ ))}
939
+ <span style={{ opacity: 0.8 }}>
940
+ {[order.bank?.name, order.payment_option?.name]
941
+ .filter(Boolean)
942
+ .join(' — ')}
943
+ </span>
468
944
  </section>
469
945
  )}
470
946
  </div>