@ecomiq/storefront 1.1.5 → 2.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.
Files changed (134) hide show
  1. package/README.md +26 -417
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -84
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-3QGB55R6.js +0 -157
  67. package/dist/chunk-3QGB55R6.js.map +0 -1
  68. package/dist/chunk-64GLOWMJ.js +0 -216
  69. package/dist/chunk-64GLOWMJ.js.map +0 -1
  70. package/dist/chunk-ASFU32ZM.js +0 -236
  71. package/dist/chunk-ASFU32ZM.js.map +0 -1
  72. package/dist/chunk-Q4U5OEUT.js +0 -21
  73. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  74. package/dist/chunk-QRR7EPCH.js +0 -176
  75. package/dist/chunk-QRR7EPCH.js.map +0 -1
  76. package/dist/chunk-S7CPZ4UE.js +0 -6
  77. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  78. package/dist/chunk-ZG2XUYGZ.js +0 -1281
  79. package/dist/chunk-ZG2XUYGZ.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10041
  83. package/dist/chunks/client.js +0 -5363
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -47
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -34
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-C6bWDLiw.d.ts +0 -3926
  98. package/dist/ecomiq-oauth-password-plugin-J5wDFqzL.d.ts +0 -3629
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -1
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -2
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -1
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,143 @@
1
+ import type { StorefrontCart, StorefrontCartItem } from '@ecomiq/core'
2
+
3
+ export type CartLineSignatureItem = {
4
+ id: string
5
+ variantId: string
6
+ quantity: number
7
+ }
8
+
9
+ export type CartReconcileAction =
10
+ | { action: 'add'; variantId: string; quantity: number }
11
+ | { action: 'update'; itemId: string; quantity: number }
12
+ | { action: 'remove'; itemId: string }
13
+
14
+ export function cartItemsSignature(items: CartLineSignatureItem[]): string {
15
+ return items
16
+ .map((i) => `${i.variantId}:${i.quantity}`)
17
+ .sort()
18
+ .join('|')
19
+ }
20
+
21
+ export function toSignatureItems(
22
+ items: StorefrontCartItem[] | undefined,
23
+ ): CartLineSignatureItem[] {
24
+ return (items ?? []).map((item) => ({
25
+ id: item.id,
26
+ variantId: String(item.variantId),
27
+ quantity: item.quantity,
28
+ }))
29
+ }
30
+
31
+ export function areCartSignaturesEqual(
32
+ a: CartLineSignatureItem[],
33
+ b: CartLineSignatureItem[],
34
+ ): boolean {
35
+ return cartItemsSignature(a) === cartItemsSignature(b)
36
+ }
37
+
38
+ export function cartSessionSignature(
39
+ cartTokenId: string | null | undefined,
40
+ items: CartLineSignatureItem[],
41
+ ): string {
42
+ return `${cartTokenId ?? ''}|${cartItemsSignature(items)}`
43
+ }
44
+
45
+ function groupQuantitiesByVariant(items: CartLineSignatureItem[]): Map<string, number> {
46
+ const map = new Map<string, number>()
47
+ for (const item of items) {
48
+ map.set(item.variantId, (map.get(item.variantId) ?? 0) + Math.max(0, item.quantity))
49
+ }
50
+ return map
51
+ }
52
+
53
+ function groupLinesByVariant(
54
+ items: CartLineSignatureItem[],
55
+ ): Map<string, CartLineSignatureItem[]> {
56
+ const map = new Map<string, CartLineSignatureItem[]>()
57
+ for (const item of items) {
58
+ const lines = map.get(item.variantId) ?? []
59
+ lines.push(item)
60
+ map.set(item.variantId, lines)
61
+ }
62
+ return map
63
+ }
64
+
65
+ /** Acciones para alinear el API con el carrito local (fuente de verdad). */
66
+ export function findCartReconciliationActions(
67
+ local: CartLineSignatureItem[],
68
+ api: CartLineSignatureItem[],
69
+ ): CartReconcileAction[] {
70
+ const actions: CartReconcileAction[] = []
71
+ const localQty = groupQuantitiesByVariant(local)
72
+ const apiLinesByVariant = groupLinesByVariant(api)
73
+ const variantIds = new Set([...localQty.keys(), ...apiLinesByVariant.keys()])
74
+
75
+ for (const variantId of variantIds) {
76
+ const targetQty = localQty.get(variantId) ?? 0
77
+ const apiLines = apiLinesByVariant.get(variantId) ?? []
78
+ const apiQty = apiLines.reduce((sum, line) => sum + line.quantity, 0)
79
+
80
+ if (targetQty > apiQty) {
81
+ actions.push({
82
+ action: 'add',
83
+ variantId,
84
+ quantity: targetQty - apiQty,
85
+ })
86
+ continue
87
+ }
88
+
89
+ if (targetQty >= apiQty) continue
90
+
91
+ let excess = apiQty - targetQty
92
+ for (const line of [...apiLines].reverse()) {
93
+ if (excess <= 0) break
94
+ if (line.quantity <= excess) {
95
+ actions.push({ action: 'remove', itemId: line.id })
96
+ excess -= line.quantity
97
+ } else {
98
+ actions.push({
99
+ action: 'update',
100
+ itemId: line.id,
101
+ quantity: line.quantity - excess,
102
+ })
103
+ excess = 0
104
+ }
105
+ }
106
+ }
107
+
108
+ return actions
109
+ }
110
+
111
+ export function isCheckoutCartAlreadyPurchased(
112
+ cart: StorefrontCart | undefined,
113
+ ): boolean {
114
+ if (!cart) return false
115
+ if (cart.completedAt) return true
116
+ return cart.status === 'completed'
117
+ }
118
+
119
+ const PROVIDER_CHECKOUT_UI = new Set(['stripe', 'mercadopago', 'mercado_pago'])
120
+
121
+ function normalizeProviderKey(raw: string): string {
122
+ return raw.trim().toLowerCase().replace(/[\s-]+/g, '_')
123
+ }
124
+
125
+ export function methodUsesProviderCheckoutUi(method: {
126
+ isManual: boolean
127
+ kind: string
128
+ code: string
129
+ providerMethodCode?: string | null
130
+ }): boolean {
131
+ if (method.isManual) return false
132
+ const keys = [method.providerMethodCode, method.kind, method.code].filter(
133
+ Boolean,
134
+ ) as string[]
135
+ return keys.some((key) => {
136
+ const normalized = normalizeProviderKey(key)
137
+ return (
138
+ PROVIDER_CHECKOUT_UI.has(normalized) ||
139
+ normalized.includes('stripe') ||
140
+ normalized.includes('mercado')
141
+ )
142
+ })
143
+ }
@@ -0,0 +1,15 @@
1
+ import { Button } from '../../ui'
2
+
3
+ export function CheckoutExternalUpdateBanner({ onReload }: { onReload: () => void }) {
4
+ return (
5
+ <div
6
+ className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-300/80 bg-amber-50 px-4 py-3 text-sm text-amber-950"
7
+ role="alert"
8
+ >
9
+ <p>Tu carrito se acaba de actualizar</p>
10
+ <Button type="button" size="sm" onClick={onReload}>
11
+ Recargar checkout
12
+ </Button>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,139 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '../../ui'
3
+
4
+ export function fieldClass(error?: string) {
5
+ return cn(
6
+ 'mt-1.5 flex h-11 w-full rounded-md border border-border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring',
7
+ error && 'border-destructive',
8
+ )
9
+ }
10
+
11
+ export function FieldError({ message }: { message?: string }) {
12
+ if (!message) return null
13
+ return <p className="mt-1 text-xs text-destructive">{message}</p>
14
+ }
15
+
16
+ export function CheckoutTextField({
17
+ id,
18
+ label,
19
+ value,
20
+ onChange,
21
+ error,
22
+ type = 'text',
23
+ autoComplete,
24
+ inputMode,
25
+ placeholder,
26
+ }: {
27
+ id: string
28
+ label: string
29
+ value: string
30
+ onChange: (value: string) => void
31
+ error?: string
32
+ type?: string
33
+ autoComplete?: string
34
+ inputMode?: HTMLAttributes<HTMLInputElement>['inputMode']
35
+ placeholder?: string
36
+ }) {
37
+ return (
38
+ <div className="min-w-0">
39
+ <label htmlFor={id} className="text-sm font-medium text-foreground">
40
+ {label}
41
+ </label>
42
+ <input
43
+ id={id}
44
+ type={type}
45
+ className={fieldClass(error)}
46
+ value={value}
47
+ onChange={(e) => onChange(e.target.value)}
48
+ autoComplete={autoComplete}
49
+ inputMode={inputMode}
50
+ placeholder={placeholder}
51
+ />
52
+ <FieldError message={error} />
53
+ </div>
54
+ )
55
+ }
56
+
57
+ export function CheckoutSelectField({
58
+ id,
59
+ label,
60
+ value,
61
+ options,
62
+ onChange,
63
+ error,
64
+ placeholder = 'Seleccionar…',
65
+ }: {
66
+ id: string
67
+ label: string
68
+ value: string
69
+ options: { value: string; label: string }[]
70
+ onChange: (value: string) => void
71
+ error?: string
72
+ placeholder?: string
73
+ }) {
74
+ return (
75
+ <div className="min-w-0">
76
+ <label htmlFor={id} className="text-sm font-medium text-foreground">
77
+ {label}
78
+ </label>
79
+ <select
80
+ id={id}
81
+ className={fieldClass(error)}
82
+ value={value}
83
+ onChange={(e) => onChange(e.target.value)}
84
+ >
85
+ <option value="">{placeholder}</option>
86
+ {options.map((o) => (
87
+ <option key={o.value} value={o.value}>
88
+ {o.label}
89
+ </option>
90
+ ))}
91
+ </select>
92
+ <FieldError message={error} />
93
+ </div>
94
+ )
95
+ }
96
+
97
+ export function CheckoutPhoneField({
98
+ id,
99
+ label,
100
+ value,
101
+ dialCode,
102
+ error,
103
+ onChange,
104
+ }: {
105
+ id: string
106
+ label: string
107
+ value: string
108
+ dialCode: string
109
+ error?: string
110
+ onChange: (value: string) => void
111
+ }) {
112
+ return (
113
+ <div className="min-w-0">
114
+ <label htmlFor={id} className="text-sm font-medium text-foreground">
115
+ {label}
116
+ </label>
117
+ <div
118
+ className={cn(
119
+ 'mt-1.5 flex h-11 overflow-hidden rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring',
120
+ error ? 'border-destructive' : 'border-border',
121
+ )}
122
+ >
123
+ <span className="flex shrink-0 items-center border-r bg-muted/40 px-3 text-sm text-muted-foreground">
124
+ {dialCode}
125
+ </span>
126
+ <input
127
+ id={id}
128
+ type="tel"
129
+ inputMode="numeric"
130
+ autoComplete="tel-national"
131
+ className="min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
132
+ value={value}
133
+ onChange={(e) => onChange(e.target.value.replace(/\D/g, ''))}
134
+ />
135
+ </div>
136
+ <FieldError message={error} />
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,43 @@
1
+ import type { StorefrontOrderBump } from '@ecomiq/core'
2
+ import { Button } from '../../ui'
3
+
4
+ export function CheckoutOrderBumps({
5
+ bumps,
6
+ money,
7
+ busy,
8
+ onAdd,
9
+ }: {
10
+ bumps: StorefrontOrderBump[]
11
+ money: (n: number) => string
12
+ busy: boolean
13
+ onAdd: (bump: StorefrontOrderBump) => void
14
+ }) {
15
+ if (bumps.length === 0) return null
16
+ return (
17
+ <section className="space-y-3 rounded-xl border bg-card p-5">
18
+ <h2 className="text-lg font-semibold">Agregar al pedido</h2>
19
+ {bumps.map((bump) => (
20
+ <div key={bump.id} className="flex items-center gap-3">
21
+ <div className="size-14 overflow-hidden rounded-md bg-muted">
22
+ {bump.imageUrl ? (
23
+ <img src={bump.imageUrl} alt="" className="size-full object-cover" />
24
+ ) : null}
25
+ </div>
26
+ <div className="min-w-0 flex-1">
27
+ <p className="text-sm font-medium">{bump.productName}</p>
28
+ <p className="text-xs text-muted-foreground">{money(bump.offerPrice)}</p>
29
+ </div>
30
+ <Button
31
+ type="button"
32
+ size="sm"
33
+ variant="outline"
34
+ disabled={busy}
35
+ onClick={() => onAdd(bump)}
36
+ >
37
+ {bump.callToAction || 'Agregar'}
38
+ </Button>
39
+ </div>
40
+ ))}
41
+ </section>
42
+ )
43
+ }
@@ -0,0 +1,156 @@
1
+ import { Package, Tag } from 'lucide-react'
2
+ import type { StorefrontCartItem } from '@ecomiq/core'
3
+ import { Button } from '../../ui'
4
+ import { fieldClass } from './checkout-form-controls'
5
+
6
+ export function CheckoutOrderSummary({
7
+ items,
8
+ money,
9
+ subtotal,
10
+ discount,
11
+ shipping,
12
+ tax,
13
+ total,
14
+ hasShippingOptions,
15
+ couponCode,
16
+ couponError,
17
+ promotionCodes,
18
+ formError,
19
+ isSaving,
20
+ onCouponCodeChange,
21
+ onApplyCoupon,
22
+ onRemoveCoupon,
23
+ }: {
24
+ items: StorefrontCartItem[]
25
+ money: (value: number) => string
26
+ subtotal: number
27
+ discount: number
28
+ shipping: number
29
+ tax: number
30
+ total: number
31
+ hasShippingOptions: boolean
32
+ couponCode: string
33
+ couponError: string | null
34
+ promotionCodes: string[]
35
+ formError: string | null
36
+ isSaving: boolean
37
+ onCouponCodeChange: (value: string) => void
38
+ onApplyCoupon: () => void
39
+ onRemoveCoupon: (code: string) => void
40
+ }) {
41
+ return (
42
+ <aside className="rounded-xl border bg-card p-5 shadow-sm lg:sticky lg:top-6">
43
+ <h2 className="text-lg font-semibold">Resumen</h2>
44
+
45
+ <ul className="mt-4 divide-y border-y" aria-label="Productos">
46
+ {items.map((item) => (
47
+ <SummaryLineItem key={item.id} item={item} money={money} />
48
+ ))}
49
+ </ul>
50
+
51
+ <div className="mt-4 flex gap-2">
52
+ <input
53
+ className={fieldClass()}
54
+ placeholder="Cupón"
55
+ value={couponCode}
56
+ onChange={(e) => onCouponCodeChange(e.target.value)}
57
+ />
58
+ <Button type="button" variant="outline" onClick={onApplyCoupon}>
59
+ Aplicar
60
+ </Button>
61
+ </div>
62
+ {couponError ? (
63
+ <p className="mt-2 text-xs text-destructive">{couponError}</p>
64
+ ) : null}
65
+ {promotionCodes.length > 0 ? (
66
+ <div className="mt-3 flex flex-wrap gap-2">
67
+ {promotionCodes.map((code) => (
68
+ <button
69
+ key={code}
70
+ type="button"
71
+ className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium"
72
+ onClick={() => onRemoveCoupon(code)}
73
+ >
74
+ <Tag className="size-3" aria-hidden="true" />
75
+ {code} ×
76
+ </button>
77
+ ))}
78
+ </div>
79
+ ) : null}
80
+
81
+ <dl className="mt-5 space-y-3 text-sm">
82
+ <div className="flex justify-between gap-4">
83
+ <dt className="text-muted-foreground">Subtotal</dt>
84
+ <dd className="tabular-nums">{money(subtotal)}</dd>
85
+ </div>
86
+ {discount !== 0 ? (
87
+ <div className="flex justify-between gap-4 text-primary">
88
+ <dt>Descuentos</dt>
89
+ <dd className="tabular-nums">-{money(Math.abs(discount))}</dd>
90
+ </div>
91
+ ) : null}
92
+ <div className="flex justify-between gap-4">
93
+ <dt className="text-muted-foreground">Envío</dt>
94
+ <dd className="tabular-nums">
95
+ {hasShippingOptions ? money(shipping) : 'Por calcular'}
96
+ </dd>
97
+ </div>
98
+ {tax !== 0 ? (
99
+ <div className="flex justify-between gap-4">
100
+ <dt className="text-muted-foreground">Impuestos</dt>
101
+ <dd className="tabular-nums">{money(tax)}</dd>
102
+ </div>
103
+ ) : null}
104
+ <div className="flex items-baseline justify-between gap-4 border-t pt-4">
105
+ <dt className="font-semibold">Total</dt>
106
+ <dd className="text-xl font-semibold tabular-nums">{money(total)}</dd>
107
+ </div>
108
+ </dl>
109
+
110
+ {formError ? (
111
+ <p className="mt-4 text-sm text-destructive" role="alert">
112
+ {formError}
113
+ </p>
114
+ ) : null}
115
+
116
+ <Button type="submit" className="mt-6 w-full" size="lg" disabled={isSaving}>
117
+ {isSaving ? 'Procesando…' : 'Pay now'}
118
+ </Button>
119
+ </aside>
120
+ )
121
+ }
122
+
123
+ function SummaryLineItem({
124
+ item,
125
+ money,
126
+ }: {
127
+ item: StorefrontCartItem
128
+ money: (value: number) => string
129
+ }) {
130
+ const lineTotal = item.unitPrice * item.quantity
131
+ return (
132
+ <li className="flex gap-3 py-3">
133
+ <div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
134
+ {item.thumbnail ? (
135
+ <img
136
+ src={item.thumbnail}
137
+ alt=""
138
+ width={56}
139
+ height={56}
140
+ className="size-full object-cover"
141
+ />
142
+ ) : (
143
+ <Package className="size-4 text-muted-foreground" aria-hidden="true" />
144
+ )}
145
+ </div>
146
+ <div className="min-w-0 flex-1">
147
+ <p className="text-sm font-medium leading-snug">{item.title}</p>
148
+ <div className="mt-0.5 flex flex-wrap gap-x-2 text-xs text-muted-foreground">
149
+ <span>Cant. {item.quantity}</span>
150
+ {item.variantLabel ? <span>{item.variantLabel}</span> : null}
151
+ </div>
152
+ </div>
153
+ <p className="shrink-0 text-sm font-semibold tabular-nums">{money(lineTotal)}</p>
154
+ </li>
155
+ )
156
+ }