@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|