@ecomiq/storefront 2.0.19 → 2.0.21

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 CHANGED
@@ -22,7 +22,9 @@ Peer deps: `react` y `react-dom` ^19.
22
22
  import { defineConfig } from '@ecomiq/storefront/config'
23
23
 
24
24
  export default defineConfig({
25
- store: { slug: 'mi-tienda' },
25
+ domain: 'mi-tienda',
26
+ // Server-only. Obligatorio en producción para usar una publicación de Cloud.
27
+ cloud: { baseUrl: 'https://cloud.internal.example' },
26
28
  })
27
29
  ```
28
30
 
@@ -31,10 +33,7 @@ export default defineConfig({
31
33
  import { definePage, serverApi } from '@ecomiq/storefront'
32
34
 
33
35
  export default definePage({
34
- loader: async () => {
35
- const products = await serverApi.products.list()
36
- return { products }
37
- },
36
+ loader: () => serverApi.getProducts({ page: 1, pageSize: 12 }),
38
37
  component: ({ data }) => <pre>{JSON.stringify(data.products, null, 2)}</pre>,
39
38
  })
40
39
  ```
@@ -64,4 +63,71 @@ dev: { port: 3000, runtime: 'workerd' },
64
63
 
65
64
  `ecomiq build` / `deploy` siempre empaquetan para Cloudflare Workers.
66
65
 
66
+ Durante `ecomiq dev`, si `cloud.baseUrl` se omite, el framework conecta a
67
+ `http://127.0.0.1:8020`. En producción no se inventa un hostname: si falta la
68
+ URL o Cloud no tiene una publicación activa, el template conserva su home
69
+ clásica.
70
+
67
71
  Ver el monorepo [ecomiq-storefront](https://github.com/ecomiq/ecomiq-storefront) para la app de referencia y más documentación.
72
+
73
+ ## Storefront declarativo
74
+
75
+ `StorefrontDefinition v1` permite construir y versionar una tienda sin generar
76
+ código React arbitrario. El contrato valida tema, navegación, footer y ocho
77
+ tipos de sección: `hero`, `product-grid`, `collection-grid`, `promo-banner`,
78
+ `image-text`, `rich-text`, `testimonials` y `newsletter`.
79
+
80
+ ```tsx
81
+ import {
82
+ StorefrontDefinitionRenderer,
83
+ createDefaultStorefrontDefinition,
84
+ } from '@ecomiq/storefront'
85
+
86
+ const definition = createDefaultStorefrontDefinition({
87
+ storeName: 'Casa Lumbre',
88
+ description: 'Cerámica producida localmente.',
89
+ })
90
+
91
+ export function Home() {
92
+ return (
93
+ <StorefrontDefinitionRenderer
94
+ definition={definition}
95
+ locale="es-PE"
96
+ currency="PEN"
97
+ data={{
98
+ productsBySectionId: {
99
+ 'featured-products': [],
100
+ },
101
+ collectionsBySectionId: {
102
+ 'featured-collections': [],
103
+ },
104
+ }}
105
+ />
106
+ )
107
+ }
108
+ ```
109
+
110
+ Los sistemas que solo necesitan validar o modificar el contrato pueden importar
111
+ el subpath sin cargar el renderer:
112
+
113
+ ```ts
114
+ import {
115
+ StorefrontDefinitionSchema,
116
+ StorefrontDefinitionPatchSchema,
117
+ applyStorefrontDefinitionPatch,
118
+ type StorefrontDefinitionInput,
119
+ type StorefrontDefinitionPatchInput,
120
+ } from '@ecomiq/storefront/definition'
121
+ ```
122
+
123
+ Cada definición lleva `version` y `revision`. Los patches son puros, exigen
124
+ `expectedRevision` y conservan el ID de una sección al reemplazarla. Soportan
125
+ `section.add`, `section.replace`, `section.remove`, `section.reorder`,
126
+ `theme.replace`, `navigation.replace` y `footer.replace`.
127
+
128
+ El template productivo consulta server-side
129
+ `GET /v1/runtime/storefronts/:storeSlug`, revalida la publicación mediante
130
+ ETag y resuelve los `product-grid.source` y `collection-grid.source` contra el
131
+ API storefront. La publicación declarativa reemplaza solo el contenido de la
132
+ home; el layout comercial y las rutas de catálogo, producto, carrito, checkout
133
+ y cuenta siguen siendo las del framework.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomiq/storefront",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "Micro framework SSR de Ecomiq para storefronts. Páginas, datos y deploy sin configurar nada.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,8 @@
32
32
  "default": "./src/config.mjs"
33
33
  },
34
34
  "./ui": "./src/ui/index.ts",
35
+ "./definition": "./src/storefront-definition/contract.ts",
36
+ "./definition/react": "./src/storefront-definition/index.ts",
35
37
  "./styles.css": "./src/styles.css",
36
38
  "./internal/page": "./src/runtime/page.tsx",
37
39
  "./internal/context": "./src/runtime/context.ts",
@@ -44,6 +46,8 @@
44
46
  "./internal/thank-you": "./src/pages/thank-you.tsx",
45
47
  "./internal/cms-page": "./src/pages/pages.$pageSlug.tsx",
46
48
  "./internal/account": "./src/pages/account.tsx",
49
+ "./internal/api-proxy": "./src/pages/api.$.ts",
50
+ "./internal/auth-api": "./src/pages/api.auth.$.ts",
47
51
  "./package.json": "./package.json"
48
52
  },
49
53
  "files": [
@@ -80,7 +84,8 @@
80
84
  "vite": "^8.1.0",
81
85
  "wrangler": "^4.100.0",
82
86
  "zod": "^3.25.0",
83
- "@ecomiq/core": "^0.1.6"
87
+ "@ecomiq/core": "^0.1.7",
88
+ "@ecomiq/storefront-definition": "^1.0.0"
84
89
  },
85
90
  "peerDependencies": {
86
91
  "react": "^19.0.0",
@@ -2,7 +2,6 @@ import { z } from "zod";
2
2
 
3
3
  export const customerLoginCustomerSchema = z.object({
4
4
  id: z.coerce.number(),
5
- publicId: z.union([z.coerce.number(), z.string()]),
6
5
  status: z.string(),
7
6
  isVerified: z.boolean(),
8
7
  });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Instancia Better Auth del storefront (servidor).
3
+ * Singleton usado por la ruta cerrada `/api/auth/$`.
4
+ */
5
+ import { createStorefrontAuth } from './create-storefront-auth'
6
+
7
+ export const storefrontAuth = createStorefrontAuth()
@@ -0,0 +1,218 @@
1
+ import { z } from 'zod'
2
+ import { StorefrontDefinitionSchema } from '@ecomiq/storefront-definition'
3
+
4
+ export const CloudRuntimeStoreSlugSchema = z
5
+ .string()
6
+ .trim()
7
+ .min(2)
8
+ .max(100)
9
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
10
+
11
+ export const ActiveStorefrontRuntimeSchema = z
12
+ .object({
13
+ schemaVersion: z.literal(1),
14
+ store: z
15
+ .object({
16
+ slug: CloudRuntimeStoreSlugSchema,
17
+ })
18
+ .strict(),
19
+ environment: z
20
+ .object({
21
+ name: z.literal('production'),
22
+ status: z.literal('ready'),
23
+ })
24
+ .strict(),
25
+ publication: z
26
+ .object({
27
+ sequence: z.number().int().nonnegative(),
28
+ releaseSequence: z.number().int().nonnegative(),
29
+ publishedAt: z.string().datetime({ offset: true }),
30
+ })
31
+ .strict(),
32
+ definition: StorefrontDefinitionSchema,
33
+ })
34
+ .strict()
35
+
36
+ export type ActiveStorefrontRuntime = z.infer<
37
+ typeof ActiveStorefrontRuntimeSchema
38
+ >
39
+
40
+ export type CloudRuntimeFetch = (
41
+ input: RequestInfo | URL,
42
+ init?: RequestInit,
43
+ ) => Promise<Response>
44
+
45
+ export interface CloudRuntimeClientOptions {
46
+ baseUrl: string
47
+ timeoutMs?: number
48
+ cacheTtlMs?: number
49
+ fetch?: CloudRuntimeFetch
50
+ now?: () => number
51
+ }
52
+
53
+ interface RuntimeCacheEntry {
54
+ value: ActiveStorefrontRuntime | null
55
+ etag?: string
56
+ expiresAt: number
57
+ }
58
+
59
+ export class CloudRuntimeResponseError extends Error {
60
+ readonly status: number
61
+
62
+ constructor(status: number) {
63
+ super(`Cloud runtime respondió HTTP ${status}`)
64
+ this.name = 'CloudRuntimeResponseError'
65
+ this.status = status
66
+ }
67
+ }
68
+
69
+ const DEFAULT_TIMEOUT_MS = 3_000
70
+ const DEFAULT_CACHE_TTL_MS = 15_000
71
+ const MAX_CACHE_ENTRIES = 100
72
+
73
+ export class CloudRuntimeClient {
74
+ readonly baseUrl: string
75
+ readonly timeoutMs: number
76
+ readonly cacheTtlMs: number
77
+
78
+ private readonly fetchRuntime: CloudRuntimeFetch
79
+ private readonly now: () => number
80
+ private readonly cache = new Map<string, RuntimeCacheEntry>()
81
+ private readonly inFlight = new Map<
82
+ string,
83
+ Promise<ActiveStorefrontRuntime | null>
84
+ >()
85
+
86
+ constructor(options: CloudRuntimeClientOptions) {
87
+ this.baseUrl = new URL(options.baseUrl).toString().replace(/\/$/, '')
88
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
89
+ this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
90
+ this.fetchRuntime = options.fetch ?? globalThis.fetch
91
+ this.now = options.now ?? Date.now
92
+ }
93
+
94
+ async resolveActiveStorefront(
95
+ storeSlugInput: string,
96
+ ): Promise<ActiveStorefrontRuntime | null> {
97
+ const storeSlug = CloudRuntimeStoreSlugSchema.parse(storeSlugInput)
98
+ const cached = this.cache.get(storeSlug)
99
+ if (cached && cached.expiresAt > this.now()) {
100
+ return cached.value
101
+ }
102
+
103
+ const activeRequest = this.inFlight.get(storeSlug)
104
+ if (activeRequest) return activeRequest
105
+
106
+ const request = this.requestActiveStorefront(storeSlug, cached).finally(
107
+ () => {
108
+ this.inFlight.delete(storeSlug)
109
+ },
110
+ )
111
+ this.inFlight.set(storeSlug, request)
112
+ return request
113
+ }
114
+
115
+ clearCache(): void {
116
+ this.cache.clear()
117
+ this.inFlight.clear()
118
+ }
119
+
120
+ private setCache(storeSlug: string, entry: RuntimeCacheEntry): void {
121
+ if (!this.cache.has(storeSlug) && this.cache.size >= MAX_CACHE_ENTRIES) {
122
+ const oldestKey = this.cache.keys().next().value
123
+ if (oldestKey !== undefined) this.cache.delete(oldestKey)
124
+ }
125
+ this.cache.set(storeSlug, entry)
126
+ }
127
+
128
+ private async requestActiveStorefront(
129
+ storeSlug: string,
130
+ cached?: RuntimeCacheEntry,
131
+ ): Promise<ActiveStorefrontRuntime | null> {
132
+ const controller = new AbortController()
133
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs)
134
+ const headers = new Headers({ accept: 'application/json' })
135
+ if (cached?.etag) headers.set('if-none-match', cached.etag)
136
+
137
+ try {
138
+ const response = await this.fetchRuntime(
139
+ new URL(
140
+ `/v1/runtime/storefronts/${encodeURIComponent(storeSlug)}`,
141
+ this.baseUrl,
142
+ ),
143
+ {
144
+ method: 'GET',
145
+ headers,
146
+ signal: controller.signal,
147
+ },
148
+ )
149
+
150
+ if (response.status === 304) {
151
+ if (!cached) throw new CloudRuntimeResponseError(304)
152
+ this.setCache(storeSlug, {
153
+ ...cached,
154
+ expiresAt: this.now() + this.cacheTtlMs,
155
+ })
156
+ return cached.value
157
+ }
158
+
159
+ if (response.status === 404) {
160
+ this.setCache(storeSlug, {
161
+ value: null,
162
+ etag: response.headers.get('etag') ?? undefined,
163
+ expiresAt: this.now() + this.cacheTtlMs,
164
+ })
165
+ return null
166
+ }
167
+
168
+ if (!response.ok) {
169
+ throw new CloudRuntimeResponseError(response.status)
170
+ }
171
+
172
+ const runtime = ActiveStorefrontRuntimeSchema.parse(
173
+ await response.json(),
174
+ )
175
+ if (runtime.store.slug !== storeSlug) {
176
+ throw new Error(
177
+ `Cloud runtime devolvió el store slug ${runtime.store.slug} para ${storeSlug}`,
178
+ )
179
+ }
180
+
181
+ this.setCache(storeSlug, {
182
+ value: runtime,
183
+ etag: response.headers.get('etag') ?? undefined,
184
+ expiresAt: this.now() + this.cacheTtlMs,
185
+ })
186
+ return runtime
187
+ } finally {
188
+ clearTimeout(timeout)
189
+ }
190
+ }
191
+ }
192
+
193
+ const sharedClients = new Map<string, CloudRuntimeClient>()
194
+
195
+ export function getCloudRuntimeClient(
196
+ options: Omit<CloudRuntimeClientOptions, 'fetch' | 'now'>,
197
+ ): CloudRuntimeClient {
198
+ const key = [
199
+ new URL(options.baseUrl).toString(),
200
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
201
+ options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
202
+ ].join('|')
203
+ const current = sharedClients.get(key)
204
+ if (current) return current
205
+
206
+ const client = new CloudRuntimeClient(options)
207
+ if (sharedClients.size >= 8) {
208
+ const oldestKey = sharedClients.keys().next().value
209
+ if (oldestKey !== undefined) sharedClients.delete(oldestKey)
210
+ }
211
+ sharedClients.set(key, client)
212
+ return client
213
+ }
214
+
215
+ export function resetCloudRuntimeClients(): void {
216
+ for (const client of sharedClients.values()) client.clearCache()
217
+ sharedClients.clear()
218
+ }
package/src/codegen.mjs CHANGED
@@ -17,6 +17,8 @@
17
17
  * /checkout/$checkoutId ← flujo de checkout
18
18
  * /thank-you ← post-compra
19
19
  * /account/* ← área de cuenta (login/register en template: /login, /register)
20
+ * /api/$ ← proxy BFF same-origin
21
+ * /api/auth/$ ← Better Auth (handlers del framework)
20
22
  */
21
23
  import {
22
24
  mkdirSync,
@@ -90,6 +92,20 @@ const FRAMEWORK_LOCKED_PAGES = [
90
92
  },
91
93
  ]
92
94
 
95
+ /** Server routes cerradas del framework (proxy BFF, Better Auth, etc.). */
96
+ const FRAMEWORK_LOCKED_API_ROUTES = [
97
+ {
98
+ rel: 'api/$.ts',
99
+ routeId: '/api/$',
100
+ importFrom: '@ecomiq/storefront/internal/api-proxy',
101
+ },
102
+ {
103
+ rel: 'api/auth.$.ts',
104
+ routeId: '/api/auth/$',
105
+ importFrom: '@ecomiq/storefront/internal/auth-api',
106
+ },
107
+ ]
108
+
93
109
  export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
94
110
  const pagesDir = resolve(cwd, 'pages')
95
111
  if (!existsSync(pagesDir)) {
@@ -136,7 +152,7 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
136
152
  }
137
153
  if (config.theme !== undefined) publicConfig.theme = config.theme
138
154
 
139
- for (const serverOnlyKey of ['api', 'dev', 'deploy', 'env']) {
155
+ for (const serverOnlyKey of ['api', 'cloud', 'dev', 'deploy', 'env']) {
140
156
  if (Object.hasOwn(publicConfig, serverOnlyKey)) {
141
157
  throw new Error(`La clave server-only \`${serverOnlyKey}\` no puede serializarse en app-config.ts`)
142
158
  }
@@ -163,7 +179,16 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
163
179
  ` if (import.meta.env.DEV && config.dev?.apiBaseUrl) {\n` +
164
180
  ` api = { ...api, baseUrl: config.dev.apiBaseUrl }\n` +
165
181
  ` }\n` +
166
- ` registerServerConfig({ domain: config.domain, api })\n` +
182
+ ` let cloud = config.cloud\n` +
183
+ ` if (import.meta.env.DEV && !cloud?.baseUrl) {\n` +
184
+ ` cloud = {\n` +
185
+ ` timeoutMs: cloud?.timeoutMs ?? 3_000,\n` +
186
+ ` cacheTtlMs: cloud?.cacheTtlMs ?? 15_000,\n` +
187
+ ` ...cloud,\n` +
188
+ ` baseUrl: 'http://127.0.0.1:8020',\n` +
189
+ ` }\n` +
190
+ ` }\n` +
191
+ ` registerServerConfig({ domain: config.domain, api, cloud })\n` +
167
192
  ` const health = await handleEcomiqHealth(request)\n` +
168
193
  ` if (health) return health\n` +
169
194
  ` return startServer.fetch(request, ...rest)\n` +
@@ -209,11 +234,13 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
209
234
  }
210
235
 
211
236
  assertNoFrameworkPageOverrides(pageEntries)
237
+ assertNoFrameworkApiOverrides(apiEntries)
212
238
 
213
239
  const desiredRoutes = [
214
240
  ...pageEntries.map((entry) => entry.rel.replace(/\.jsx$/, '.tsx')),
215
241
  ...apiEntries.map((entry) => entry.rel),
216
242
  ...FRAMEWORK_LOCKED_PAGES.map((page) => page.rel),
243
+ ...FRAMEWORK_LOCKED_API_ROUTES.map((route) => route.rel),
217
244
  ].sort()
218
245
 
219
246
  for (const entry of pageEntries) {
@@ -244,6 +271,15 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
244
271
  )
245
272
  }
246
273
 
274
+ for (const locked of FRAMEWORK_LOCKED_API_ROUTES) {
275
+ const outFile = join(routesDir, locked.rel)
276
+ mkdirSync(dirname(outFile), { recursive: true })
277
+ writeGeneratedFile(
278
+ outFile,
279
+ apiRouteFileSource(locked.routeId, locked.importFrom),
280
+ )
281
+ }
282
+
247
283
  reconcileGeneratedRoutes(appDir, routesDir, desiredRoutes)
248
284
 
249
285
  // ── 9. wrangler.jsonc para deploy en Cloudflare Workers ──
@@ -521,6 +557,23 @@ function assertNoFrameworkPageOverrides(pageEntries) {
521
557
  }
522
558
  }
523
559
 
560
+ function assertNoFrameworkApiOverrides(apiEntries) {
561
+ const reserved = new Map(
562
+ FRAMEWORK_LOCKED_API_ROUTES.map((route) => [route.routeId, route.rel]),
563
+ )
564
+
565
+ for (const entry of apiEntries) {
566
+ const routeId = fileToRouteId(entry.rel)
567
+ if (!reserved.has(routeId)) continue
568
+ throw new Error(
569
+ `La ruta ${routeId} es reservada por Ecomiq. ` +
570
+ `Elimina pages/${entry.rel}; ya la genera @ecomiq/storefront ` +
571
+ `(proxy BFF /api/$ y Better Auth /api/auth/$). ` +
572
+ `Puedes añadir otras rutas en pages/api/.`,
573
+ )
574
+ }
575
+ }
576
+
524
577
  /** Server route: reexporta GET/POST/... del módulo del usuario. */
525
578
  function apiRouteFileSource(routeId, importPath) {
526
579
  return (
@@ -1,13 +1,6 @@
1
1
  import type { CartCheckoutResponse } from '@ecomiq/core'
2
2
 
3
- export function formatMoney(value: number, language: string, currency: string) {
4
- return new Intl.NumberFormat(language, {
5
- style: 'currency',
6
- currency,
7
- minimumFractionDigits: 2,
8
- maximumFractionDigits: 2,
9
- }).format(value)
10
- }
3
+ export { formatMoney } from '../../format-money'
11
4
 
12
5
  export function thankYouUrl(cartId: string, transactionId?: string) {
13
6
  const q = new URLSearchParams({ cartId })
package/src/config.d.ts CHANGED
@@ -7,6 +7,22 @@ export interface EcomiqApiConfig {
7
7
  timeoutMs?: number
8
8
  }
9
9
 
10
+ export interface EcomiqCloudConfig {
11
+ /**
12
+ * URL privada del plano de control. En producción debe configurarse de forma
13
+ * explícita; durante `ecomiq dev` el runtime usa http://127.0.0.1:8020.
14
+ */
15
+ baseUrl?: string
16
+ /**
17
+ * Slug canónico en Cloud. Si se omite, se deriva del primer label de
18
+ * `domain`.
19
+ */
20
+ storeSlug?: string
21
+ timeoutMs?: number
22
+ /** TTL del cache de la publicación activa. Default: 15 segundos. */
23
+ cacheTtlMs?: number
24
+ }
25
+
10
26
  export type EcomiqCloudflareRoute =
11
27
  | string
12
28
  | {
@@ -33,6 +49,8 @@ export interface EcomiqConfigInput {
33
49
  */
34
50
  domain: string
35
51
  api?: EcomiqApiConfig
52
+ /** Configuración server-only para resolver la publicación activa. */
53
+ cloud?: EcomiqCloudConfig
36
54
  theme?: EcomiqThemeConfig
37
55
  dev?: {
38
56
  port?: number
@@ -52,13 +70,20 @@ export interface EcomiqConfigInput {
52
70
  env?: Record<string, string>
53
71
  }
54
72
 
55
- export interface ResolvedEcomiqConfig extends Omit<EcomiqConfigInput, 'api'> {
73
+ export interface ResolvedEcomiqConfig
74
+ extends Omit<EcomiqConfigInput, 'api' | 'cloud'> {
56
75
  api: {
57
76
  baseUrl: string
58
77
  version: string
59
78
  headers?: Record<string, string>
60
79
  timeoutMs: number
61
80
  }
81
+ cloud?: {
82
+ baseUrl?: string
83
+ storeSlug?: string
84
+ timeoutMs: number
85
+ cacheTtlMs: number
86
+ }
62
87
  }
63
88
 
64
89
  export function parseConfig(
package/src/config.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * Se valida con Zod al arrancar la CLI para dar errores claros.
4
4
  *
5
5
  * Identidad de tienda, SEO, analytics y menús salen de `/v1/settings`.
6
- * Aquí solo queda lo operativo: domain, api, theme, dev, deploy, env.
6
+ * Aquí solo queda lo operativo: domain, api, cloud, theme, dev, deploy, env.
7
7
  */
8
8
  import { z } from 'zod'
9
9
 
@@ -50,6 +50,25 @@ const configSchema = z.object({
50
50
  timeoutMs: z.number().int().positive().default(10_000),
51
51
  })
52
52
  .default({}),
53
+ /**
54
+ * Plano de control server-only. `baseUrl` se exige operativamente en
55
+ * producción; si falta, el storefront degrada a la home clásica.
56
+ */
57
+ cloud: z
58
+ .object({
59
+ baseUrl: z.string().url().optional(),
60
+ storeSlug: z
61
+ .string()
62
+ .trim()
63
+ .min(2)
64
+ .max(100)
65
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
66
+ .optional(),
67
+ timeoutMs: z.number().int().positive().default(3_000),
68
+ cacheTtlMs: z.number().int().min(0).max(300_000).default(15_000),
69
+ })
70
+ .strict()
71
+ .optional(),
53
72
  /** Tokens de tema; sobreescriben las variables CSS del design system. */
54
73
  theme: z
55
74
  .object({
@@ -0,0 +1,31 @@
1
+ export type MoneyLocale = {
2
+ language: string
3
+ currency: string
4
+ }
5
+
6
+ /**
7
+ * Formatea un monto con `Intl.NumberFormat` (moneda ISO-4217).
8
+ *
9
+ * @example
10
+ * formatMoney(19.9, { language: 'es-PE', currency: 'PEN' })
11
+ * formatMoney(19.9, 'es-PE', 'PEN')
12
+ */
13
+ export function formatMoney(value: number, locale: MoneyLocale): string
14
+ export function formatMoney(value: number, language: string, currency: string): string
15
+ export function formatMoney(
16
+ value: number,
17
+ languageOrLocale: string | MoneyLocale,
18
+ currency?: string,
19
+ ): string {
20
+ const language =
21
+ typeof languageOrLocale === 'string' ? languageOrLocale : languageOrLocale.language
22
+ const currencyCode =
23
+ typeof languageOrLocale === 'string' ? currency! : languageOrLocale.currency
24
+
25
+ return new Intl.NumberFormat(language, {
26
+ style: 'currency',
27
+ currency: currencyCode,
28
+ minimumFractionDigits: 2,
29
+ maximumFractionDigits: 2,
30
+ }).format(value)
31
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,9 @@ export {
31
31
  DepartmentsSchema,
32
32
  DistrictSchema,
33
33
  DistrictsSchema,
34
+ EntityIdSchema,
35
+ NullableEntityIdSchema,
36
+ OptionalEntityIdSchema,
34
37
  GeoDivisionSchema,
35
38
  GeoDivisionTypeSchema,
36
39
  GetDepartmentsParamsSchema,
@@ -43,6 +46,7 @@ export {
43
46
  StorefrontMenuItemSchema,
44
47
  getMenuItems,
45
48
  } from '@ecomiq/core'
49
+ export type { EntityId } from '@ecomiq/core'
46
50
  export { getCountries, getDepartments, getDistricts, getProvinces } from '@ecomiq/core'
47
51
  export {
48
52
  AddCartItemParamsSchema,
@@ -235,9 +239,20 @@ export { ecomiqOAuthPasswordClient } from './auth/ecomiq-oauth-password-client'
235
239
  export { signInWithEcomiqCredentials } from './auth/ecomiq-storefront-sign-in'
236
240
  export type { EcomiqSignInCredentials } from './auth/ecomiq-storefront-sign-in'
237
241
  export * from './utils'
242
+ export { formatMoney, type MoneyLocale } from './format-money'
238
243
  export { Link } from './link'
239
244
  export { Await } from '@tanstack/react-router'
240
- export { Hero, findHeroSection, type HeroProps, type HeroSlide } from './components/hero'
245
+ export {
246
+ Hero,
247
+ findHeroSection,
248
+ type HeroProps,
249
+ type HeroSlide,
250
+ } from './components/hero'
251
+ export * from './storefront-definition'
252
+ export {
253
+ definitionForRoute,
254
+ useDefinition,
255
+ } from './storefront-layout'
241
256
  export { z } from 'zod'
242
257
 
243
258
  /**
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Proxy catch-all same-origin: `/api/*` → storefront `/v1/*`.
3
+ * Ruta cerrada del framework (no hace falta en pages/ del usuario).
4
+ */
5
+ import { proxyStorefrontRequest } from '../api-proxy'
6
+
7
+ type ApiHandlerArgs = {
8
+ request: Request
9
+ params: { _splat?: string }
10
+ }
11
+
12
+ async function handle({ request, params }: ApiHandlerArgs) {
13
+ return proxyStorefrontRequest(request, params._splat)
14
+ }
15
+
16
+ export const GET = handle
17
+ export const POST = handle
18
+ export const PUT = handle
19
+ export const PATCH = handle
20
+ export const DELETE = handle
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Better Auth catch-all: `/api/auth/*`.
3
+ * Ruta cerrada del framework (no hace falta en pages/ del usuario).
4
+ */
5
+ import { storefrontAuth } from '../auth/storefront-auth'
6
+
7
+ type ApiHandlerArgs = {
8
+ request: Request
9
+ }
10
+
11
+ async function handle({ request }: ApiHandlerArgs) {
12
+ return storefrontAuth.handler(request)
13
+ }
14
+
15
+ export const GET = handle
16
+ export const POST = handle