@ecomiq/storefront 2.0.20 → 2.0.22

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.20",
3
+ "version": "2.0.22",
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",
@@ -82,7 +84,8 @@
82
84
  "vite": "^8.1.0",
83
85
  "wrangler": "^4.100.0",
84
86
  "zod": "^3.25.0",
85
- "@ecomiq/core": "^0.1.7"
87
+ "@ecomiq/core": "^0.1.8",
88
+ "@ecomiq/storefront-definition": "^1.0.0"
86
89
  },
87
90
  "peerDependencies": {
88
91
  "react": "^19.0.0",
@@ -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
@@ -152,7 +152,7 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
152
152
  }
153
153
  if (config.theme !== undefined) publicConfig.theme = config.theme
154
154
 
155
- for (const serverOnlyKey of ['api', 'dev', 'deploy', 'env']) {
155
+ for (const serverOnlyKey of ['api', 'cloud', 'dev', 'deploy', 'env']) {
156
156
  if (Object.hasOwn(publicConfig, serverOnlyKey)) {
157
157
  throw new Error(`La clave server-only \`${serverOnlyKey}\` no puede serializarse en app-config.ts`)
158
158
  }
@@ -179,7 +179,16 @@ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
179
179
  ` if (import.meta.env.DEV && config.dev?.apiBaseUrl) {\n` +
180
180
  ` api = { ...api, baseUrl: config.dev.apiBaseUrl }\n` +
181
181
  ` }\n` +
182
- ` 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` +
183
192
  ` const health = await handleEcomiqHealth(request)\n` +
184
193
  ` if (health) return health\n` +
185
194
  ` return startServer.fetch(request, ...rest)\n` +
@@ -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
@@ -239,9 +239,20 @@ export { ecomiqOAuthPasswordClient } from './auth/ecomiq-oauth-password-client'
239
239
  export { signInWithEcomiqCredentials } from './auth/ecomiq-storefront-sign-in'
240
240
  export type { EcomiqSignInCredentials } from './auth/ecomiq-storefront-sign-in'
241
241
  export * from './utils'
242
+ export { formatMoney, type MoneyLocale } from './format-money'
242
243
  export { Link } from './link'
243
244
  export { Await } from '@tanstack/react-router'
244
- 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'
245
256
  export { z } from 'zod'
246
257
 
247
258
  /**
@@ -1,9 +1,17 @@
1
1
  import type { CreateApiOptions } from '@ecomiq/core'
2
2
  import { createServerOnlyFn } from '@tanstack/react-start'
3
3
 
4
+ export interface ServerEcomiqCloudConfig {
5
+ baseUrl?: string
6
+ storeSlug?: string
7
+ timeoutMs?: number
8
+ cacheTtlMs?: number
9
+ }
10
+
4
11
  export interface ServerEcomiqConfig {
5
12
  domain: string
6
13
  api?: CreateApiOptions
14
+ cloud?: ServerEcomiqCloudConfig
7
15
  }
8
16
 
9
17
  const serverConfigKey = Symbol.for('@ecomiq/storefront/server-config')
package/src/server.ts CHANGED
@@ -71,11 +71,16 @@ import {
71
71
  type AddProductToCartInput,
72
72
  } from './cart-session'
73
73
  import { submitProductReview } from './review-submission'
74
+ import { loadActiveStorefrontHome } from './storefront-runtime'
74
75
 
75
76
  const getSettingsFn = createServerFn({ method: 'GET' }).handler(() =>
76
77
  getStorefrontClient().getSettings(),
77
78
  )
78
79
 
80
+ const getActiveStorefrontHomeFn = createServerFn({ method: 'GET' }).handler(
81
+ () => loadActiveStorefrontHome(),
82
+ )
83
+
79
84
  const getStoreIdFn = createServerFn({ method: 'GET' }).handler(() =>
80
85
  getStorefrontClient().getStoreId(),
81
86
  )
@@ -222,6 +227,7 @@ const getDistrictsFn = createServerFn({ method: 'GET' })
222
227
  */
223
228
  export const serverApi = {
224
229
  getSettings: () => getSettingsFn(),
230
+ getActiveStorefrontHome: () => getActiveStorefrontHomeFn(),
225
231
  getStoreId: () => getStoreIdFn(),
226
232
  getCollections: () => getCollectionsFn(),
227
233
  getCollectionBySlug: (slug: string) => getCollectionBySlugFn({ data: { slug } }),
@@ -0,0 +1 @@
1
+ export * from '@ecomiq/storefront-definition'
@@ -0,0 +1,2 @@
1
+ export * from './contract'
2
+ export * from './renderer'