@thorprovider/blocks 0.1.2 → 1.0.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/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
|
@@ -1,65 +1,48 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
4
|
-
import { ShoppingCart, Zap, Clock, CreditCard, Wallet, Minus, Plus, X } from 'lucide-react'
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { ShoppingCart as ShoppingCartIcon, Zap, Clock, CreditCard, Wallet, Minus, Plus, X } from 'lucide-react'
|
|
5
5
|
import type { LucideIcon } from 'lucide-react'
|
|
6
6
|
import type { Money } from '@thorprovider/types'
|
|
7
7
|
import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
|
|
8
8
|
import { Button } from '../../../ui/button'
|
|
9
|
+
import { Input } from '../../../ui/input'
|
|
9
10
|
import { Separator } from '../../../ui/separator'
|
|
10
11
|
import { Progress } from '../../../ui/progress'
|
|
11
12
|
import { cn } from '../../../lib/utils'
|
|
12
13
|
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
14
|
+
import type { CartTotals, ShoppingCartItem, ShoppingCartSuggestion } from '../../../lib/contracts'
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
id: string
|
|
17
|
-
name: string
|
|
18
|
-
price: number
|
|
19
|
-
quantity: number
|
|
20
|
-
/** Optional; an absent image is omitted rather than serialized as `""`. */
|
|
21
|
-
image?: string
|
|
22
|
-
variant?: string
|
|
23
|
-
/** Canonical backend per-unit price; preferred for display when present. */
|
|
24
|
-
unitPrice?: Money
|
|
25
|
-
/** Canonical backend line total; preferred over `price * quantity`. */
|
|
26
|
-
lineTotal?: Money
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ShoppingCart4Totals {
|
|
30
|
-
subtotal: Money
|
|
31
|
-
discount?: Money
|
|
32
|
-
tax?: Money
|
|
33
|
-
shipping?: Money
|
|
34
|
-
total: Money
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type ShoppingCart4TotalsState = 'idle' | 'pending' | 'error'
|
|
38
|
-
|
|
39
|
-
export interface ShoppingCart4Promotion {
|
|
16
|
+
/** Host-supplied promotion definition. Icons are React-side only. */
|
|
17
|
+
export interface ShoppingCartPromotion {
|
|
40
18
|
threshold: number
|
|
41
19
|
reward: string
|
|
42
20
|
icon: LucideIcon
|
|
43
21
|
}
|
|
44
22
|
|
|
45
|
-
export
|
|
46
|
-
id: string
|
|
47
|
-
name: string
|
|
48
|
-
price: number
|
|
49
|
-
image?: string
|
|
50
|
-
}
|
|
23
|
+
export type ShoppingCartTotalsState = 'idle' | 'pending' | 'error'
|
|
51
24
|
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
25
|
+
export type ShoppingCartPresentation = 'compact' | 'page'
|
|
26
|
+
|
|
27
|
+
export interface ShoppingCartProps {
|
|
28
|
+
/** Canonical cart lines keyed by backend cart line-item id. */
|
|
29
|
+
items: ShoppingCartItem[]
|
|
30
|
+
promotions?: ShoppingCartPromotion[]
|
|
31
|
+
recentlyViewed?: ShoppingCartSuggestion[]
|
|
56
32
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
33
|
+
* Layout presentation. `compact` (default) is the collapsible quick-cart
|
|
34
|
+
* card; `page` is a wide, always-expanded review layout with a primary
|
|
35
|
+
* heading, no collapse control, and normal page scrolling. Both use the same
|
|
36
|
+
* line controls and authoritative monetary rendering.
|
|
59
37
|
*/
|
|
60
|
-
|
|
38
|
+
presentation?: ShoppingCartPresentation
|
|
39
|
+
/**
|
|
40
|
+
* Backend-authoritative totals. The cart renders these verbatim; it never
|
|
41
|
+
* computes subtotal/tax/shipping/total locally.
|
|
42
|
+
*/
|
|
43
|
+
totals?: CartTotals
|
|
61
44
|
/** Controlled state of the totals fetch/mutation. */
|
|
62
|
-
totalsState?:
|
|
45
|
+
totalsState?: ShoppingCartTotalsState
|
|
63
46
|
totalsErrorLabel?: string
|
|
64
47
|
/** Shown when a required total amount is absent or malformed (never as zero). */
|
|
65
48
|
totalsUnavailableLabel?: string
|
|
@@ -69,10 +52,6 @@ export interface ShoppingCart4Props {
|
|
|
69
52
|
onRetryTotals?: () => void
|
|
70
53
|
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
71
54
|
formatMoney?: (money: Money) => string
|
|
72
|
-
/** @deprecated legacy numeric formatter, used only when `totals` is absent. */
|
|
73
|
-
formatPrice?: (value: number) => string
|
|
74
|
-
/** @deprecated legacy local tax rate, used only when `totals` is absent. */
|
|
75
|
-
taxRate?: number
|
|
76
55
|
// labels (Spanish defaults):
|
|
77
56
|
titleLabel?: string // 'Carrito de compras'
|
|
78
57
|
subtotalLabel?: string // 'Subtotal'
|
|
@@ -91,6 +70,9 @@ export interface ShoppingCart4Props {
|
|
|
91
70
|
estimatedDeliveryLabel?: string
|
|
92
71
|
secureCheckoutLabel?: string // 'Pago seguro • SSL cifrado'
|
|
93
72
|
recentlyViewedLabel?: string // 'Vistos recientemente'
|
|
73
|
+
promoPromptLabel?: string // '¿Tienes un código de descuento?'
|
|
74
|
+
promoPlaceholderLabel?: string // 'Introduce tu código'
|
|
75
|
+
promoApplyLabel?: string // 'Aplicar'
|
|
94
76
|
collapseLabel?: string // 'Contraer carrito'
|
|
95
77
|
expandLabel?: string // 'Expandir carrito'
|
|
96
78
|
checkoutLabel?: string // 'Ver carrito y finalizar'
|
|
@@ -100,6 +82,12 @@ export interface ShoppingCart4Props {
|
|
|
100
82
|
removeLabel?: string
|
|
101
83
|
decreaseLabel?: string
|
|
102
84
|
increaseLabel?: string
|
|
85
|
+
continueShoppingLabel?: string // 'Seguir comprando'
|
|
86
|
+
// action availability:
|
|
87
|
+
/** Suppress the checkout action (e.g. the checkout feature is not installed). */
|
|
88
|
+
checkoutUnavailable?: boolean
|
|
89
|
+
/** Disable checkout while the cart is not ready (pending/error/invalid totals). */
|
|
90
|
+
checkoutDisabled?: boolean
|
|
103
91
|
// callbacks:
|
|
104
92
|
/**
|
|
105
93
|
* ABSOLUTE quantity for a backend cart line-item id (distinct from the
|
|
@@ -112,10 +100,19 @@ export interface ShoppingCart4Props {
|
|
|
112
100
|
onQuickCheckout?: () => void
|
|
113
101
|
onPayWithApple?: () => void
|
|
114
102
|
onPayWithGoogle?: () => void
|
|
103
|
+
/**
|
|
104
|
+
* Report an entered promotion code to the host. The block never validates
|
|
105
|
+
* or resolves codes; the authoritative result arrives through `totals`
|
|
106
|
+
* (discount) and `promotions`. When absent, the promo affordance is not
|
|
107
|
+
* rendered.
|
|
108
|
+
*/
|
|
109
|
+
onApplyPromo?: (code: string) => void
|
|
115
110
|
onRecentlyViewedClick?: (id: string) => void
|
|
111
|
+
/** Page presentation only: navigate back to browsing. */
|
|
112
|
+
onContinueShopping?: () => void
|
|
116
113
|
}
|
|
117
114
|
|
|
118
|
-
const
|
|
115
|
+
const fallbackFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
|
119
116
|
|
|
120
117
|
const interpolate = (template: string, values: Record<string, string>) =>
|
|
121
118
|
template.replace(/\$\{(\w+)\}|\{(\w+)\}/g, (match, dollarKey, braceKey) => {
|
|
@@ -123,10 +120,11 @@ const interpolate = (template: string, values: Record<string, string>) =>
|
|
|
123
120
|
return values[key] ?? match
|
|
124
121
|
})
|
|
125
122
|
|
|
126
|
-
export function
|
|
123
|
+
export function ShoppingCart({
|
|
127
124
|
items,
|
|
128
125
|
promotions,
|
|
129
126
|
recentlyViewed,
|
|
127
|
+
presentation = 'compact',
|
|
130
128
|
totals,
|
|
131
129
|
totalsState = 'idle',
|
|
132
130
|
totalsErrorLabel = 'No se pudieron actualizar los totales.',
|
|
@@ -135,7 +133,6 @@ export function ShoppingCart4({
|
|
|
135
133
|
retryLabel = 'Reintentar',
|
|
136
134
|
onRetryTotals,
|
|
137
135
|
formatMoney: formatMoneyProp,
|
|
138
|
-
formatPrice,
|
|
139
136
|
titleLabel = 'Carrito de compras',
|
|
140
137
|
subtotalLabel = 'Subtotal',
|
|
141
138
|
discountLabel = 'Descuento',
|
|
@@ -153,6 +150,9 @@ export function ShoppingCart4({
|
|
|
153
150
|
estimatedDeliveryLabel,
|
|
154
151
|
secureCheckoutLabel = 'Pago seguro • SSL cifrado',
|
|
155
152
|
recentlyViewedLabel = 'Vistos recientemente',
|
|
153
|
+
promoPromptLabel = '¿Tienes un código de descuento?',
|
|
154
|
+
promoPlaceholderLabel = 'Introduce tu código',
|
|
155
|
+
promoApplyLabel = 'Aplicar',
|
|
156
156
|
collapseLabel = 'Contraer carrito',
|
|
157
157
|
expandLabel = 'Expandir carrito',
|
|
158
158
|
checkoutLabel = 'Ver carrito y finalizar',
|
|
@@ -162,24 +162,47 @@ export function ShoppingCart4({
|
|
|
162
162
|
removeLabel = 'Quitar',
|
|
163
163
|
decreaseLabel = 'Disminuir cantidad',
|
|
164
164
|
increaseLabel = 'Aumentar cantidad',
|
|
165
|
+
continueShoppingLabel = 'Seguir comprando',
|
|
166
|
+
checkoutUnavailable,
|
|
167
|
+
checkoutDisabled,
|
|
165
168
|
onQuantityChange,
|
|
166
169
|
onRemove,
|
|
167
170
|
onCheckout,
|
|
168
171
|
onQuickCheckout,
|
|
169
172
|
onPayWithApple,
|
|
170
173
|
onPayWithGoogle,
|
|
174
|
+
onApplyPromo,
|
|
171
175
|
onRecentlyViewedClick,
|
|
172
|
-
|
|
176
|
+
onContinueShopping,
|
|
177
|
+
}: ShoppingCartProps) {
|
|
173
178
|
const [isExpanded, setIsExpanded] = useState(true)
|
|
174
179
|
const [quickCheckout, setQuickCheckout] = useState(false)
|
|
175
|
-
|
|
176
|
-
const
|
|
180
|
+
const [promoCode, setPromoCode] = useState('')
|
|
181
|
+
const promoInputId = useId()
|
|
182
|
+
|
|
183
|
+
const isPage = presentation === 'page'
|
|
184
|
+
// The page presentation is always expanded; only the compact card collapses.
|
|
185
|
+
const expanded = isPage ? true : isExpanded
|
|
186
|
+
// Display currency derives from the authoritative Money subtotal; the USD
|
|
187
|
+
// formatter is only the fallback when no authoritative currency exists.
|
|
188
|
+
const format = formatMoneyProp
|
|
189
|
+
? (value: number) => {
|
|
190
|
+
const money: Money = { amount: String(value), currencyCode: totals?.subtotal?.currencyCode ?? 'USD' }
|
|
191
|
+
return formatMoneyProp(money)
|
|
192
|
+
}
|
|
193
|
+
: totals?.subtotal?.currencyCode && totals.subtotal.currencyCode !== 'USD'
|
|
194
|
+
? (value: number) =>
|
|
195
|
+
new Intl.NumberFormat('en-US', { style: 'currency', currency: totals.subtotal.currencyCode }).format(value)
|
|
196
|
+
: (value: number) => fallbackFormatter.format(value)
|
|
177
197
|
const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
|
|
178
198
|
const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
|
|
179
199
|
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
|
|
180
200
|
const hasExpress = Boolean(onQuickCheckout || onPayWithApple || onPayWithGoogle)
|
|
201
|
+
// While totals/mutations are pending, keep confirmed data visible but block
|
|
202
|
+
// conflicting edits and checkout until the backend responds.
|
|
203
|
+
const mutationsDisabled = totalsState === 'pending'
|
|
181
204
|
|
|
182
|
-
const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
|
|
205
|
+
const sortedPromotions = [...(promotions ?? [])].sort((a, b) => a.threshold - b.threshold)
|
|
183
206
|
const currentPromo = (() => {
|
|
184
207
|
if (!isAvailableMoney(totals?.subtotal)) return null
|
|
185
208
|
for (let i = sortedPromotions.length - 1; i >= 0; i--) {
|
|
@@ -195,11 +218,12 @@ export function ShoppingCart4({
|
|
|
195
218
|
? Math.min(100, (subtotalAmount / currentPromo.threshold) * 100)
|
|
196
219
|
: 100
|
|
197
220
|
|
|
198
|
-
const lineAmount = (item:
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
const lineAmount = (item: ShoppingCartItem): string | null => {
|
|
222
|
+
// The backend-authoritative line total renders verbatim. A malformed amount
|
|
223
|
+
// is unavailable (never a fabricated zero); `unitPrice` is never multiplied
|
|
224
|
+
// locally — the cart contract forbids local `unit × qty` arithmetic.
|
|
225
|
+
if (item.lineTotal === undefined) return null
|
|
226
|
+
return money(item.lineTotal)
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
const renderTotals = () => {
|
|
@@ -277,33 +301,73 @@ export function ShoppingCart4({
|
|
|
277
301
|
)
|
|
278
302
|
}
|
|
279
303
|
|
|
304
|
+
const renderPromoEntry = () =>
|
|
305
|
+
onApplyPromo ? (
|
|
306
|
+
<form
|
|
307
|
+
className='flex flex-col gap-2'
|
|
308
|
+
onSubmit={event => {
|
|
309
|
+
event.preventDefault()
|
|
310
|
+
const code = promoCode.trim()
|
|
311
|
+
if (!code) return
|
|
312
|
+
onApplyPromo(code)
|
|
313
|
+
setPromoCode('')
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<label className='text-sm' htmlFor={promoInputId}>
|
|
317
|
+
{promoPromptLabel}
|
|
318
|
+
</label>
|
|
319
|
+
<div className='flex gap-2'>
|
|
320
|
+
<Input
|
|
321
|
+
id={promoInputId}
|
|
322
|
+
value={promoCode}
|
|
323
|
+
onChange={event => setPromoCode(event.target.value)}
|
|
324
|
+
placeholder={promoPlaceholderLabel}
|
|
325
|
+
className='h-9 flex-1'
|
|
326
|
+
/>
|
|
327
|
+
<Button type='submit' variant='outline' size='sm' className='h-9' disabled={mutationsDisabled}>
|
|
328
|
+
{promoApplyLabel}
|
|
329
|
+
</Button>
|
|
330
|
+
</div>
|
|
331
|
+
</form>
|
|
332
|
+
) : null
|
|
333
|
+
|
|
280
334
|
return (
|
|
281
335
|
<section className='py-8'>
|
|
282
|
-
<div className='mx-auto max-w-
|
|
283
|
-
|
|
336
|
+
<div className={cn('mx-auto px-4', isPage ? 'max-w-3xl' : 'max-w-md')}>
|
|
337
|
+
{isPage ? (
|
|
338
|
+
<h1 className='mb-6 text-2xl font-semibold tracking-tight'>{titleLabel}</h1>
|
|
339
|
+
) : null}
|
|
340
|
+
<Card className={isPage ? 'shadow-sm' : 'shadow-lg'}>
|
|
284
341
|
{/* Header */}
|
|
285
342
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
|
|
286
343
|
<CardTitle className='flex items-center gap-2'>
|
|
287
|
-
<
|
|
344
|
+
<ShoppingCartIcon className='size-5' />
|
|
288
345
|
{titleLabel} ({itemCount})
|
|
289
346
|
</CardTitle>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
347
|
+
{!isPage ? (
|
|
348
|
+
<Button
|
|
349
|
+
variant='ghost'
|
|
350
|
+
className='h-8 cursor-pointer px-3 text-xs'
|
|
351
|
+
size='sm'
|
|
352
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
353
|
+
aria-expanded={isExpanded}
|
|
354
|
+
aria-label={isExpanded ? collapseLabel : expandLabel}
|
|
355
|
+
>
|
|
356
|
+
{isExpanded ? <X /> : <Plus />}
|
|
357
|
+
</Button>
|
|
358
|
+
) : null}
|
|
300
359
|
</CardHeader>
|
|
301
360
|
|
|
302
361
|
<CardContent className='flex flex-col gap-6'>
|
|
303
362
|
{items.length === 0 ? (
|
|
304
|
-
<div className='flex flex-col items-center justify-center gap-
|
|
305
|
-
<
|
|
363
|
+
<div className='flex flex-col items-center justify-center gap-4 py-6 text-center'>
|
|
364
|
+
<ShoppingCartIcon className='text-muted-foreground/50 size-10' />
|
|
306
365
|
<p className='text-muted-foreground text-sm'>{emptyLabel}</p>
|
|
366
|
+
{isPage && onContinueShopping ? (
|
|
367
|
+
<Button variant='outline' onClick={onContinueShopping}>
|
|
368
|
+
{continueShoppingLabel}
|
|
369
|
+
</Button>
|
|
370
|
+
) : null}
|
|
307
371
|
</div>
|
|
308
372
|
) : (
|
|
309
373
|
<>
|
|
@@ -337,10 +401,16 @@ export function ShoppingCart4({
|
|
|
337
401
|
</div>
|
|
338
402
|
) : null}
|
|
339
403
|
|
|
340
|
-
{
|
|
404
|
+
{expanded && (
|
|
341
405
|
<>
|
|
342
406
|
{/* Cart Items */}
|
|
343
|
-
<div
|
|
407
|
+
<div
|
|
408
|
+
className={cn(
|
|
409
|
+
'flex flex-col gap-4',
|
|
410
|
+
!isPage &&
|
|
411
|
+
'scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent max-h-64 overflow-y-auto',
|
|
412
|
+
)}
|
|
413
|
+
>
|
|
344
414
|
{items.map(item => (
|
|
345
415
|
<div key={item.id} className='flex items-start gap-3'>
|
|
346
416
|
{item.image ? (
|
|
@@ -366,7 +436,7 @@ export function ShoppingCart4({
|
|
|
366
436
|
? () => onQuantityChange(item.id, item.quantity - 1)
|
|
367
437
|
: undefined
|
|
368
438
|
}
|
|
369
|
-
disabled={item.quantity <= 1}
|
|
439
|
+
disabled={item.quantity <= 1 || mutationsDisabled}
|
|
370
440
|
aria-label={`${decreaseLabel}, ${item.name}`}
|
|
371
441
|
>
|
|
372
442
|
<Minus />
|
|
@@ -382,6 +452,7 @@ export function ShoppingCart4({
|
|
|
382
452
|
: undefined
|
|
383
453
|
}
|
|
384
454
|
aria-label={`${increaseLabel}, ${item.name}`}
|
|
455
|
+
disabled={mutationsDisabled}
|
|
385
456
|
>
|
|
386
457
|
<Plus />
|
|
387
458
|
</Button>
|
|
@@ -390,6 +461,7 @@ export function ShoppingCart4({
|
|
|
390
461
|
size='sm'
|
|
391
462
|
className='text-destructive hover:text-destructive ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs'
|
|
392
463
|
onClick={onRemove ? () => onRemove(item.id) : undefined}
|
|
464
|
+
disabled={mutationsDisabled}
|
|
393
465
|
aria-label={`${removeLabel} ${item.name}`}
|
|
394
466
|
>
|
|
395
467
|
<X />
|
|
@@ -406,6 +478,9 @@ export function ShoppingCart4({
|
|
|
406
478
|
{/* Order Summary — backend-authoritative only */}
|
|
407
479
|
<div className='flex flex-col gap-2'>{renderTotals()}</div>
|
|
408
480
|
|
|
481
|
+
{/* Promo code entry — reports to the host, never resolves locally */}
|
|
482
|
+
{renderPromoEntry()}
|
|
483
|
+
|
|
409
484
|
{/* Quick Actions */}
|
|
410
485
|
<div className='flex flex-col gap-3'>
|
|
411
486
|
{/* Express Checkout Toggle — only when the host supplies a handler */}
|
|
@@ -445,13 +520,28 @@ export function ShoppingCart4({
|
|
|
445
520
|
) : null}
|
|
446
521
|
|
|
447
522
|
{/* Checkout Button */}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
523
|
+
{!checkoutUnavailable ? (
|
|
524
|
+
<Button
|
|
525
|
+
size='lg'
|
|
526
|
+
className='h-10 w-full cursor-pointer px-8'
|
|
527
|
+
onClick={hasExpress && quickCheckout ? onQuickCheckout : onCheckout}
|
|
528
|
+
disabled={checkoutDisabled || totalsState === 'pending'}
|
|
529
|
+
>
|
|
530
|
+
{hasExpress && quickCheckout ? quickCheckoutLabel : checkoutLabel}
|
|
531
|
+
</Button>
|
|
532
|
+
) : null}
|
|
533
|
+
|
|
534
|
+
{/* Continue shopping — page presentation only */}
|
|
535
|
+
{isPage && onContinueShopping ? (
|
|
536
|
+
<Button
|
|
537
|
+
variant='outline'
|
|
538
|
+
size='lg'
|
|
539
|
+
className='h-10 w-full cursor-pointer px-8'
|
|
540
|
+
onClick={onContinueShopping}
|
|
541
|
+
>
|
|
542
|
+
{continueShoppingLabel}
|
|
543
|
+
</Button>
|
|
544
|
+
) : null}
|
|
455
545
|
|
|
456
546
|
{/* Estimated Delivery — only when supplied, never fabricated */}
|
|
457
547
|
{estimatedDeliveryLabel ? (
|
|
@@ -473,7 +563,7 @@ export function ShoppingCart4({
|
|
|
473
563
|
)}
|
|
474
564
|
|
|
475
565
|
{/* Collapsed Summary */}
|
|
476
|
-
{!
|
|
566
|
+
{!expanded && (
|
|
477
567
|
<div className='flex flex-col gap-3'>
|
|
478
568
|
<div className='flex items-center justify-between'>
|
|
479
569
|
<span className='text-muted-foreground text-sm'>
|
|
@@ -490,34 +580,39 @@ export function ShoppingCart4({
|
|
|
490
580
|
</div>
|
|
491
581
|
)}
|
|
492
582
|
|
|
493
|
-
{/* Recently Viewed */}
|
|
494
|
-
{
|
|
583
|
+
{/* Recently Viewed — host-supplied suggestions only */}
|
|
584
|
+
{expanded && recentlyViewed && recentlyViewed.length > 0 && (
|
|
495
585
|
<div className='flex flex-col gap-3'>
|
|
496
586
|
<h4 className='text-sm font-medium'>{recentlyViewedLabel}</h4>
|
|
497
587
|
<div className='grid grid-cols-3 gap-2'>
|
|
498
|
-
{recentlyViewed.map(item =>
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
'
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
588
|
+
{recentlyViewed.map(item => {
|
|
589
|
+
const suggestionPrice = money(item.unitPrice)
|
|
590
|
+
return (
|
|
591
|
+
<button
|
|
592
|
+
key={item.id}
|
|
593
|
+
type='button'
|
|
594
|
+
className={cn(
|
|
595
|
+
'group text-start',
|
|
596
|
+
onRecentlyViewedClick && 'cursor-pointer',
|
|
597
|
+
)}
|
|
598
|
+
onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
|
|
599
|
+
>
|
|
600
|
+
<div className='bg-muted mb-1 aspect-square overflow-hidden'>
|
|
601
|
+
{item.image ? (
|
|
602
|
+
<img
|
|
603
|
+
src={item.image || undefined}
|
|
604
|
+
alt={item.name}
|
|
605
|
+
className='size-full rounded-md object-cover'
|
|
606
|
+
/>
|
|
607
|
+
) : null}
|
|
608
|
+
</div>
|
|
609
|
+
<p className='text-muted-foreground truncate text-xs'>{item.name}</p>
|
|
610
|
+
{suggestionPrice !== null ? (
|
|
611
|
+
<p className='text-xs font-medium tabular-nums'>{suggestionPrice}</p>
|
|
515
612
|
) : null}
|
|
516
|
-
</
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
</button>
|
|
520
|
-
))}
|
|
613
|
+
</button>
|
|
614
|
+
)
|
|
615
|
+
})}
|
|
521
616
|
</div>
|
|
522
617
|
</div>
|
|
523
618
|
)}
|
|
@@ -530,4 +625,4 @@ export function ShoppingCart4({
|
|
|
530
625
|
)
|
|
531
626
|
}
|
|
532
627
|
|
|
533
|
-
export default
|
|
628
|
+
export default ShoppingCart
|