@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 +71 -5
- package/package.json +5 -2
- package/src/cloud-runtime-client.ts +218 -0
- package/src/codegen.mjs +11 -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 +12 -1
- package/src/server-config-store.ts +8 -0
- package/src/server.ts +6 -0
- 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/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.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.
|
|
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
|
-
`
|
|
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
|
|
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
|
@@ -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 {
|
|
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'
|