@ecomiq/storefront 1.1.9 → 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 -418
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -94
  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-64GLOWMJ.js +0 -216
  67. package/dist/chunk-64GLOWMJ.js.map +0 -1
  68. package/dist/chunk-AKD5IDEB.js +0 -177
  69. package/dist/chunk-AKD5IDEB.js.map +0 -1
  70. package/dist/chunk-EFEVJJWF.js +0 -1345
  71. package/dist/chunk-EFEVJJWF.js.map +0 -1
  72. package/dist/chunk-NAJQB5DN.js +0 -155
  73. package/dist/chunk-NAJQB5DN.js.map +0 -1
  74. package/dist/chunk-OABDGTBI.js +0 -253
  75. package/dist/chunk-OABDGTBI.js.map +0 -1
  76. package/dist/chunk-Q4U5OEUT.js +0 -21
  77. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  78. package/dist/chunk-S7CPZ4UE.js +0 -6
  79. package/dist/chunk-S7CPZ4UE.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 -10759
  83. package/dist/chunks/client.js +0 -5398
  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 -59
  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 -40
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-DU7gREXS.d.ts +0 -4062
  98. package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
  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 -18
  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 -1
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -18
  133. package/dist/tanstack.server.js +0 -1
  134. 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
+ }