@ecomiq/storefront 1.1.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +26 -418
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -94
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-64GLOWMJ.js +0 -216
  67. package/dist/chunk-64GLOWMJ.js.map +0 -1
  68. package/dist/chunk-AKD5IDEB.js +0 -177
  69. package/dist/chunk-AKD5IDEB.js.map +0 -1
  70. package/dist/chunk-EFEVJJWF.js +0 -1345
  71. package/dist/chunk-EFEVJJWF.js.map +0 -1
  72. package/dist/chunk-NAJQB5DN.js +0 -155
  73. package/dist/chunk-NAJQB5DN.js.map +0 -1
  74. package/dist/chunk-OABDGTBI.js +0 -253
  75. package/dist/chunk-OABDGTBI.js.map +0 -1
  76. package/dist/chunk-Q4U5OEUT.js +0 -21
  77. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  78. package/dist/chunk-S7CPZ4UE.js +0 -6
  79. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10759
  83. package/dist/chunks/client.js +0 -5398
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -59
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -40
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-DU7gREXS.d.ts +0 -4062
  98. package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -18
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -1
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -18
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,291 @@
1
+ import { Link } from '../link'
2
+ import { Carousel } from '../ui/carousel'
3
+ import { cn } from '../ui/cn'
4
+ import { buttonVariants } from '../ui/button'
5
+ import type { CmsPageDetailsDto, CmsSectionDto } from '@ecomiq/core'
6
+ import { useEffect, useRef, useState } from 'react'
7
+ import { z } from 'zod'
8
+
9
+ /** Slide tipado para uso manual (sin CMS). */
10
+ export type HeroSlide = {
11
+ id?: string
12
+ title: string
13
+ subtitle?: string
14
+ highlight?: string
15
+ description?: string
16
+ ctaText?: string
17
+ ctaUrl?: string
18
+ imageUrl?: string
19
+ }
20
+
21
+ export type HeroProps = {
22
+ /** Página CMS: busca la sección hero automáticamente. */
23
+ page?: Pick<CmsPageDetailsDto, 'sections'> | null
24
+ /** Sección CMS directa (prioridad sobre `page`). */
25
+ section?: CmsSectionDto | null
26
+ /** Slides manuales (prioridad máxima). */
27
+ slides?: HeroSlide[]
28
+ /** Autoplay en ms. Default: 6000. `false` para desactivar. */
29
+ autoplay?: number | false
30
+ /** Altura fija en px. Si no, usa `section.settings.height`. */
31
+ height?: number
32
+ className?: string
33
+ fallbacks?: {
34
+ title?: string
35
+ subtitle?: string
36
+ ctaText?: string
37
+ ctaUrl?: string
38
+ }
39
+ }
40
+
41
+ const HERO_SECTION_TYPES = new Set(['hero', 'banner', 'slideshow', 'carousel'])
42
+ const HERO_BLOCK_TYPES = new Set(['slide'])
43
+
44
+ const HeroTextSchema = z.string().trim().min(1)
45
+
46
+ const HeroSectionSettingsSchema = z
47
+ .object({
48
+ enabled: z.boolean().optional(),
49
+ title: HeroTextSchema.optional(),
50
+ subtitle: HeroTextSchema.optional(),
51
+ description: HeroTextSchema.optional(),
52
+ ctaText: HeroTextSchema.optional(),
53
+ ctaUrl: HeroTextSchema.optional(),
54
+ imageUrl: HeroTextSchema.optional(),
55
+ height: z.number().positive().optional(),
56
+ })
57
+ .strip()
58
+
59
+ const HeroBlockSettingsSchema = z
60
+ .object({
61
+ title: HeroTextSchema,
62
+ subtitle: HeroTextSchema.optional(),
63
+ highlight: HeroTextSchema.optional(),
64
+ description: HeroTextSchema.optional(),
65
+ ctaText: HeroTextSchema.optional(),
66
+ ctaUrl: HeroTextSchema.optional(),
67
+ imageUrl: HeroTextSchema.optional(),
68
+ })
69
+ .strip()
70
+
71
+ function isHeroSection(section: CmsSectionDto): boolean {
72
+ return HERO_SECTION_TYPES.has(section.type.toLowerCase())
73
+ }
74
+
75
+ export function findHeroSection(
76
+ page?: Pick<CmsPageDetailsDto, 'sections'> | null,
77
+ ): CmsSectionDto | null {
78
+ return page?.sections.find(isHeroSection) ?? null
79
+ }
80
+
81
+ function heroHeightPx(
82
+ sectionSettings: z.infer<typeof HeroSectionSettingsSchema> | null,
83
+ height?: number,
84
+ ): number | undefined {
85
+ if (typeof height === 'number' && height > 0) return height
86
+ return sectionSettings?.height
87
+ }
88
+
89
+ function slidesFromSection(
90
+ section: CmsSectionDto,
91
+ sectionSettings: z.infer<typeof HeroSectionSettingsSchema>,
92
+ fallbacks: NonNullable<HeroProps['fallbacks']>,
93
+ ): HeroSlide[] {
94
+ const blocks = [...section.blocks].sort((a, b) => a.order - b.order)
95
+ if (blocks.length === 0) {
96
+ return [
97
+ {
98
+ id: section.id,
99
+ title: sectionSettings.title ?? fallbacks.title ?? 'Nueva colección',
100
+ subtitle: sectionSettings.subtitle ?? fallbacks.subtitle,
101
+ description: sectionSettings.description,
102
+ ctaText: sectionSettings.ctaText ?? fallbacks.ctaText ?? 'Ver colección',
103
+ ctaUrl: sectionSettings.ctaUrl ?? fallbacks.ctaUrl ?? '/collections/all',
104
+ imageUrl: sectionSettings.imageUrl,
105
+ },
106
+ ]
107
+ }
108
+
109
+ const slides: HeroSlide[] = []
110
+ for (const block of blocks) {
111
+ if (!HERO_BLOCK_TYPES.has(block.type.toLowerCase())) continue
112
+
113
+ const parsed = HeroBlockSettingsSchema.safeParse(block.settings)
114
+ if (!parsed.success) continue
115
+
116
+ slides.push({
117
+ id: block.id,
118
+ title: parsed.data.title,
119
+ subtitle: parsed.data.subtitle,
120
+ highlight: parsed.data.highlight,
121
+ description: parsed.data.description ?? sectionSettings.subtitle,
122
+ ctaText: parsed.data.ctaText ?? fallbacks.ctaText ?? 'Ver colección',
123
+ ctaUrl: parsed.data.ctaUrl ?? fallbacks.ctaUrl ?? '/collections/all',
124
+ imageUrl: parsed.data.imageUrl,
125
+ })
126
+ }
127
+
128
+ return slides
129
+ }
130
+
131
+ function resolveCmsHero(
132
+ section: CmsSectionDto,
133
+ fallbacks: NonNullable<HeroProps['fallbacks']>,
134
+ ): {
135
+ settings: z.infer<typeof HeroSectionSettingsSchema>
136
+ slides: HeroSlide[]
137
+ } | null {
138
+ const parsed = HeroSectionSettingsSchema.safeParse(section.settings)
139
+ if (!parsed.success || parsed.data.enabled === false) return null
140
+
141
+ return {
142
+ settings: parsed.data,
143
+ slides: slidesFromSection(section, parsed.data, fallbacks),
144
+ }
145
+ }
146
+
147
+ function selectedCmsSection(props: HeroProps): CmsSectionDto | null {
148
+ if (props.section) {
149
+ if (isHeroSection(props.section)) return props.section
150
+ return null
151
+ }
152
+
153
+ return findHeroSection(props.page)
154
+ }
155
+
156
+ function resolveSlides(props: HeroProps): { slides: HeroSlide[]; heightPx?: number } {
157
+ if (props.slides?.length) {
158
+ let sectionSettings: z.infer<typeof HeroSectionSettingsSchema> | null = null
159
+ if (props.section) {
160
+ const parsed = HeroSectionSettingsSchema.safeParse(props.section.settings)
161
+ if (parsed.success) sectionSettings = parsed.data
162
+ }
163
+ return {
164
+ slides: props.slides,
165
+ heightPx: heroHeightPx(sectionSettings, props.height),
166
+ }
167
+ }
168
+
169
+ const section = selectedCmsSection(props)
170
+ if (!section) return { slides: [], heightPx: props.height }
171
+
172
+ const resolved = resolveCmsHero(section, props.fallbacks ?? {})
173
+ if (!resolved) return { slides: [], heightPx: props.height }
174
+
175
+ return {
176
+ slides: resolved.slides,
177
+ heightPx: heroHeightPx(resolved.settings, props.height),
178
+ }
179
+ }
180
+
181
+ function HeroSlideContent({
182
+ slide,
183
+ priorityImage,
184
+ heightPx,
185
+ }: {
186
+ slide: HeroSlide
187
+ priorityImage?: boolean
188
+ heightPx?: number
189
+ }) {
190
+ const [failedImage, setFailedImage] = useState<string | null>(null)
191
+ const imageRef = useRef<HTMLImageElement>(null)
192
+ const hasImage = Boolean(slide.imageUrl && slide.imageUrl !== failedImage)
193
+
194
+ useEffect(() => {
195
+ const image = imageRef.current
196
+ if (slide.imageUrl && image?.complete && image.naturalWidth === 0) {
197
+ setFailedImage(slide.imageUrl)
198
+ }
199
+ }, [slide.imageUrl])
200
+
201
+ return (
202
+ <div
203
+ className={cn(
204
+ 'relative w-full overflow-hidden',
205
+ !heightPx && 'min-h-[min(70vh,520px)] md:aspect-[21/9] md:min-h-0',
206
+ )}
207
+ style={heightPx ? { height: `${heightPx}px` } : undefined}
208
+ >
209
+ {hasImage ? (
210
+ <img
211
+ ref={imageRef}
212
+ src={slide.imageUrl}
213
+ alt=""
214
+ aria-hidden
215
+ decoding="async"
216
+ fetchPriority={priorityImage ? 'high' : 'auto'}
217
+ loading={priorityImage ? 'eager' : 'lazy'}
218
+ onError={() => setFailedImage(slide.imageUrl ?? null)}
219
+ className="pointer-events-none absolute inset-0 h-full w-full object-cover object-center"
220
+ />
221
+ ) : (
222
+ <div className="absolute inset-0 bg-muted" aria-hidden />
223
+ )}
224
+
225
+ <div
226
+ className="absolute inset-0 bg-linear-to-r from-black/80 via-black/45 to-black/10"
227
+ aria-hidden
228
+ />
229
+
230
+ <div className="relative mx-auto flex h-full min-h-[inherit] w-full max-w-6xl items-center px-4 py-12 md:py-16">
231
+ <div className="relative z-10 max-w-xl text-white">
232
+ {slide.subtitle ? (
233
+ <p className="text-xs font-medium uppercase tracking-[0.2em] text-white/70">
234
+ {slide.subtitle}
235
+ </p>
236
+ ) : null}
237
+ <h1 className="mt-3 text-4xl font-light leading-tight tracking-tight md:text-5xl lg:text-6xl">
238
+ {slide.title}
239
+ </h1>
240
+ {slide.highlight ? (
241
+ <p className="mt-2 text-2xl font-medium tracking-wide text-primary-foreground md:text-3xl">
242
+ {slide.highlight}
243
+ </p>
244
+ ) : null}
245
+ {slide.description ? (
246
+ <p className="mt-4 max-w-lg text-sm leading-relaxed text-white/80 md:text-base">
247
+ {slide.description}
248
+ </p>
249
+ ) : null}
250
+ {slide.ctaText && slide.ctaUrl ? (
251
+ <Link to={slide.ctaUrl} className={cn(buttonVariants({ size: 'lg' }), 'mt-8')}>
252
+ {slide.ctaText}
253
+ </Link>
254
+ ) : null}
255
+ </div>
256
+ </div>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ /**
262
+ * Hero de tienda con carrusel Embla interno.
263
+ *
264
+ * ```tsx
265
+ * <Hero page={homePage} />
266
+ * <Hero section={heroSection} />
267
+ * <Hero slides={[{ title: 'Hola', ctaText: 'Ver', ctaUrl: '/' }]} />
268
+ * ```
269
+ */
270
+ export function Hero(props: HeroProps) {
271
+ const { slides, heightPx } = resolveSlides(props)
272
+ if (!slides.length) return null
273
+
274
+ return (
275
+ <Carousel
276
+ className={cn('bg-foreground', props.className)}
277
+ fade
278
+ autoplay={props.autoplay === undefined ? 6000 : props.autoplay}
279
+ aria-label="Destacados"
280
+ >
281
+ {slides.map((slide, index) => (
282
+ <HeroSlideContent
283
+ key={slide.id ?? index}
284
+ slide={slide}
285
+ priorityImage={index === 0}
286
+ heightPx={heightPx}
287
+ />
288
+ ))}
289
+ </Carousel>
290
+ )
291
+ }
@@ -0,0 +1,69 @@
1
+ import type {
2
+ EcomiqAnalyticsConfig,
3
+ EcomiqLayoutConfig,
4
+ EcomiqSeoConfig,
5
+ EcomiqThemeConfig,
6
+ } from './index'
7
+
8
+ export interface EcomiqApiConfig {
9
+ baseUrl?: string
10
+ version?: string
11
+ headers?: Record<string, string>
12
+ timeoutMs?: number
13
+ }
14
+
15
+ export type EcomiqCloudflareRoute =
16
+ | string
17
+ | {
18
+ pattern: string
19
+ zone_id: string
20
+ custom_domain?: boolean
21
+ }
22
+ | {
23
+ pattern: string
24
+ zone_name: string
25
+ custom_domain?: boolean
26
+ }
27
+ | {
28
+ pattern: string
29
+ custom_domain: boolean
30
+ enabled?: boolean
31
+ previews_enabled?: boolean
32
+ }
33
+
34
+ export interface EcomiqConfigInput {
35
+ name: string
36
+ slug: string
37
+ description?: string
38
+ api?: EcomiqApiConfig
39
+ theme?: EcomiqThemeConfig
40
+ seo?: EcomiqSeoConfig
41
+ analytics?: EcomiqAnalyticsConfig
42
+ layout?: EcomiqLayoutConfig
43
+ dev?: {
44
+ port?: number
45
+ apiBaseUrl?: string
46
+ }
47
+ deploy?: {
48
+ target?: 'cloudflare'
49
+ name?: string
50
+ routes?: EcomiqCloudflareRoute[]
51
+ }
52
+ env?: Record<string, string>
53
+ }
54
+
55
+ export interface ResolvedEcomiqConfig extends Omit<EcomiqConfigInput, 'api'> {
56
+ api: {
57
+ baseUrl: string
58
+ version: string
59
+ headers?: Record<string, string>
60
+ timeoutMs: number
61
+ }
62
+ }
63
+
64
+ export function parseConfig(
65
+ config: unknown,
66
+ options?: { source?: string },
67
+ ): ResolvedEcomiqConfig
68
+
69
+ export function defineConfig(config: EcomiqConfigInput): ResolvedEcomiqConfig
package/src/config.mjs ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Configuración base de un proyecto Ecomiq (ecomiq.config.js).
3
+ * Se valida con Zod al arrancar la CLI para dar errores claros.
4
+ */
5
+ import { z } from 'zod'
6
+
7
+ const cloudflareRouteSchema = z.union([
8
+ z.string().min(1),
9
+ z
10
+ .object({
11
+ pattern: z.string().min(1),
12
+ zone_id: z.string().min(1),
13
+ custom_domain: z.boolean().optional(),
14
+ })
15
+ .strict(),
16
+ z
17
+ .object({
18
+ pattern: z.string().min(1),
19
+ zone_name: z.string().min(1),
20
+ custom_domain: z.boolean().optional(),
21
+ })
22
+ .strict(),
23
+ z
24
+ .object({
25
+ pattern: z.string().min(1),
26
+ custom_domain: z.boolean(),
27
+ enabled: z.boolean().optional(),
28
+ previews_enabled: z.boolean().optional(),
29
+ })
30
+ .strict(),
31
+ ])
32
+
33
+ const configSchema = z.object({
34
+ /** Nombre visible de la tienda (header, <title>, worker por defecto). */
35
+ name: z.string().min(1),
36
+ /**
37
+ * Identidad de la tienda en el API storefront (header x-Store-Domain).
38
+ * Ej. 'marketplace'.
39
+ */
40
+ slug: z.string().min(1),
41
+ /** Descripción para SEO por defecto. */
42
+ description: z.string().optional(),
43
+ /** API de storefront. baseUrl default: https://storefront.ecomiq.pe */
44
+ api: z
45
+ .object({
46
+ baseUrl: z.string().url().default('https://storefront.ecomiq.pe'),
47
+ /** Segmento de path de la API, ej. 'v1' → {baseUrl}/v1/settings */
48
+ version: z.string().min(1).default('v1'),
49
+ headers: z.record(z.string()).optional(),
50
+ /** Timeout en ms para cada request. */
51
+ timeoutMs: z.number().int().positive().default(10_000),
52
+ })
53
+ .default({}),
54
+ /** Tokens de tema; sobreescriben las variables CSS del design system. */
55
+ theme: z
56
+ .object({
57
+ primary: z.string().optional(),
58
+ primaryForeground: z.string().optional(),
59
+ background: z.string().optional(),
60
+ foreground: z.string().optional(),
61
+ accent: z.string().optional(),
62
+ radius: z.string().optional(),
63
+ })
64
+ .optional(),
65
+ /** SEO global, se mezcla con el `meta` de cada página (la página gana). */
66
+ seo: z
67
+ .object({
68
+ /** Plantilla del <title>. `%s` se reemplaza por el título de la página. */
69
+ titleTemplate: z.string().optional(),
70
+ keywords: z.array(z.string()).optional(),
71
+ /** Ruta dentro de public/, ej. '/favicon.svg'. */
72
+ favicon: z.string().optional(),
73
+ /** Imagen OG por defecto (URL absoluta o ruta en public/). */
74
+ ogImage: z.string().optional(),
75
+ twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
76
+ /** Metas extra: [{ name | property, content }]. */
77
+ meta: z
78
+ .array(
79
+ z.object({
80
+ name: z.string().optional(),
81
+ property: z.string().optional(),
82
+ content: z.string(),
83
+ }),
84
+ )
85
+ .optional(),
86
+ /** <link> extra: [{ rel, href, ... }]. */
87
+ links: z.array(z.record(z.string())).optional(),
88
+ })
89
+ .optional(),
90
+ /** Analytics sin escribir código. */
91
+ analytics: z
92
+ .object({
93
+ /** ID de Google Analytics 4, ej. 'G-XXXXXXX'. */
94
+ googleAnalytics: z.string().optional(),
95
+ /** ID de Meta Pixel. */
96
+ metaPixel: z.string().optional(),
97
+ /** Dominio para Plausible, ej. 'tienda.ecomiq.app'. */
98
+ plausible: z.string().optional(),
99
+ /** Scripts arbitrarios: [{ src }] o [{ inline: '...' }]. */
100
+ scripts: z
101
+ .array(
102
+ z.object({
103
+ src: z.string().optional(),
104
+ inline: z.string().optional(),
105
+ defer: z.boolean().optional(),
106
+ async: z.boolean().optional(),
107
+ }),
108
+ )
109
+ .optional(),
110
+ })
111
+ .optional(),
112
+ /** Layout declarativo (usado por el layout por defecto; un layout.tsx en la raíz lo reemplaza). */
113
+ layout: z
114
+ .object({
115
+ /** Enlaces del header: [{ label, to }]. */
116
+ nav: z.array(z.object({ label: z.string(), to: z.string() })).optional(),
117
+ /** Texto del footer. Default: '{name} — creado con Ecomiq'. */
118
+ footerText: z.string().optional(),
119
+ /** Header pegado arriba con blur. Default: true. */
120
+ stickyHeader: z.boolean().default(true).optional(),
121
+ })
122
+ .optional(),
123
+ dev: z
124
+ .object({
125
+ port: z.number().int().default(3000),
126
+ apiBaseUrl: z.string().url(),
127
+ })
128
+ .partial()
129
+ .optional(),
130
+ deploy: z
131
+ .object({
132
+ target: z.literal('cloudflare').default('cloudflare'),
133
+ /** Nombre del Worker en Cloudflare. */
134
+ name: z.string().optional(),
135
+ /** Rutas/custom domains del Worker. */
136
+ routes: z.array(cloudflareRouteSchema).optional(),
137
+ })
138
+ .optional(),
139
+ /** Variables expuestas al Worker (no secretas). */
140
+ env: z.record(z.string()).optional(),
141
+ })
142
+
143
+ export function parseConfig(config, options = {}) {
144
+ const source = options.source ?? 'ecomiq.config.js'
145
+ if (
146
+ config === null ||
147
+ typeof config !== 'object' ||
148
+ (Object.getPrototypeOf(config) !== Object.prototype && Object.getPrototypeOf(config) !== null)
149
+ ) {
150
+ throw new Error(`${source} inválido:\n · <raíz>: el default export debe ser un objeto plano`)
151
+ }
152
+
153
+ const parsed = configSchema.safeParse(config)
154
+ if (!parsed.success) {
155
+ const issues = parsed.error.issues
156
+ .map((issue) => ` · ${issue.path.join('.') || '<raíz>'}: ${issue.message}`)
157
+ .join('\n')
158
+ throw new Error(`${source} inválido:\n${issues}`)
159
+ }
160
+ return parsed.data
161
+ }
162
+
163
+ export function defineConfig(config) {
164
+ return parseConfig(config)
165
+ }