@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.
- package/README.md +26 -418
- package/bin/ecomiq.mjs +154 -0
- package/package.json +72 -94
- package/src/api-proxy.ts +194 -0
- package/src/auth/create-storefront-auth.ts +35 -0
- package/src/auth/ecomiq-oauth-password-client.ts +26 -0
- package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
- package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
- package/src/auth/ecomiq-oauth-password-types.ts +25 -0
- package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
- package/src/auth/env.ts +7 -0
- package/src/cart-session.ts +241 -0
- package/src/client.ts +48 -0
- package/src/codegen.mjs +603 -0
- package/src/components/checkout/cart-sync.ts +143 -0
- package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
- package/src/components/checkout/checkout-form-controls.tsx +139 -0
- package/src/components/checkout/checkout-line-items.tsx +43 -0
- package/src/components/checkout/checkout-order-summary.tsx +156 -0
- package/src/components/checkout/checkout-payment-section.tsx +215 -0
- package/src/components/checkout/checkout-session-view.tsx +240 -0
- package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
- package/src/components/checkout/checkout-shipping-section.tsx +90 -0
- package/src/components/checkout/checkout-states.tsx +73 -0
- package/src/components/checkout/form-schema.ts +229 -0
- package/src/components/checkout/format.ts +27 -0
- package/src/components/checkout/index.ts +51 -0
- package/src/components/checkout/types.ts +43 -0
- package/src/components/checkout/use-checkout-geo.ts +50 -0
- package/src/components/checkout/use-checkout-session.ts +583 -0
- package/src/components/hero.tsx +291 -0
- package/src/config.d.ts +69 -0
- package/src/config.mjs +165 -0
- package/src/index.ts +274 -0
- package/src/link.tsx +2 -0
- package/src/pages/checkout.$checkoutId.tsx +60 -0
- package/src/pages/checkout.tsx +31 -0
- package/src/pages/thank-you.tsx +53 -0
- package/src/providers/auth.tsx +69 -0
- package/src/providers/cart.tsx +275 -0
- package/src/providers/ecomiq.ts +137 -0
- package/src/providers/index.ts +26 -0
- package/src/public-config.ts +44 -0
- package/src/review-submission.ts +89 -0
- package/src/runtime/context.ts +1 -0
- package/src/runtime/default-layout.tsx +80 -0
- package/src/runtime/head.ts +126 -0
- package/src/runtime/page.tsx +82 -0
- package/src/runtime/postcss-browser-stub.mjs +5 -0
- package/src/server-config-store.ts +26 -0
- package/src/server.ts +279 -0
- package/src/styles.css +76 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +31 -0
- package/src/ui/card.tsx +31 -0
- package/src/ui/carousel.tsx +218 -0
- package/src/ui/cn.ts +6 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/separator.tsx +6 -0
- package/src/ui/skeleton.tsx +6 -0
- package/src/ui/storefront-error.tsx +49 -0
- package/src/utils.ts +36 -0
- package/src/vite-inline.mjs +95 -0
- package/templates/__root.tsx +77 -0
- package/templates/router.tsx +19 -0
- package/dist/chunk-64GLOWMJ.js +0 -216
- package/dist/chunk-64GLOWMJ.js.map +0 -1
- package/dist/chunk-AKD5IDEB.js +0 -177
- package/dist/chunk-AKD5IDEB.js.map +0 -1
- package/dist/chunk-EFEVJJWF.js +0 -1345
- package/dist/chunk-EFEVJJWF.js.map +0 -1
- package/dist/chunk-NAJQB5DN.js +0 -155
- package/dist/chunk-NAJQB5DN.js.map +0 -1
- package/dist/chunk-OABDGTBI.js +0 -253
- package/dist/chunk-OABDGTBI.js.map +0 -1
- package/dist/chunk-Q4U5OEUT.js +0 -21
- package/dist/chunk-Q4U5OEUT.js.map +0 -1
- package/dist/chunk-S7CPZ4UE.js +0 -6
- package/dist/chunk-S7CPZ4UE.js.map +0 -1
- package/dist/chunks/client.css +0 -984
- package/dist/chunks/client.css.map +0 -1
- package/dist/chunks/client.d.ts +0 -10759
- package/dist/chunks/client.js +0 -5398
- package/dist/chunks/client.js.map +0 -1
- package/dist/chunks/config.d.ts +0 -31
- package/dist/chunks/config.js +0 -5
- package/dist/chunks/config.js.map +0 -1
- package/dist/chunks/core-public.d.ts +0 -130
- package/dist/chunks/core-public.js +0 -92
- package/dist/chunks/core-public.js.map +0 -1
- package/dist/chunks/server-nextjs.d.ts +0 -33
- package/dist/chunks/server-nextjs.js +0 -59
- package/dist/chunks/server-nextjs.js.map +0 -1
- package/dist/chunks/server-tanstack.d.ts +0 -22
- package/dist/chunks/server-tanstack.js +0 -40
- package/dist/chunks/server-tanstack.js.map +0 -1
- package/dist/collection-DU7gREXS.d.ts +0 -4062
- package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
- package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
- package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.server.d.ts +0 -9
- package/dist/index.server.js +0 -8
- package/dist/index.server.js.map +0 -1
- package/dist/nextjs/api.d.ts +0 -45
- package/dist/nextjs/api.js +0 -32
- package/dist/nextjs/api.js.map +0 -1
- package/dist/nextjs/globals.css +0 -13
- package/dist/nextjs.d.ts +0 -2
- package/dist/nextjs.js +0 -2
- package/dist/nextjs.js.map +0 -1
- package/dist/nextjs.server.d.ts +0 -18
- package/dist/nextjs.server.js +0 -1
- package/dist/nextjs.server.js.map +0 -1
- package/dist/postcss.config.mjs +0 -6
- package/dist/react/checkout-page.css +0 -855
- package/dist/react/client.css +0 -984
- package/dist/react/payment-provider.css +0 -91
- package/dist/react/thank-you-page.css +0 -105
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -2
- package/dist/react.js.map +0 -1
- package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
- package/dist/tanstack/api.d.ts +0 -41
- package/dist/tanstack/api.js +0 -30
- package/dist/tanstack/api.js.map +0 -1
- package/dist/tanstack.d.ts +0 -1
- package/dist/tanstack.js +0 -2
- package/dist/tanstack.js.map +0 -1
- package/dist/tanstack.server.d.ts +0 -18
- package/dist/tanstack.server.js +0 -1
- 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
|
+
}
|
package/src/config.d.ts
ADDED
|
@@ -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
|
+
}
|