@ecomiq/storefront 1.1.9 → 2.0.1
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/README.md +30 -415
- package/bin/ecomiq.mjs +165 -0
- package/package.json +72 -94
- package/src/api-proxy.ts +194 -0
- package/src/auth/create-storefront-auth.ts +35 -0
- package/src/auth/ecomiq-oauth-password-client.ts +26 -0
- package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
- package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
- package/src/auth/ecomiq-oauth-password-types.ts +25 -0
- package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
- package/src/auth/env.ts +7 -0
- package/src/cart-session.ts +241 -0
- package/src/client.ts +48 -0
- package/src/codegen.mjs +704 -0
- package/src/components/checkout/cart-sync.ts +143 -0
- package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
- package/src/components/checkout/checkout-form-controls.tsx +139 -0
- package/src/components/checkout/checkout-line-items.tsx +43 -0
- package/src/components/checkout/checkout-order-summary.tsx +156 -0
- package/src/components/checkout/checkout-payment-section.tsx +215 -0
- package/src/components/checkout/checkout-session-view.tsx +240 -0
- package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
- package/src/components/checkout/checkout-shipping-section.tsx +90 -0
- package/src/components/checkout/checkout-states.tsx +73 -0
- package/src/components/checkout/form-schema.ts +229 -0
- package/src/components/checkout/format.ts +27 -0
- package/src/components/checkout/index.ts +51 -0
- package/src/components/checkout/types.ts +43 -0
- package/src/components/checkout/use-checkout-geo.ts +50 -0
- package/src/components/checkout/use-checkout-session.ts +583 -0
- package/src/components/hero.tsx +291 -0
- package/src/config.d.ts +69 -0
- package/src/config.mjs +165 -0
- package/src/index.ts +274 -0
- package/src/link.tsx +2 -0
- package/src/pages/checkout.$checkoutId.tsx +60 -0
- package/src/pages/checkout.tsx +31 -0
- package/src/pages/thank-you.tsx +53 -0
- package/src/providers/auth.tsx +69 -0
- package/src/providers/cart.tsx +275 -0
- package/src/providers/ecomiq.ts +137 -0
- package/src/providers/index.ts +26 -0
- package/src/public-config.ts +44 -0
- package/src/review-submission.ts +89 -0
- package/src/runtime/context.ts +1 -0
- package/src/runtime/default-layout.tsx +80 -0
- package/src/runtime/head.ts +126 -0
- package/src/runtime/page.tsx +82 -0
- package/src/runtime/postcss-browser-stub.mjs +5 -0
- package/src/server-config-store.ts +26 -0
- package/src/server.ts +279 -0
- package/src/styles.css +76 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +31 -0
- package/src/ui/card.tsx +31 -0
- package/src/ui/carousel.tsx +218 -0
- package/src/ui/cn.ts +6 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/separator.tsx +6 -0
- package/src/ui/skeleton.tsx +6 -0
- package/src/ui/storefront-error.tsx +49 -0
- package/src/utils.ts +36 -0
- package/src/vite-inline.mjs +135 -0
- package/templates/__root.tsx +77 -0
- package/templates/router.tsx +19 -0
- package/dist/chunk-64GLOWMJ.js +0 -216
- package/dist/chunk-64GLOWMJ.js.map +0 -1
- package/dist/chunk-AKD5IDEB.js +0 -177
- package/dist/chunk-AKD5IDEB.js.map +0 -1
- package/dist/chunk-EFEVJJWF.js +0 -1345
- package/dist/chunk-EFEVJJWF.js.map +0 -1
- package/dist/chunk-NAJQB5DN.js +0 -155
- package/dist/chunk-NAJQB5DN.js.map +0 -1
- package/dist/chunk-OABDGTBI.js +0 -253
- package/dist/chunk-OABDGTBI.js.map +0 -1
- package/dist/chunk-Q4U5OEUT.js +0 -21
- package/dist/chunk-Q4U5OEUT.js.map +0 -1
- package/dist/chunk-S7CPZ4UE.js +0 -6
- package/dist/chunk-S7CPZ4UE.js.map +0 -1
- package/dist/chunks/client.css +0 -984
- package/dist/chunks/client.css.map +0 -1
- package/dist/chunks/client.d.ts +0 -10759
- package/dist/chunks/client.js +0 -5398
- package/dist/chunks/client.js.map +0 -1
- package/dist/chunks/config.d.ts +0 -31
- package/dist/chunks/config.js +0 -5
- package/dist/chunks/config.js.map +0 -1
- package/dist/chunks/core-public.d.ts +0 -130
- package/dist/chunks/core-public.js +0 -92
- package/dist/chunks/core-public.js.map +0 -1
- package/dist/chunks/server-nextjs.d.ts +0 -33
- package/dist/chunks/server-nextjs.js +0 -59
- package/dist/chunks/server-nextjs.js.map +0 -1
- package/dist/chunks/server-tanstack.d.ts +0 -22
- package/dist/chunks/server-tanstack.js +0 -40
- package/dist/chunks/server-tanstack.js.map +0 -1
- package/dist/collection-DU7gREXS.d.ts +0 -4062
- package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
- package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
- package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.server.d.ts +0 -9
- package/dist/index.server.js +0 -8
- package/dist/index.server.js.map +0 -1
- package/dist/nextjs/api.d.ts +0 -45
- package/dist/nextjs/api.js +0 -32
- package/dist/nextjs/api.js.map +0 -1
- package/dist/nextjs/globals.css +0 -13
- package/dist/nextjs.d.ts +0 -2
- package/dist/nextjs.js +0 -2
- package/dist/nextjs.js.map +0 -1
- package/dist/nextjs.server.d.ts +0 -18
- package/dist/nextjs.server.js +0 -1
- package/dist/nextjs.server.js.map +0 -1
- package/dist/postcss.config.mjs +0 -6
- package/dist/react/checkout-page.css +0 -855
- package/dist/react/client.css +0 -984
- package/dist/react/payment-provider.css +0 -91
- package/dist/react/thank-you-page.css +0 -105
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -2
- package/dist/react.js.map +0 -1
- package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
- package/dist/tanstack/api.d.ts +0 -41
- package/dist/tanstack/api.js +0 -30
- package/dist/tanstack/api.js.map +0 -1
- package/dist/tanstack.d.ts +0 -1
- package/dist/tanstack.js +0 -2
- package/dist/tanstack.js.map +0 -1
- package/dist/tanstack.server.d.ts +0 -18
- package/dist/tanstack.server.js +0 -1
- 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
|
+
}
|