@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.
- package/README.md +26 -417
- package/bin/ecomiq.mjs +154 -0
- package/package.json +72 -84
- 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 +603 -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 +95 -0
- package/templates/__root.tsx +77 -0
- package/templates/router.tsx +19 -0
- package/dist/chunk-3QGB55R6.js +0 -157
- package/dist/chunk-3QGB55R6.js.map +0 -1
- package/dist/chunk-64GLOWMJ.js +0 -216
- package/dist/chunk-64GLOWMJ.js.map +0 -1
- package/dist/chunk-ASFU32ZM.js +0 -236
- package/dist/chunk-ASFU32ZM.js.map +0 -1
- package/dist/chunk-Q4U5OEUT.js +0 -21
- package/dist/chunk-Q4U5OEUT.js.map +0 -1
- package/dist/chunk-QRR7EPCH.js +0 -176
- package/dist/chunk-QRR7EPCH.js.map +0 -1
- package/dist/chunk-S7CPZ4UE.js +0 -6
- package/dist/chunk-S7CPZ4UE.js.map +0 -1
- package/dist/chunk-ZG2XUYGZ.js +0 -1281
- package/dist/chunk-ZG2XUYGZ.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 -10041
- package/dist/chunks/client.js +0 -5363
- 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 -47
- 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 -34
- package/dist/chunks/server-tanstack.js.map +0 -1
- package/dist/collection-C6bWDLiw.d.ts +0 -3926
- package/dist/ecomiq-oauth-password-plugin-J5wDFqzL.d.ts +0 -3629
- 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 -1
- 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 -2
- package/dist/tanstack.js +0 -2
- package/dist/tanstack.js.map +0 -1
- package/dist/tanstack.server.d.ts +0 -1
- package/dist/tanstack.server.js +0 -1
- package/dist/tanstack.server.js.map +0 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estado de carrito storefront: sesión en localStorage + mutaciones vía serverApi.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <CartProvider storeId={settings.store.id} currencyCode={settings.store.currency}>
|
|
6
|
+
* <App />
|
|
7
|
+
* </CartProvider>
|
|
8
|
+
*
|
|
9
|
+
* const { cart, addItem } = useCart()
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from 'react'
|
|
20
|
+
import { type StorefrontCart } from '@ecomiq/core'
|
|
21
|
+
import { z } from 'zod'
|
|
22
|
+
import { serverApi } from '../server'
|
|
23
|
+
|
|
24
|
+
const CartSessionSchema = z.object({
|
|
25
|
+
version: z.literal(1),
|
|
26
|
+
storeId: z.string().min(1),
|
|
27
|
+
cartId: z.string().uuid(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export type CartLoadStatus = 'loading' | 'ready' | 'error'
|
|
31
|
+
|
|
32
|
+
export type CartMutation = {
|
|
33
|
+
kind: 'adding' | 'updating' | 'removing'
|
|
34
|
+
itemId?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CartContextValue {
|
|
38
|
+
cart: StorefrontCart | null
|
|
39
|
+
cartId: string | undefined
|
|
40
|
+
itemCount: number
|
|
41
|
+
loadStatus: CartLoadStatus
|
|
42
|
+
mutation: CartMutation | null
|
|
43
|
+
error: string | undefined
|
|
44
|
+
addItem: (input: { variantId: string; quantity: number }) => Promise<StorefrontCart>
|
|
45
|
+
setItemQuantity: (itemId: string, quantity: number) => Promise<void>
|
|
46
|
+
removeItem: (itemId: string) => Promise<void>
|
|
47
|
+
reload: () => Promise<void>
|
|
48
|
+
clearCart: () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CartContext = createContext<CartContextValue | null>(null)
|
|
52
|
+
|
|
53
|
+
function sessionKey(storeId: string) {
|
|
54
|
+
return `ecomiq:cart:v1:${storeId}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readCartSession(key: string) {
|
|
58
|
+
if (typeof localStorage === 'undefined') return undefined
|
|
59
|
+
const raw = localStorage.getItem(key)
|
|
60
|
+
if (!raw) return undefined
|
|
61
|
+
try {
|
|
62
|
+
const result = CartSessionSchema.safeParse(JSON.parse(raw))
|
|
63
|
+
return result.success ? result.data : undefined
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function CartProvider({
|
|
70
|
+
storeId,
|
|
71
|
+
currencyCode,
|
|
72
|
+
children,
|
|
73
|
+
}: {
|
|
74
|
+
storeId: string
|
|
75
|
+
currencyCode: string
|
|
76
|
+
children: ReactNode
|
|
77
|
+
}) {
|
|
78
|
+
const key = sessionKey(storeId)
|
|
79
|
+
const [cart, setCart] = useState<StorefrontCart | null>(null)
|
|
80
|
+
const [cartId, setCartId] = useState<string>()
|
|
81
|
+
const [loadStatus, setLoadStatus] = useState<CartLoadStatus>('loading')
|
|
82
|
+
const [mutation, setMutation] = useState<CartMutation | null>(null)
|
|
83
|
+
const [error, setError] = useState<string>()
|
|
84
|
+
const mutationInFlight = useRef(false)
|
|
85
|
+
|
|
86
|
+
const persist = (nextCartId: string) => {
|
|
87
|
+
setCartId(nextCartId)
|
|
88
|
+
localStorage.setItem(key, JSON.stringify({ version: 1, storeId, cartId: nextCartId }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const clear = () => {
|
|
92
|
+
setCart(null)
|
|
93
|
+
setCartId(undefined)
|
|
94
|
+
localStorage.removeItem(key)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reload = async () => {
|
|
98
|
+
setLoadStatus('loading')
|
|
99
|
+
setError(undefined)
|
|
100
|
+
const stored = readCartSession(key)
|
|
101
|
+
if (!stored || stored.storeId !== storeId) {
|
|
102
|
+
clear()
|
|
103
|
+
setLoadStatus('ready')
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setCartId(stored.cartId)
|
|
108
|
+
try {
|
|
109
|
+
const result = await serverApi.loadCart({ cartId: stored.cartId })
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
setError(result.error.message)
|
|
112
|
+
setLoadStatus('error')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (!result.value) {
|
|
116
|
+
clear()
|
|
117
|
+
} else {
|
|
118
|
+
setCart(result.value)
|
|
119
|
+
}
|
|
120
|
+
setLoadStatus('ready')
|
|
121
|
+
} catch {
|
|
122
|
+
setError('No pudimos cargar el carrito. Inténtalo nuevamente.')
|
|
123
|
+
setLoadStatus('error')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
void reload()
|
|
129
|
+
const sync = (event: StorageEvent) => {
|
|
130
|
+
if (event.key === key) void reload()
|
|
131
|
+
}
|
|
132
|
+
window.addEventListener('storage', sync)
|
|
133
|
+
return () => window.removeEventListener('storage', sync)
|
|
134
|
+
}, [key])
|
|
135
|
+
|
|
136
|
+
const addItem = async (input: { variantId: string; quantity: number }) => {
|
|
137
|
+
if (mutationInFlight.current) {
|
|
138
|
+
const message = 'El carrito se está actualizando. Inténtalo nuevamente.'
|
|
139
|
+
setError(message)
|
|
140
|
+
throw new Error(message)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
mutationInFlight.current = true
|
|
144
|
+
setMutation({ kind: 'adding' })
|
|
145
|
+
setError(undefined)
|
|
146
|
+
try {
|
|
147
|
+
let result
|
|
148
|
+
try {
|
|
149
|
+
result = await serverApi.addProductToCart({
|
|
150
|
+
cartId,
|
|
151
|
+
currencyCode,
|
|
152
|
+
variantId: input.variantId,
|
|
153
|
+
quantity: input.quantity,
|
|
154
|
+
})
|
|
155
|
+
} catch {
|
|
156
|
+
const message = 'No pudimos actualizar el carrito. Inténtalo nuevamente.'
|
|
157
|
+
setError(message)
|
|
158
|
+
throw new Error(message)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
setError(result.error.message)
|
|
163
|
+
throw new Error(result.error.message)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setError(undefined)
|
|
167
|
+
persist(result.value.cartId)
|
|
168
|
+
setCart(result.value.cart)
|
|
169
|
+
return result.value.cart
|
|
170
|
+
} finally {
|
|
171
|
+
mutationInFlight.current = false
|
|
172
|
+
setMutation(null)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const setItemQuantity = async (itemId: string, quantity: number) => {
|
|
177
|
+
if (!cartId) {
|
|
178
|
+
setError('No hay un carrito activo.')
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (mutationInFlight.current) {
|
|
182
|
+
setError('El carrito se está actualizando. Inténtalo nuevamente.')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
mutationInFlight.current = true
|
|
187
|
+
setMutation({ kind: 'updating', itemId })
|
|
188
|
+
setError(undefined)
|
|
189
|
+
try {
|
|
190
|
+
let result
|
|
191
|
+
try {
|
|
192
|
+
result = await serverApi.setCartItemQuantity({
|
|
193
|
+
cartId,
|
|
194
|
+
cartItemId: itemId,
|
|
195
|
+
quantity,
|
|
196
|
+
})
|
|
197
|
+
} catch {
|
|
198
|
+
setError('No pudimos actualizar el carrito. Inténtalo nuevamente.')
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
if (result.error.kind === 'cart-unavailable') clear()
|
|
204
|
+
setError(result.error.message)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setCart(result.value)
|
|
209
|
+
} finally {
|
|
210
|
+
mutationInFlight.current = false
|
|
211
|
+
setMutation(null)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const removeItem = async (itemId: string) => {
|
|
216
|
+
if (!cartId) {
|
|
217
|
+
setError('No hay un carrito activo.')
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (mutationInFlight.current) {
|
|
221
|
+
setError('El carrito se está actualizando. Inténtalo nuevamente.')
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
mutationInFlight.current = true
|
|
226
|
+
setMutation({ kind: 'removing', itemId })
|
|
227
|
+
setError(undefined)
|
|
228
|
+
try {
|
|
229
|
+
let result
|
|
230
|
+
try {
|
|
231
|
+
result = await serverApi.removeCartItem({
|
|
232
|
+
cartId,
|
|
233
|
+
cartItemId: itemId,
|
|
234
|
+
})
|
|
235
|
+
} catch {
|
|
236
|
+
setError('No pudimos actualizar el carrito. Inténtalo nuevamente.')
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!result.ok) {
|
|
241
|
+
if (result.error.kind === 'cart-unavailable') clear()
|
|
242
|
+
setError(result.error.message)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setCart(result.value)
|
|
247
|
+
} finally {
|
|
248
|
+
mutationInFlight.current = false
|
|
249
|
+
setMutation(null)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const value: CartContextValue = {
|
|
254
|
+
cart,
|
|
255
|
+
cartId,
|
|
256
|
+
itemCount: cart?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
|
|
257
|
+
loadStatus,
|
|
258
|
+
mutation,
|
|
259
|
+
error,
|
|
260
|
+
addItem,
|
|
261
|
+
setItemQuantity,
|
|
262
|
+
removeItem,
|
|
263
|
+
reload,
|
|
264
|
+
clearCart: clear,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Acceso al carrito. Requiere `<CartProvider>` en el layout. */
|
|
271
|
+
export function useCart() {
|
|
272
|
+
const value = useContext(CartContext)
|
|
273
|
+
if (!value) throw new Error('useCart debe usarse dentro de CartProvider')
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider + hooks públicos de Ecomiq (settings, config, queries).
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
createElement,
|
|
7
|
+
useContext,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { hashKey, keepPreviousData, useQuery } from '@tanstack/react-query'
|
|
11
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
12
|
+
import {
|
|
13
|
+
getMenuItems,
|
|
14
|
+
type StorefrontMenuItem,
|
|
15
|
+
type StorefrontSettings,
|
|
16
|
+
} from '@ecomiq/core'
|
|
17
|
+
import type { PublicEcomiqConfig } from '../public-config'
|
|
18
|
+
|
|
19
|
+
export interface ApiQueryOptions<T> {
|
|
20
|
+
/** Datos iniciales (ej. los del loader SSR) para render inmediato. */
|
|
21
|
+
initialData?: T
|
|
22
|
+
/** Tiempo en ms que los datos se consideran frescos. Default 30s. */
|
|
23
|
+
freshForMs?: number
|
|
24
|
+
/** Habilita/deshabilita la consulta. */
|
|
25
|
+
enabled?: boolean
|
|
26
|
+
/** Reintentos ante error. Default 1. */
|
|
27
|
+
retry?: number
|
|
28
|
+
keepPreviousData?: boolean
|
|
29
|
+
seed?: { key: readonly unknown[]; data: T }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ApiQueryResult<T> {
|
|
33
|
+
data: T | undefined
|
|
34
|
+
isLoading: boolean
|
|
35
|
+
isRefreshing: boolean
|
|
36
|
+
error: Error | null
|
|
37
|
+
refresh: () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Consulta datos en el cliente con caché y deduplicación automáticas.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* const { data, isLoading } = useApiQuery(['products'], () => api.get('/products', schema))
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useApiQuery<T>(
|
|
48
|
+
key: readonly unknown[],
|
|
49
|
+
fetcher: (context: { signal: AbortSignal }) => Promise<T>,
|
|
50
|
+
options: ApiQueryOptions<T> = {},
|
|
51
|
+
): ApiQueryResult<T> {
|
|
52
|
+
let initialData = options.initialData
|
|
53
|
+
if (options.seed && hashKey(options.seed.key) === hashKey(key)) {
|
|
54
|
+
initialData = options.seed.data
|
|
55
|
+
}
|
|
56
|
+
const query = useQuery({
|
|
57
|
+
queryKey: key,
|
|
58
|
+
queryFn: ({ signal }) => fetcher({ signal }),
|
|
59
|
+
initialData,
|
|
60
|
+
placeholderData: options.keepPreviousData ? keepPreviousData : undefined,
|
|
61
|
+
staleTime: options.freshForMs ?? 30_000,
|
|
62
|
+
enabled: options.enabled,
|
|
63
|
+
retry: options.retry ?? 1,
|
|
64
|
+
})
|
|
65
|
+
return {
|
|
66
|
+
data: query.data,
|
|
67
|
+
isLoading: query.isPending,
|
|
68
|
+
isRefreshing: query.isFetching && !query.isPending,
|
|
69
|
+
error: query.error,
|
|
70
|
+
refresh: () => void query.refetch(),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Valor único del shell: config local + settings storefront. */
|
|
75
|
+
export interface AppEcomiqValue {
|
|
76
|
+
config: PublicEcomiqConfig
|
|
77
|
+
settings: StorefrontSettings
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Contexto único de la app Ecomiq (lo provee el root generado). */
|
|
81
|
+
export const AppEcomiqContext = createContext<AppEcomiqValue | null>(null)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Provider del shell storefront (config + settings).
|
|
85
|
+
*
|
|
86
|
+
* ```tsx
|
|
87
|
+
* <AppEcomiqProvider config={publicConfig} settings={settings}>
|
|
88
|
+
* <App />
|
|
89
|
+
* </AppEcomiqProvider>
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function AppEcomiqProvider({
|
|
93
|
+
config,
|
|
94
|
+
settings,
|
|
95
|
+
children,
|
|
96
|
+
}: AppEcomiqValue & { children: ReactNode }) {
|
|
97
|
+
return createElement(
|
|
98
|
+
AppEcomiqContext.Provider,
|
|
99
|
+
{ value: { config, settings } },
|
|
100
|
+
children,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function useAppEcomiq(): AppEcomiqValue {
|
|
105
|
+
const value = useContext(AppEcomiqContext)
|
|
106
|
+
if (!value) {
|
|
107
|
+
throw new Error('Hooks de Ecomiq deben usarse dentro de AppEcomiqProvider')
|
|
108
|
+
}
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Acceso al ecomiq.config.js resuelto. */
|
|
113
|
+
export function useAppConfig(): PublicEcomiqConfig {
|
|
114
|
+
return useAppEcomiq().config
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Acceso a /v1/settings (CMS, store, menus). */
|
|
118
|
+
export function useSettings(): StorefrontSettings {
|
|
119
|
+
return useAppEcomiq().settings
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Items de un menú CMS (`main`, `footer`, `account`, …). */
|
|
123
|
+
export function useMenu(key: string): StorefrontMenuItem[] {
|
|
124
|
+
return getMenuItems(useSettings(), key)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Navegación cliente a una ruta con query params (sin full page reload).
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const go = useSearchNavigate()
|
|
132
|
+
* go('/products', { sort: 'price_asc', page: 1 })
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function useSearchNavigate() {
|
|
136
|
+
return useNavigate()
|
|
137
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
AppEcomiqContext,
|
|
3
|
+
AppEcomiqProvider,
|
|
4
|
+
useApiQuery,
|
|
5
|
+
useAppConfig,
|
|
6
|
+
useSettings,
|
|
7
|
+
useMenu,
|
|
8
|
+
useSearchNavigate,
|
|
9
|
+
type AppEcomiqValue,
|
|
10
|
+
type ApiQueryOptions,
|
|
11
|
+
type ApiQueryResult,
|
|
12
|
+
} from './ecomiq'
|
|
13
|
+
export {
|
|
14
|
+
CartProvider,
|
|
15
|
+
useCart,
|
|
16
|
+
type CartContextValue,
|
|
17
|
+
type CartLoadStatus,
|
|
18
|
+
type CartMutation,
|
|
19
|
+
} from './cart'
|
|
20
|
+
export {
|
|
21
|
+
AuthProvider,
|
|
22
|
+
createStorefrontAuthClient,
|
|
23
|
+
useAuth,
|
|
24
|
+
type StorefrontAuthClientInstance,
|
|
25
|
+
type StorefrontSession,
|
|
26
|
+
} from './auth'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface EcomiqThemeConfig {
|
|
2
|
+
primary?: string
|
|
3
|
+
primaryForeground?: string
|
|
4
|
+
background?: string
|
|
5
|
+
foreground?: string
|
|
6
|
+
accent?: string
|
|
7
|
+
radius?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EcomiqSeoConfig {
|
|
11
|
+
titleTemplate?: string
|
|
12
|
+
keywords?: string[]
|
|
13
|
+
favicon?: string
|
|
14
|
+
ogImage?: string
|
|
15
|
+
twitterCard?: 'summary' | 'summary_large_image'
|
|
16
|
+
meta?: Array<{ name?: string; property?: string; content: string }>
|
|
17
|
+
links?: Array<Record<string, string>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EcomiqAnalyticsConfig {
|
|
21
|
+
googleAnalytics?: string
|
|
22
|
+
metaPixel?: string
|
|
23
|
+
plausible?: string
|
|
24
|
+
scripts?: Array<{ src?: string; inline?: string; defer?: boolean; async?: boolean }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EcomiqLayoutConfig {
|
|
28
|
+
nav?: Array<{ label: string; to: string }>
|
|
29
|
+
footerText?: string
|
|
30
|
+
stickyHeader?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PublicEcomiqConfig {
|
|
34
|
+
name: string
|
|
35
|
+
/** Identidad de tienda para x-Store-Domain (ej. 'marketplace'). */
|
|
36
|
+
slug: string
|
|
37
|
+
description?: string
|
|
38
|
+
theme?: EcomiqThemeConfig
|
|
39
|
+
seo?: EcomiqSeoConfig
|
|
40
|
+
analytics?: EcomiqAnalyticsConfig
|
|
41
|
+
layout?: EcomiqLayoutConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type EcomiqConfig = PublicEcomiqConfig
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateProductReviewParams,
|
|
3
|
+
StorefrontReview,
|
|
4
|
+
} from '@ecomiq/core'
|
|
5
|
+
import { ApiError } from '@ecomiq/core'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
const ReviewProblemSchema = z.object({
|
|
9
|
+
code: z.string().max(120).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
interface ProductReviewClient {
|
|
13
|
+
createProductReview(
|
|
14
|
+
params: CreateProductReviewParams,
|
|
15
|
+
): Promise<StorefrontReview>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProductReviewSubmissionError {
|
|
19
|
+
status: 400 | 409 | 429 | 500
|
|
20
|
+
code?: string
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ProductReviewSubmissionResult =
|
|
25
|
+
| {
|
|
26
|
+
ok: true
|
|
27
|
+
review: StorefrontReview
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
ok: false
|
|
31
|
+
error: ProductReviewSubmissionError
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function submitProductReview(
|
|
35
|
+
client: ProductReviewClient,
|
|
36
|
+
input: CreateProductReviewParams,
|
|
37
|
+
): Promise<ProductReviewSubmissionResult> {
|
|
38
|
+
try {
|
|
39
|
+
const review = await client.createProductReview(input)
|
|
40
|
+
return { ok: true, review }
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!(error instanceof ApiError)) throw error
|
|
43
|
+
|
|
44
|
+
let code: string | undefined
|
|
45
|
+
const problem = ReviewProblemSchema.safeParse(error.body)
|
|
46
|
+
if (problem.success) code = problem.data.code
|
|
47
|
+
|
|
48
|
+
if (error.status === 400) {
|
|
49
|
+
let message = 'Revisa los datos de la reseña e inténtalo nuevamente.'
|
|
50
|
+
if (
|
|
51
|
+
code === 'ProductReview.OrderInvalid' ||
|
|
52
|
+
code === 'ProductReview.OrderOwnerMismatch' ||
|
|
53
|
+
code === 'ProductReview.ProductNotPurchased'
|
|
54
|
+
) {
|
|
55
|
+
message = 'No pudimos verificar la compra con los datos ingresados.'
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, error: { status: 400, code, message } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (error.status === 409) {
|
|
61
|
+
let message = 'No pudimos registrar esta reseña porque ya existe una similar.'
|
|
62
|
+
if (code === 'ProductReview.AlreadyExists') {
|
|
63
|
+
message = 'Ya enviaste una reseña para este producto.'
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, error: { status: 409, code, message } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (error.status === 429) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: {
|
|
72
|
+
status: 429,
|
|
73
|
+
code,
|
|
74
|
+
message:
|
|
75
|
+
'Enviaste varias reseñas en poco tiempo. Espera un momento e inténtalo nuevamente.',
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: {
|
|
83
|
+
status: 500,
|
|
84
|
+
code,
|
|
85
|
+
message: 'No pudimos enviar la reseña. Inténtalo nuevamente.',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AppEcomiqContext, AppEcomiqProvider, type AppEcomiqValue } from '../providers/ecomiq'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout por defecto de Ecomiq.
|
|
3
|
+
* Nav y footer salen de settings.cms.menus (main / footer).
|
|
4
|
+
* Fallback: bloque `layout` de ecomiq.config.js.
|
|
5
|
+
*/
|
|
6
|
+
import { Link } from '@tanstack/react-router'
|
|
7
|
+
import { getMenuItems } from '@ecomiq/core'
|
|
8
|
+
import type { LayoutProps } from '../index'
|
|
9
|
+
|
|
10
|
+
export default function DefaultLayout({ children, config, settings }: LayoutProps) {
|
|
11
|
+
const layout = (config.layout ?? {}) as {
|
|
12
|
+
nav?: Array<{ label: string; to: string }>
|
|
13
|
+
footerText?: string
|
|
14
|
+
stickyHeader?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const mainItems = getMenuItems(settings, 'main')
|
|
18
|
+
const footerItems = getMenuItems(settings, 'footer')
|
|
19
|
+
const nav =
|
|
20
|
+
mainItems.length > 0
|
|
21
|
+
? mainItems.map((item) => ({ label: item.label, to: item.url, target: item.target }))
|
|
22
|
+
: (layout.nav ?? [{ label: 'Productos', to: '/' }]).map((item) => ({
|
|
23
|
+
...item,
|
|
24
|
+
target: null as string | null | undefined,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const sticky = layout.stickyHeader !== false
|
|
28
|
+
const storeName = settings?.store.name ?? config.name
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex min-h-dvh flex-col">
|
|
32
|
+
<header
|
|
33
|
+
className={
|
|
34
|
+
'border-b bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/75 z-40' +
|
|
35
|
+
(sticky ? ' sticky top-0' : '')
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between px-4">
|
|
39
|
+
<Link to="/" className="font-semibold tracking-tight text-lg">
|
|
40
|
+
{storeName}
|
|
41
|
+
</Link>
|
|
42
|
+
<nav className="flex items-center gap-5 text-sm text-muted-foreground">
|
|
43
|
+
{nav.map((item) =>
|
|
44
|
+
item.target === '_blank' ? (
|
|
45
|
+
<a
|
|
46
|
+
key={item.to}
|
|
47
|
+
href={item.to}
|
|
48
|
+
target="_blank"
|
|
49
|
+
rel="noreferrer"
|
|
50
|
+
className="transition-colors hover:text-foreground"
|
|
51
|
+
>
|
|
52
|
+
{item.label}
|
|
53
|
+
</a>
|
|
54
|
+
) : (
|
|
55
|
+
<Link key={item.to} to={item.to} className="transition-colors hover:text-foreground">
|
|
56
|
+
{item.label}
|
|
57
|
+
</Link>
|
|
58
|
+
),
|
|
59
|
+
)}
|
|
60
|
+
</nav>
|
|
61
|
+
</div>
|
|
62
|
+
</header>
|
|
63
|
+
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">{children}</main>
|
|
64
|
+
<footer className="border-t">
|
|
65
|
+
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
66
|
+
<p>{layout.footerText ?? `${storeName} — creado con Ecomiq`}</p>
|
|
67
|
+
{footerItems.length > 0 && (
|
|
68
|
+
<nav className="flex flex-wrap gap-4">
|
|
69
|
+
{footerItems.map((item) => (
|
|
70
|
+
<Link key={item.url} to={item.url} className="transition-colors hover:text-foreground">
|
|
71
|
+
{item.label}
|
|
72
|
+
</Link>
|
|
73
|
+
))}
|
|
74
|
+
</nav>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</footer>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|