@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 +71 -5
- package/package.json +7 -2
- package/src/auth/ecomiq-oauth-password-types.ts +0 -1
- package/src/auth/storefront-auth.ts +7 -0
- package/src/cloud-runtime-client.ts +218 -0
- package/src/codegen.mjs +55 -2
- package/src/components/checkout/format.ts +1 -8
- package/src/config.d.ts +26 -1
- package/src/config.mjs +20 -1
- package/src/format-money.ts +31 -0
- package/src/index.ts +16 -1
- package/src/pages/api.$.ts +20 -0
- package/src/pages/api.auth.$.ts +16 -0
- package/src/providers/auth.tsx +20 -7
- package/src/providers/cart.tsx +2 -2
- package/src/runtime/context.ts +1 -0
- package/src/server-config-store.ts +8 -0
- package/src/server.ts +10 -2
- package/src/storefront-definition/contract.ts +1 -0
- package/src/storefront-definition/index.ts +2 -0
- package/src/storefront-definition/renderer.tsx +905 -0
- package/src/storefront-layout.ts +37 -0
- package/src/storefront-runtime.ts +277 -0
- package/src/styles.css +13 -0
- package/templates/__root.tsx +6 -4
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
87
|
+
"@ecomiq/core": "^0.1.7",
|
|
88
|
+
"@ecomiq/storefront-definition": "^1.0.0"
|
|
84
89
|
},
|
|
85
90
|
"peerDependencies": {
|
|
86
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
|
@@ -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
|
-
`
|
|
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
|
|
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
|
|
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 {
|
|
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
|