@cartbase/storefront 0.1.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.
Files changed (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,152 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
+ import { DualPrice } from "../lib/dual-price"
8
+ import { cn } from "../lib/utils"
9
+ import { useCheckoutLabels } from "./context"
10
+
11
+ /**
12
+ * LineItemCard — a single cart line shown inside the checkout order
13
+ * summary card. Includes thumbnail, title, variant, quantity select,
14
+ * and dual-currency price.
15
+ *
16
+ * Ported from `@1click/ui/src/checkout/line-item-card.tsx` (v2.3.1) with
17
+ * the barter data seam: the cookie-bound `updateLineItem` server action →
18
+ * `carts.updateLineItem(client, cartId, lineId, {quantity})`
19
+ * (POST /api/store/carts/:id/line-items/:lineId — docs/storefront/
20
+ * carts.md). The route returns the freshly decorated cart; pass
21
+ * `onCartChange` so the host can update its cart state (or
22
+ * `router.refresh()` in an RSC app). `variant.title` → the flat
23
+ * `variant_title` column of the barter line DTO.
24
+ *
25
+ * Checkout is master-level: customers cannot remove items here.
26
+ * Removal lives in the cart drawer only.
27
+ */
28
+
29
+ type LineItemCardProps = {
30
+ /** The SDK transport — quantity changes go through it. */
31
+ client: StorefrontClient
32
+ cartId: string
33
+ item: CartLineItem
34
+ currencyCode: string
35
+ /** Receives the decorated cart returned by the quantity update. */
36
+ onCartChange?: (cart: Cart) => void
37
+ }
38
+
39
+ export function LineItemCard({
40
+ client,
41
+ cartId,
42
+ item,
43
+ currencyCode,
44
+ onCartChange,
45
+ }: LineItemCardProps) {
46
+ const labels = useCheckoutLabels()
47
+ const [updating, setUpdating] = useState(false)
48
+
49
+ const handleQty = async (qty: number) => {
50
+ setUpdating(true)
51
+ try {
52
+ const { cart } = await updateLineItem(client, cartId, item.id, {
53
+ quantity: qty,
54
+ })
55
+ onCartChange?.(cart)
56
+ } catch {
57
+ // Quantity update failed (e.g. insufficient_inventory) — the select
58
+ // snaps back to the server value on the next cart render.
59
+ } finally {
60
+ setUpdating(false)
61
+ }
62
+ }
63
+
64
+ const total = item.total ?? 0
65
+
66
+ return (
67
+ <div
68
+ className={cn(
69
+ "relative flex gap-4 p-4 rounded-xl border border-border bg-muted/50 transition-opacity",
70
+ updating && "opacity-40 pointer-events-none"
71
+ )}
72
+ >
73
+ <div className="relative w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-card border border-border">
74
+ {item.thumbnail ? (
75
+ // eslint-disable-next-line @next/next/no-img-element
76
+ <img
77
+ src={item.thumbnail}
78
+ alt={item.product_title || ""}
79
+ className="w-full h-full object-cover"
80
+ />
81
+ ) : (
82
+ <div className="w-full h-full flex items-center justify-center">
83
+ <svg
84
+ className="w-6 h-6 text-muted-foreground"
85
+ fill="none"
86
+ viewBox="0 0 24 24"
87
+ stroke="currentColor"
88
+ strokeWidth={1}
89
+ >
90
+ <path
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
94
+ />
95
+ </svg>
96
+ </div>
97
+ )}
98
+ {item.quantity > 1 && (
99
+ <span className="absolute -bottom-1 -left-1 w-5 h-5 text-[10px] font-bold rounded-full bg-foreground text-card flex items-center justify-center shadow-sm">
100
+ {item.quantity}
101
+ </span>
102
+ )}
103
+ </div>
104
+
105
+ <div className="flex-1 min-w-0">
106
+ <p className="text-sm font-semibold text-foreground leading-tight truncate">
107
+ {item.product_title}
108
+ </p>
109
+ {item.variant_title && item.variant_title !== "Default" && (
110
+ <p className="text-xs text-muted-foreground mt-1">{item.variant_title}</p>
111
+ )}
112
+
113
+ <div className="flex items-center gap-2 mt-2.5">
114
+ <div className="relative">
115
+ <select
116
+ value={item.quantity}
117
+ onChange={(e) => handleQty(parseInt(e.target.value, 10))}
118
+ className="h-8 pl-2.5 pr-7 text-xs font-medium border border-border rounded-lg bg-card text-foreground appearance-none cursor-pointer hover:border-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-primary/20"
119
+ >
120
+ {Array.from({ length: 10 }, (_, i) => (
121
+ <option key={i + 1} value={i + 1}>
122
+ {labels.qty} {i + 1}
123
+ </option>
124
+ ))}
125
+ </select>
126
+ <svg
127
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none"
128
+ fill="none"
129
+ viewBox="0 0 24 24"
130
+ stroke="currentColor"
131
+ strokeWidth={2}
132
+ >
133
+ <path
134
+ strokeLinecap="round"
135
+ strokeLinejoin="round"
136
+ d="M19.5 8.25l-7.5 7.5-7.5-7.5"
137
+ />
138
+ </svg>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <div className="flex flex-col items-end justify-center flex-shrink-0">
144
+ <DualPrice
145
+ amount={total}
146
+ currencyCode={currencyCode}
147
+ className="text-sm font-bold text-foreground"
148
+ />
149
+ </div>
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,524 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useState } from "react"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
+ import { DualPrice } from "../lib/dual-price"
8
+ import { isProductLine } from "../lib/cart-helpers"
9
+ import { cn } from "../lib/utils"
10
+ import { useCheckoutLabels } from "./context"
11
+ import { DiscountSection } from "./discount-section"
12
+ import { GiftCardSection } from "./gift-card-section"
13
+
14
+ /**
15
+ * OrderSummary — the right column of the checkout: scrollable list of
16
+ * line items, discount code, gift cards, subtotal / shipping / COD fee /
17
+ * tax / total, optional optimistic overrides used during method selection.
18
+ *
19
+ * Ported from `@1click/ui/src/checkout/order-summary.tsx` (v2.3.1) with
20
+ * the barter data seam:
21
+ *
22
+ * - COD fee is CART-LEVEL decoration on the barter wire —
23
+ * `cart.cod_fee_total` / `cart.cod_fee_label`, folded into `cart.total`
24
+ * while a live pp_cod session exists (docs/storefront/checkout.md) —
25
+ * NOT a metadata-flagged fee line item. The fee row + optimistic math
26
+ * read those fields; `isProductLine` is kept as a defensive filter for
27
+ * any legacy fee-line carts.
28
+ * - Gift-card tender rows (barter feature): `gift_card_total` renders as
29
+ * a negative tender row and `gift_card_remainder` as "Remaining to pay"
30
+ * UNDER the (unchanged) total — tender never moves totals
31
+ * (docs/storefront/gift-cards.md). `GiftCardSection` mounts next to
32
+ * `DiscountSection`.
33
+ * - Line rows call `carts.updateLineItem` through the caller-supplied
34
+ * `client`; every mutation hands the decorated cart to `onCartChange`.
35
+ * - The flat `CheckoutLineItem` row (source: checkout-line-item.tsx,
36
+ * outside this batch's pre-declared file list) is ported INTO this file
37
+ * as the summary's row renderer and re-exported from the family barrel.
38
+ */
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // CheckoutLineItem — flat row used INSIDE the OrderSummary card
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * CheckoutLineItem — flat row used INSIDE the OrderSummary card. Unlike
46
+ * LineItemCard (which is itself a card), this component is borderless:
47
+ * rows sit on the parent card's surface with parent-provided dividers.
48
+ *
49
+ * Checkout is master-level: customers cannot remove items from the
50
+ * order summary. Removal happens in the cart drawer only — the qty
51
+ * pill here allows adjustment down to 1, not zero.
52
+ *
53
+ * Ported from `@1click/ui/src/checkout/checkout-line-item.tsx` (v2.3.1);
54
+ * lucide-react's Minus/Plus icons are inlined as SVGs so the checkout
55
+ * family carries no icon-kit dependency (mirrors lib/payment-constants'
56
+ * no-icons rule). `variant.title` → the flat `variant_title` column.
57
+ */
58
+ export function CheckoutLineItem({
59
+ client,
60
+ cartId,
61
+ item,
62
+ currencyCode,
63
+ onCartChange,
64
+ }: {
65
+ client: StorefrontClient
66
+ cartId: string
67
+ item: CartLineItem
68
+ currencyCode: string
69
+ onCartChange?: (cart: Cart) => void
70
+ }) {
71
+ const labels = useCheckoutLabels()
72
+ const [updating, setUpdating] = useState(false)
73
+
74
+ const handleQty = async (qty: number) => {
75
+ if (qty < 1) return
76
+ setUpdating(true)
77
+ try {
78
+ const { cart } = await updateLineItem(client, cartId, item.id, {
79
+ quantity: qty,
80
+ })
81
+ onCartChange?.(cart)
82
+ } catch {
83
+ // e.g. insufficient_inventory — the row snaps back to server truth
84
+ // on the next cart render.
85
+ } finally {
86
+ setUpdating(false)
87
+ }
88
+ }
89
+
90
+ const qtyPill = (
91
+ <div className="inline-flex items-center rounded-[2px] border border-border bg-card">
92
+ <button
93
+ type="button"
94
+ onClick={() => handleQty(item.quantity - 1)}
95
+ disabled={item.quantity <= 1 || updating}
96
+ aria-label={labels.qty}
97
+ className="w-8 h-8 flex items-center justify-center text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
98
+ >
99
+ <svg
100
+ className="w-3.5 h-3.5"
101
+ fill="none"
102
+ viewBox="0 0 24 24"
103
+ stroke="currentColor"
104
+ strokeWidth={2.25}
105
+ >
106
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
107
+ </svg>
108
+ </button>
109
+ <span className="min-w-[1.75rem] px-1 text-center text-xs font-semibold tabular-nums">
110
+ {item.quantity}
111
+ </span>
112
+ <button
113
+ type="button"
114
+ onClick={() => handleQty(item.quantity + 1)}
115
+ disabled={updating}
116
+ aria-label={labels.qty}
117
+ className="w-8 h-8 flex items-center justify-center text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
118
+ >
119
+ <svg
120
+ className="w-3.5 h-3.5"
121
+ fill="none"
122
+ viewBox="0 0 24 24"
123
+ stroke="currentColor"
124
+ strokeWidth={2.25}
125
+ >
126
+ <path
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ d="M12 5v14m-7-7h14"
130
+ />
131
+ </svg>
132
+ </button>
133
+ </div>
134
+ )
135
+
136
+ const titleBlock = (
137
+ <div className="min-w-0">
138
+ <p className="text-[15px] font-semibold text-foreground leading-snug break-words">
139
+ {item.product_title}
140
+ </p>
141
+ {item.variant_title && item.variant_title !== "Default" && (
142
+ <p className="text-xs text-muted-foreground mt-1 truncate">
143
+ {item.variant_title}
144
+ </p>
145
+ )}
146
+ </div>
147
+ )
148
+
149
+ return (
150
+ <div
151
+ className={cn(
152
+ "relative flex gap-4 transition-opacity",
153
+ updating && "opacity-40 pointer-events-none"
154
+ )}
155
+ >
156
+ <div className="relative w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-muted border border-border">
157
+ {item.thumbnail ? (
158
+ // eslint-disable-next-line @next/next/no-img-element
159
+ <img
160
+ src={item.thumbnail}
161
+ alt={item.product_title || ""}
162
+ className="w-full h-full object-cover"
163
+ />
164
+ ) : null}
165
+ </div>
166
+
167
+ {/* DESKTOP layout —
168
+ row 1: title (left) · price right-aligned, baseline of title line 1
169
+ row 2: qty pill (left) */}
170
+ <div className="hidden sm:flex flex-1 min-w-0 flex-col gap-2.5">
171
+ <div className="flex items-baseline justify-between gap-3">
172
+ {titleBlock}
173
+ <DualPrice
174
+ amount={item.total ?? 0}
175
+ currencyCode={currencyCode}
176
+ className="text-sm font-bold text-foreground text-right flex-shrink-0"
177
+ />
178
+ </div>
179
+ {qtyPill}
180
+ </div>
181
+
182
+ {/* MOBILE layout — title takes full content width, qty+price share
183
+ the row underneath. */}
184
+ <div className="sm:hidden flex-1 min-w-0 flex flex-col gap-2">
185
+ {titleBlock}
186
+ <div className="flex items-center justify-between gap-3">
187
+ {qtyPill}
188
+ <DualPrice
189
+ amount={item.total ?? 0}
190
+ currencyCode={currencyCode}
191
+ className="text-sm font-bold text-foreground text-right"
192
+ />
193
+ </div>
194
+ </div>
195
+ </div>
196
+ )
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // OrderSummary
201
+ // ---------------------------------------------------------------------------
202
+
203
+ type OrderSummaryProps = {
204
+ /** The SDK transport — line-item / promo / gift-card mutations use it. */
205
+ client: StorefrontClient
206
+ cart: Cart
207
+ /**
208
+ * Optimistic shipping total during method selection. Pass `null` when
209
+ * there is no optimistic override so the server value is used.
210
+ */
211
+ optimisticShippingCost: number | null
212
+ onOptimisticShippingClear?: () => void
213
+ /**
214
+ * Optimistic COD-fee prediction. Three values:
215
+ * - null → no prediction; render whatever the cart says.
216
+ * - 0 → predict no fee (toggling away from COD).
217
+ * - positive → predict the fee at this amount.
218
+ * Pass `useCheckoutOrchestration`'s `optimisticCodFee`. Needed because
219
+ * the deferred-checkout architecture defers the pp_cod session (and so
220
+ * the native fee) until Buy click.
221
+ */
222
+ optimisticCodFee?: number | null
223
+ onOptimisticCodFeeClear?: () => void
224
+ /**
225
+ * Override label for the cash-on-delivery fee row. Optional — falls
226
+ * back to the cart's server-provided `cod_fee_label` (admin-configured
227
+ * on the COD integration), then to `labels.codFee`.
228
+ */
229
+ codFeeLabel?: string
230
+ /** Show the gift-card widget. Default true; pass false for stores that
231
+ * haven't enabled gift cards (the widget itself is harmless either way —
232
+ * an unknown code gets the generic refusal). */
233
+ showGiftCards?: boolean
234
+ /**
235
+ * Receives every decorated cart returned by a summary mutation
236
+ * (quantity change, promo apply, gift-card apply/remove). Hosts update
237
+ * their cart state here (or `router.refresh()`), and SHOULD re-run
238
+ * `syncPaymentAmount` when a pending payment session may exist — a
239
+ * gift-card change moves the remainder the provider must charge.
240
+ */
241
+ onCartChange?: (cart: Cart) => void
242
+ }
243
+
244
+ export function OrderSummary({
245
+ client,
246
+ cart,
247
+ optimisticShippingCost,
248
+ onOptimisticShippingClear,
249
+ optimisticCodFee,
250
+ onOptimisticCodFeeClear,
251
+ codFeeLabel,
252
+ showGiftCards = true,
253
+ onCartChange,
254
+ }: OrderSummaryProps) {
255
+ const labels = useCheckoutLabels()
256
+
257
+ // Product rows only. Barter's COD fee is cart-level decoration (never a
258
+ // line item), so this filter is defensive — it also hides any
259
+ // metadata-flagged fee line a legacy/ported cart might still carry.
260
+ const allItems = cart.items ?? []
261
+ const productItems = useMemo(
262
+ () => allItems.filter(isProductLine),
263
+ [allItems]
264
+ )
265
+ const itemCount = productItems.reduce((s, i) => s + i.quantity, 0)
266
+
267
+ const shippingCost =
268
+ optimisticShippingCost !== null ? optimisticShippingCost : cart.shipping_total
269
+ const shippingKnown = shippingCost !== null && shippingCost !== undefined
270
+
271
+ // Effective COD fee: optimistic prediction wins until the cart catches
272
+ // up. Three states (null / 0 / positive). Barter server truth is
273
+ // `cart.cod_fee_total` (folded into cart.total while a pp_cod session
274
+ // lives — docs/storefront/checkout.md).
275
+ const realCodFeeAmount = cart.cod_fee_total ?? 0
276
+ const effectiveCodFee =
277
+ optimisticCodFee !== null && optimisticCodFee !== undefined
278
+ ? optimisticCodFee
279
+ : realCodFeeAmount
280
+
281
+ const displayTotal = useMemo(() => {
282
+ let total = cart.total ?? 0
283
+ if (optimisticShippingCost !== null) {
284
+ total = total - (cart.shipping_total ?? 0) + optimisticShippingCost
285
+ }
286
+ if (optimisticCodFee !== null && optimisticCodFee !== undefined) {
287
+ total = total - realCodFeeAmount + effectiveCodFee
288
+ }
289
+ return total
290
+ }, [
291
+ cart.total,
292
+ cart.shipping_total,
293
+ optimisticShippingCost,
294
+ optimisticCodFee,
295
+ effectiveCodFee,
296
+ realCodFeeAmount,
297
+ ])
298
+
299
+ // Subtotal: barter's `item_total` is products-only (the COD fee is not a
300
+ // line item), so no fee subtraction is needed — direct server truth.
301
+ const productSubtotal = cart.item_total ?? 0
302
+
303
+ const codFeeAmount = effectiveCodFee
304
+
305
+ // Gift-card tender decoration — SERVER truth, rendered as-is. Totals
306
+ // never move; the tender rows sit under the total.
307
+ const giftCardTotal = cart.gift_card_total ?? 0
308
+ const giftCardRemainder = cart.gift_card_remainder ?? 0
309
+
310
+ // Clear optimistic override once server cart reflects the new shipping cost
311
+ useEffect(() => {
312
+ if (
313
+ optimisticShippingCost !== null &&
314
+ cart.shipping_total === optimisticShippingCost
315
+ ) {
316
+ onOptimisticShippingClear?.()
317
+ }
318
+ }, [cart.shipping_total, optimisticShippingCost, onOptimisticShippingClear])
319
+
320
+ // Clear optimistic COD fee once the cart's decoration matches the
321
+ // prediction (barter: cod_fee_total, not a fee line item).
322
+ useEffect(() => {
323
+ if (optimisticCodFee === null || optimisticCodFee === undefined) return
324
+ const synced = realCodFeeAmount === optimisticCodFee
325
+ if (synced) onOptimisticCodFeeClear?.()
326
+ }, [optimisticCodFee, realCodFeeAmount, onOptimisticCodFeeClear])
327
+
328
+ return (
329
+ <div>
330
+ <div className="flex items-baseline justify-between mb-4">
331
+ <h2 className="text-lg font-semibold text-foreground tracking-tight">
332
+ {labels.orderSummary}
333
+ </h2>
334
+ <span className="text-sm text-muted-foreground">
335
+ {itemCount} {labels.items}
336
+ </span>
337
+ </div>
338
+
339
+ <div className="bg-card border border-border rounded-[2px] overflow-hidden shadow-sm">
340
+ {/* Items section — bg-muted accent so the items zone reads as a
341
+ distinct surface from the totals section below. */}
342
+ <div className="bg-muted px-5 sm:px-6 py-2 divide-y divide-border border-b border-border">
343
+ {productItems
344
+ .slice()
345
+ .sort((a, b) => ((a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1))
346
+ .map((item) => (
347
+ <div key={item.id} className="py-5 first:pt-3 last:pb-3">
348
+ <CheckoutLineItem
349
+ client={client}
350
+ cartId={cart.id}
351
+ item={item}
352
+ currencyCode={cart.currency_code}
353
+ onCartChange={onCartChange}
354
+ />
355
+ </div>
356
+ ))}
357
+ </div>
358
+
359
+ <div className="px-5 sm:px-6 mt-4 space-y-3">
360
+ <DiscountSection
361
+ client={client}
362
+ cart={cart}
363
+ onCartChange={onCartChange}
364
+ />
365
+ {showGiftCards && (
366
+ <GiftCardSection
367
+ client={client}
368
+ cart={cart}
369
+ onCartChange={onCartChange}
370
+ />
371
+ )}
372
+ </div>
373
+
374
+ <div className="mx-5 sm:mx-6 my-5 h-px bg-border" />
375
+
376
+ <div className="px-5 sm:px-6 space-y-3">
377
+ <div className="flex justify-between text-sm">
378
+ <span className="text-muted-foreground">{labels.subtotal}</span>
379
+ <DualPrice
380
+ amount={productSubtotal}
381
+ currencyCode={cart.currency_code}
382
+ className="font-medium text-foreground"
383
+ />
384
+ </div>
385
+
386
+ <div className="flex justify-between text-sm">
387
+ <span className="text-muted-foreground">{labels.shipping}</span>
388
+ {shippingKnown ? (
389
+ shippingCost === 0 ? (
390
+ <span className="font-medium text-success">
391
+ {labels.shippingFree}
392
+ </span>
393
+ ) : (
394
+ <DualPrice
395
+ amount={shippingCost ?? 0}
396
+ currencyCode={cart.currency_code}
397
+ className="font-medium text-foreground"
398
+ />
399
+ )
400
+ ) : (
401
+ <span className="font-medium text-foreground">
402
+ {labels.shippingCalc}
403
+ </span>
404
+ )}
405
+ </div>
406
+
407
+ {codFeeAmount > 0 && (
408
+ <div className="flex justify-between text-sm">
409
+ <span className="text-muted-foreground">
410
+ {codFeeLabel ?? cart.cod_fee_label ?? labels.codFee}
411
+ </span>
412
+ <DualPrice
413
+ amount={codFeeAmount}
414
+ currencyCode={cart.currency_code}
415
+ className="font-medium text-foreground"
416
+ />
417
+ </div>
418
+ )}
419
+
420
+ {!!cart.discount_total && (
421
+ <div className="flex justify-between text-sm text-success">
422
+ <span>{labels.discount}</span>
423
+ <span className="font-medium">
424
+ -
425
+ <DualPrice
426
+ amount={cart.discount_total}
427
+ currencyCode={cart.currency_code}
428
+ />
429
+ </span>
430
+ </div>
431
+ )}
432
+
433
+ <div className="flex justify-between text-sm">
434
+ <span className="text-muted-foreground flex items-center gap-1 relative group">
435
+ {labels.tax}
436
+ <svg
437
+ className="w-3.5 h-3.5 text-muted-foreground cursor-help"
438
+ fill="none"
439
+ viewBox="0 0 24 24"
440
+ stroke="currentColor"
441
+ strokeWidth={2}
442
+ >
443
+ <path
444
+ strokeLinecap="round"
445
+ strokeLinejoin="round"
446
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
447
+ />
448
+ </svg>
449
+ <span className="absolute left-0 bottom-full mb-1.5 px-2.5 py-1.5 text-xs text-card bg-foreground rounded-md whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
450
+ {labels.taxTooltip}
451
+ </span>
452
+ </span>
453
+ <DualPrice
454
+ amount={cart.tax_total ?? 0}
455
+ currencyCode={cart.currency_code}
456
+ className="font-medium text-foreground"
457
+ />
458
+ </div>
459
+ </div>
460
+
461
+ <div className="mx-5 sm:mx-6 mt-4 pt-4 border-t border-border">
462
+ <div className="flex justify-between items-center">
463
+ <span className="text-lg font-bold text-foreground">
464
+ {labels.total}
465
+ </span>
466
+ <div className="text-right">
467
+ <DualPrice
468
+ amount={displayTotal ?? 0}
469
+ currencyCode={cart.currency_code}
470
+ className="text-2xl font-bold text-foreground tracking-tight"
471
+ bgnClassName="ml-2 text-sm text-muted-foreground/70 font-normal"
472
+ />
473
+ </div>
474
+ </div>
475
+
476
+ {/* Gift-card TENDER rows — under the (unchanged) total, because
477
+ tender covers the total rather than reducing it. Rendered only
478
+ while cards actually cover something. */}
479
+ {giftCardTotal > 0 && (
480
+ <div className="mt-3 space-y-2">
481
+ <div className="flex justify-between text-sm text-success">
482
+ <span>{labels.giftCardApplied}</span>
483
+ <span className="font-medium">
484
+ -
485
+ <DualPrice
486
+ amount={giftCardTotal}
487
+ currencyCode={cart.currency_code}
488
+ />
489
+ </span>
490
+ </div>
491
+ <div className="flex justify-between text-sm">
492
+ <span className="text-muted-foreground">
493
+ {labels.giftCardRemaining}
494
+ </span>
495
+ <DualPrice
496
+ amount={giftCardRemainder}
497
+ currencyCode={cart.currency_code}
498
+ className="font-semibold text-foreground"
499
+ />
500
+ </div>
501
+ </div>
502
+ )}
503
+ </div>
504
+
505
+ <div className="px-5 sm:px-6 pb-5 pt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
506
+ <svg
507
+ className="w-3.5 h-3.5"
508
+ fill="none"
509
+ viewBox="0 0 24 24"
510
+ stroke="currentColor"
511
+ strokeWidth={2}
512
+ >
513
+ <path
514
+ strokeLinecap="round"
515
+ strokeLinejoin="round"
516
+ d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
517
+ />
518
+ </svg>
519
+ <span>{labels.secureCheckout}</span>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ )
524
+ }