@ecomiq/storefront 2.0.9 → 2.0.12
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/bin/ecomiq.mjs +1 -1
- package/package.json +2 -2
- package/src/api-proxy.ts +2 -2
- package/src/client.ts +2 -2
- package/src/codegen.mjs +3 -8
- package/src/components/checkout/checkout-contact-section.tsx +70 -0
- package/src/components/checkout/checkout-order-summary.tsx +11 -2
- package/src/components/checkout/checkout-payment-section.tsx +4 -7
- package/src/components/checkout/checkout-session-view.tsx +311 -68
- package/src/components/checkout/checkout-shipping-address-section.tsx +119 -90
- package/src/components/checkout/checkout-shipping-section.tsx +22 -14
- package/src/components/checkout/checkout-step-card.tsx +101 -0
- package/src/components/checkout/checkout-steps.ts +96 -0
- package/src/components/checkout/form-schema.ts +152 -100
- package/src/components/checkout/format.ts +0 -5
- package/src/components/checkout/index.ts +4 -50
- package/src/components/checkout/types.ts +0 -2
- package/src/components/checkout/use-checkout-session.ts +72 -15
- package/src/config.d.ts +6 -12
- package/src/config.mjs +5 -64
- package/src/hooks/index.ts +9 -0
- package/src/hooks/recently-viewed.ts +125 -0
- package/src/index.ts +17 -4
- package/src/public-config.ts +10 -30
- package/src/runtime/default-layout.tsx +5 -21
- package/src/runtime/head.ts +70 -47
- package/src/runtime/page.tsx +45 -13
- package/src/server-config-store.ts +1 -1
- package/templates/__root.tsx +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { useSettings } from '../providers/ecomiq'
|
|
4
|
+
|
|
5
|
+
const RecentlyViewedItemSchema = z.object({
|
|
6
|
+
id: z.string().min(1),
|
|
7
|
+
name: z.string().min(1),
|
|
8
|
+
slug: z.string().min(1),
|
|
9
|
+
imageUrl: z.string(),
|
|
10
|
+
price: z.number().nonnegative(),
|
|
11
|
+
compareAtPrice: z.number().nonnegative().nullable(),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const RecentlyViewedSessionSchema = z.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
storeId: z.string().min(1),
|
|
17
|
+
items: z.array(RecentlyViewedItemSchema).max(12),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export type RecentlyViewedItem = z.infer<typeof RecentlyViewedItemSchema>
|
|
21
|
+
|
|
22
|
+
const MAX_ITEMS = 8
|
|
23
|
+
|
|
24
|
+
function storageKey(storeId: string) {
|
|
25
|
+
return `ecomiq:recently-viewed:v1:${storeId}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readSession(storeId: string) {
|
|
29
|
+
if (typeof localStorage === 'undefined') return undefined
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(storageKey(storeId))
|
|
32
|
+
if (!raw) return undefined
|
|
33
|
+
const parsed = RecentlyViewedSessionSchema.safeParse(JSON.parse(raw))
|
|
34
|
+
return parsed.success && parsed.data.storeId === storeId ? parsed.data : undefined
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Lectura síncrona (tests / casos sin React). */
|
|
41
|
+
export function listRecentlyViewed(storeId: string): RecentlyViewedItem[] {
|
|
42
|
+
return readSession(storeId)?.items ?? []
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Persistencia síncrona: el item queda al frente y se deduplica por id. */
|
|
46
|
+
export function trackRecentlyViewed(
|
|
47
|
+
storeId: string,
|
|
48
|
+
item: RecentlyViewedItem,
|
|
49
|
+
): RecentlyViewedItem[] {
|
|
50
|
+
const current = listRecentlyViewed(storeId).filter((entry) => entry.id !== item.id)
|
|
51
|
+
const items = [item, ...current].slice(0, MAX_ITEMS)
|
|
52
|
+
localStorage.setItem(
|
|
53
|
+
storageKey(storeId),
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
version: 1,
|
|
56
|
+
storeId,
|
|
57
|
+
items,
|
|
58
|
+
} satisfies z.infer<typeof RecentlyViewedSessionSchema>),
|
|
59
|
+
)
|
|
60
|
+
return items
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TrackableProduct = {
|
|
64
|
+
id: string | number
|
|
65
|
+
name: string
|
|
66
|
+
slug: string
|
|
67
|
+
imageUrl?: string | null
|
|
68
|
+
images?: Array<{ url: string }>
|
|
69
|
+
price: number
|
|
70
|
+
compareAtPrice: number | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toRecentlyViewedItem(product: TrackableProduct): RecentlyViewedItem {
|
|
74
|
+
return {
|
|
75
|
+
id: String(product.id),
|
|
76
|
+
name: product.name,
|
|
77
|
+
slug: product.slug,
|
|
78
|
+
imageUrl: product.imageUrl || product.images?.[0]?.url || '',
|
|
79
|
+
price: product.price,
|
|
80
|
+
compareAtPrice: product.compareAtPrice,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Lista reactiva de productos vistos recientemente de la tienda actual.
|
|
86
|
+
*
|
|
87
|
+
* ```tsx
|
|
88
|
+
* const { items, track } = useRecentlyViewed()
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function useRecentlyViewed() {
|
|
92
|
+
const { store } = useSettings()
|
|
93
|
+
const [items, setItems] = useState<RecentlyViewedItem[]>([])
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setItems(listRecentlyViewed(store.id))
|
|
97
|
+
}, [store.id])
|
|
98
|
+
|
|
99
|
+
const track = useCallback(
|
|
100
|
+
(item: RecentlyViewedItem) => {
|
|
101
|
+
const next = trackRecentlyViewed(store.id, item)
|
|
102
|
+
setItems(next)
|
|
103
|
+
return next
|
|
104
|
+
},
|
|
105
|
+
[store.id],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return { items, track, storeId: store.id }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Registra un producto en “visto recientemente” al montar / cambiar de PDP.
|
|
113
|
+
*
|
|
114
|
+
* ```tsx
|
|
115
|
+
* useTrackRecentlyViewed(product)
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function useTrackRecentlyViewed(product: TrackableProduct) {
|
|
119
|
+
const { store } = useSettings()
|
|
120
|
+
const item = toRecentlyViewedItem(product)
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
trackRecentlyViewed(store.id, item)
|
|
124
|
+
}, [store.id, item.id, item.name, item.slug, item.imageUrl, item.price, item.compareAtPrice])
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,7 @@ import type { StorefrontSettings } from '@ecomiq/core'
|
|
|
8
8
|
import type { PublicEcomiqConfig } from './public-config'
|
|
9
9
|
|
|
10
10
|
export type {
|
|
11
|
-
EcomiqAnalyticsConfig,
|
|
12
11
|
EcomiqConfig,
|
|
13
|
-
EcomiqLayoutConfig,
|
|
14
|
-
EcomiqSeoConfig,
|
|
15
12
|
EcomiqThemeConfig,
|
|
16
13
|
PublicEcomiqConfig,
|
|
17
14
|
} from './public-config'
|
|
@@ -169,6 +166,15 @@ export {
|
|
|
169
166
|
type StorefrontAuthClientInstance,
|
|
170
167
|
type StorefrontSession,
|
|
171
168
|
} from './providers'
|
|
169
|
+
export {
|
|
170
|
+
listRecentlyViewed,
|
|
171
|
+
trackRecentlyViewed,
|
|
172
|
+
toRecentlyViewedItem,
|
|
173
|
+
useRecentlyViewed,
|
|
174
|
+
useTrackRecentlyViewed,
|
|
175
|
+
type RecentlyViewedItem,
|
|
176
|
+
type TrackableProduct,
|
|
177
|
+
} from './hooks'
|
|
172
178
|
export { createStorefrontAuth } from './auth/create-storefront-auth'
|
|
173
179
|
export { ecomiqOAuthPassword } from './auth/ecomiq-oauth-password-plugin'
|
|
174
180
|
export { ecomiqOAuthPasswordClient } from './auth/ecomiq-oauth-password-client'
|
|
@@ -202,12 +208,14 @@ export interface PageProps<
|
|
|
202
208
|
data: TData
|
|
203
209
|
params: TParams
|
|
204
210
|
search: TSearch
|
|
211
|
+
/** Settings storefront (CMS, store, menus). Cargado en el root; sin request extra. */
|
|
212
|
+
settings: StorefrontSettings
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
export interface PageMeta {
|
|
208
216
|
title?: string
|
|
209
217
|
description?: string
|
|
210
|
-
/** Imagen OG específica de esta página (sobreescribe
|
|
218
|
+
/** Imagen OG específica de esta página (sobreescribe branding.socialImageUrl). */
|
|
211
219
|
ogImage?: string
|
|
212
220
|
/** Marca la página como noindex,nofollow. */
|
|
213
221
|
noIndex?: boolean
|
|
@@ -254,6 +262,11 @@ export interface EcomiqPage<
|
|
|
254
262
|
params: TParams
|
|
255
263
|
search: TSearch
|
|
256
264
|
config: PublicEcomiqConfig
|
|
265
|
+
/**
|
|
266
|
+
* Settings storefront cargados en el root. Opcional: puede faltar si el
|
|
267
|
+
* `<head>` se calcula antes de que el match raíz tenga sus datos.
|
|
268
|
+
*/
|
|
269
|
+
settings?: StorefrontSettings
|
|
257
270
|
}) => PageMeta
|
|
258
271
|
/** UI de la página. */
|
|
259
272
|
component: (props: PageProps<TData, TParams, TSearch>) => ReactNode
|
package/src/public-config.ts
CHANGED
|
@@ -7,38 +7,18 @@ export interface EcomiqThemeConfig {
|
|
|
7
7
|
radius?: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
ogImage?: string
|
|
15
|
-
twitterCard?: 'summary' | 'summary_large_image'
|
|
16
|
-
meta?: Array<{ name?: string; property?: string; content: string }>
|
|
17
|
-
links?: Array<Record<string, string>>
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface EcomiqAnalyticsConfig {
|
|
21
|
-
googleAnalytics?: string
|
|
22
|
-
metaPixel?: string
|
|
23
|
-
plausible?: string
|
|
24
|
-
scripts?: Array<{ src?: string; inline?: string; defer?: boolean; async?: boolean }>
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface EcomiqLayoutConfig {
|
|
28
|
-
nav?: Array<{ label: string; to: string }>
|
|
29
|
-
footerText?: string
|
|
30
|
-
stickyHeader?: boolean
|
|
31
|
-
}
|
|
32
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Proyección pública de ecomiq.config.js (seguro para el bundle cliente).
|
|
12
|
+
* Nombre, SEO, analytics y menús viven en `/v1/settings`.
|
|
13
|
+
*/
|
|
33
14
|
export interface PublicEcomiqConfig {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Identidad de tienda para `x-Store-Domain`.
|
|
17
|
+
* Acepta slug corto o dominio completo, p. ej. `marketplace`,
|
|
18
|
+
* `marketplace.ecomiq.app` o `marketplace.pe`.
|
|
19
|
+
*/
|
|
20
|
+
domain: string
|
|
38
21
|
theme?: EcomiqThemeConfig
|
|
39
|
-
seo?: EcomiqSeoConfig
|
|
40
|
-
analytics?: EcomiqAnalyticsConfig
|
|
41
|
-
layout?: EcomiqLayoutConfig
|
|
42
22
|
}
|
|
43
23
|
|
|
44
24
|
export type EcomiqConfig = PublicEcomiqConfig
|
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Layout por defecto de Ecomiq.
|
|
3
3
|
* Nav y footer salen de settings.cms.menus (main / footer).
|
|
4
|
-
* Fallback: bloque `layout` de ecomiq.config.js.
|
|
5
4
|
*/
|
|
6
5
|
import { Link } from '@tanstack/react-router'
|
|
7
6
|
import { getMenuItems } from '@ecomiq/core'
|
|
8
7
|
import type { LayoutProps } from '../index'
|
|
9
8
|
|
|
10
|
-
export default function DefaultLayout({ children,
|
|
11
|
-
const layout = (config.layout ?? {}) as {
|
|
12
|
-
nav?: Array<{ label: string; to: string }>
|
|
13
|
-
footerText?: string
|
|
14
|
-
stickyHeader?: boolean
|
|
15
|
-
}
|
|
16
|
-
|
|
9
|
+
export default function DefaultLayout({ children, settings }: LayoutProps) {
|
|
17
10
|
const mainItems = getMenuItems(settings, 'main')
|
|
18
11
|
const footerItems = getMenuItems(settings, 'footer')
|
|
19
12
|
const nav =
|
|
20
13
|
mainItems.length > 0
|
|
21
14
|
? mainItems.map((item) => ({ label: item.label, to: item.url, target: item.target }))
|
|
22
|
-
:
|
|
23
|
-
...item,
|
|
24
|
-
target: null as string | null | undefined,
|
|
25
|
-
}))
|
|
15
|
+
: [{ label: 'Productos', to: '/', target: null as string | null }]
|
|
26
16
|
|
|
27
|
-
const
|
|
28
|
-
const storeName = settings?.store.name ?? config.name
|
|
17
|
+
const storeName = settings?.store.name ?? 'Tienda'
|
|
29
18
|
|
|
30
19
|
return (
|
|
31
20
|
<div className="flex min-h-dvh flex-col">
|
|
32
|
-
<header
|
|
33
|
-
className={
|
|
34
|
-
'border-b bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/75 z-40' +
|
|
35
|
-
(sticky ? ' sticky top-0' : '')
|
|
36
|
-
}
|
|
37
|
-
>
|
|
21
|
+
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/75">
|
|
38
22
|
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between px-4">
|
|
39
23
|
<Link to="/" className="font-semibold tracking-tight text-lg">
|
|
40
24
|
{storeName}
|
|
@@ -63,7 +47,7 @@ export default function DefaultLayout({ children, config, settings }: LayoutProp
|
|
|
63
47
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">{children}</main>
|
|
64
48
|
<footer className="border-t">
|
|
65
49
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
66
|
-
<p>{
|
|
50
|
+
<p>{settings?.store.description || `${storeName} — creado con Ecomiq`}</p>
|
|
67
51
|
{footerItems.length > 0 && (
|
|
68
52
|
<nav className="flex flex-wrap gap-4">
|
|
69
53
|
{footerItems.map((item) => (
|
package/src/runtime/head.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Runtime interno: construcción del <head>.
|
|
3
|
-
* Mezcla
|
|
4
|
-
* (la página siempre gana) y genera los scripts de analytics
|
|
3
|
+
* Mezcla SEO/branding/tracking de `/v1/settings` con el `meta` de cada página
|
|
4
|
+
* (la página siempre gana) y genera los scripts de analytics del CMS.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type { StorefrontSettings } from '@ecomiq/core'
|
|
7
|
+
import type { PageMeta } from '../index'
|
|
8
|
+
import {
|
|
9
|
+
cmsBranding,
|
|
10
|
+
cmsSeo,
|
|
11
|
+
storeDisplayName,
|
|
12
|
+
storeSeoDescription,
|
|
13
|
+
} from '../utils'
|
|
7
14
|
|
|
8
15
|
type MetaTag = Record<string, string>
|
|
9
16
|
type LinkTag = Record<string, string>
|
|
@@ -22,53 +29,75 @@ function serializeJsonLd(value: Record<string, unknown>) {
|
|
|
22
29
|
.replace(/\u2029/g, '\\u2029')
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const seo = config.seo ?? {}
|
|
28
|
-
if (!pageTitle) return config.name
|
|
29
|
-
const template = seo.titleTemplate
|
|
30
|
-
return template ? template.replace('%s', pageTitle) : `${pageTitle} · ${config.name}`
|
|
32
|
+
function siteName(settings: StorefrontSettings | null | undefined): string {
|
|
33
|
+
return storeDisplayName(settings) ?? 'Tienda'
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/**
|
|
34
|
-
export function
|
|
35
|
-
|
|
36
|
+
/** Aplica titleTemplate del CMS (`{page} | {site}` o `%s · Tienda`). */
|
|
37
|
+
export function resolveTitle(
|
|
38
|
+
pageTitle: string | undefined,
|
|
39
|
+
settings: StorefrontSettings | null | undefined,
|
|
40
|
+
): string {
|
|
41
|
+
const name = siteName(settings)
|
|
42
|
+
const seo = cmsSeo(settings)
|
|
43
|
+
if (!pageTitle) return seo?.metaTitle ?? name
|
|
44
|
+
|
|
45
|
+
const template = seo?.titleTemplate?.trim()
|
|
46
|
+
if (!template) return `${pageTitle} · ${name}`
|
|
47
|
+
|
|
48
|
+
return template
|
|
49
|
+
.replaceAll('{page}', pageTitle)
|
|
50
|
+
.replaceAll('{site}', name)
|
|
51
|
+
.replace('%s', pageTitle)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Head global: charset, viewport, favicon, tracking. Va en el root. */
|
|
55
|
+
export function buildRootHead(
|
|
56
|
+
settings: StorefrontSettings | null | undefined,
|
|
57
|
+
): HeadResult {
|
|
58
|
+
const branding = cmsBranding(settings)
|
|
59
|
+
const seo = cmsSeo(settings)
|
|
36
60
|
|
|
37
61
|
const meta: MetaTag[] = [
|
|
38
62
|
{ charSet: 'utf-8' },
|
|
39
63
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
40
64
|
]
|
|
41
|
-
if (seo
|
|
42
|
-
|
|
65
|
+
if (seo?.robotsIndex === false) {
|
|
66
|
+
meta.push({ name: 'robots', content: 'noindex, nofollow' })
|
|
67
|
+
}
|
|
43
68
|
|
|
44
|
-
const links: LinkTag[] = [
|
|
45
|
-
|
|
69
|
+
const links: LinkTag[] = [
|
|
70
|
+
{ rel: 'icon', href: branding?.faviconUrl ?? '/favicon.ico' },
|
|
71
|
+
]
|
|
46
72
|
|
|
47
|
-
return { meta, links, scripts: buildAnalyticsScripts(
|
|
73
|
+
return { meta, links, scripts: buildAnalyticsScripts(settings) }
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
/** Head por página: title (con plantilla), description, OG/Twitter, robots, metas extra y JSON-LD. */
|
|
51
77
|
export function buildPageHead(
|
|
52
78
|
pageMeta: PageMeta | undefined,
|
|
53
|
-
|
|
79
|
+
settings: StorefrontSettings | null | undefined,
|
|
54
80
|
): HeadResult {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
81
|
+
const branding = cmsBranding(settings)
|
|
82
|
+
const seo = cmsSeo(settings)
|
|
83
|
+
const title = resolveTitle(pageMeta?.title, settings)
|
|
84
|
+
const description = pageMeta?.description ?? storeSeoDescription(settings) ?? undefined
|
|
85
|
+
const ogImage = pageMeta?.ogImage ?? branding?.socialImageUrl ?? undefined
|
|
86
|
+
const name = siteName(settings)
|
|
59
87
|
|
|
60
88
|
const meta: MetaTag[] = [{ title }]
|
|
61
89
|
if (description) meta.push({ name: 'description', content: description })
|
|
62
|
-
if (pageMeta?.noIndex
|
|
90
|
+
if (pageMeta?.noIndex || seo?.robotsIndex === false) {
|
|
91
|
+
meta.push({ name: 'robots', content: 'noindex, nofollow' })
|
|
92
|
+
}
|
|
63
93
|
|
|
64
|
-
// Open Graph + Twitter con fallbacks razonables
|
|
65
94
|
meta.push({ property: 'og:title', content: title })
|
|
66
95
|
meta.push({ property: 'og:type', content: 'website' })
|
|
67
|
-
meta.push({ property: 'og:site_name', content:
|
|
96
|
+
meta.push({ property: 'og:site_name', content: name })
|
|
68
97
|
if (description) meta.push({ property: 'og:description', content: description })
|
|
69
98
|
if (ogImage) {
|
|
70
99
|
meta.push({ property: 'og:image', content: ogImage })
|
|
71
|
-
meta.push({ name: 'twitter:card', content:
|
|
100
|
+
meta.push({ name: 'twitter:card', content: 'summary_large_image' })
|
|
72
101
|
meta.push({ name: 'twitter:image', content: ogImage })
|
|
73
102
|
}
|
|
74
103
|
|
|
@@ -82,44 +111,38 @@ export function buildPageHead(
|
|
|
82
111
|
return { meta, ...(scripts.length ? { scripts } : {}) }
|
|
83
112
|
}
|
|
84
113
|
|
|
85
|
-
/** Traduce
|
|
86
|
-
export function buildAnalyticsScripts(
|
|
87
|
-
|
|
114
|
+
/** Traduce tracking + customScripts del CMS a etiquetas <script>. */
|
|
115
|
+
export function buildAnalyticsScripts(
|
|
116
|
+
settings: StorefrontSettings | null | undefined,
|
|
117
|
+
): ScriptTag[] {
|
|
118
|
+
const tracking = settings?.cms?.settings?.tracking
|
|
119
|
+
const custom = settings?.cms?.settings?.customScripts
|
|
88
120
|
const scripts: ScriptTag[] = []
|
|
89
121
|
|
|
90
|
-
if (
|
|
91
|
-
const id =
|
|
122
|
+
if (tracking?.googleAnalyticsMeasurementId) {
|
|
123
|
+
const id = tracking.googleAnalyticsMeasurementId
|
|
92
124
|
scripts.push({ src: `https://www.googletagmanager.com/gtag/js?id=${id}`, async: true })
|
|
93
125
|
scripts.push({
|
|
94
126
|
children: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)};gtag('js',new Date());gtag('config','${id}');`,
|
|
95
127
|
})
|
|
96
128
|
}
|
|
97
129
|
|
|
98
|
-
if (
|
|
130
|
+
if (tracking?.googleTagManagerContainerId) {
|
|
131
|
+
const id = tracking.googleTagManagerContainerId
|
|
99
132
|
scripts.push({
|
|
100
|
-
|
|
101
|
-
defer: true,
|
|
102
|
-
'data-domain': analytics.plausible,
|
|
133
|
+
children: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${id}');`,
|
|
103
134
|
})
|
|
104
135
|
}
|
|
105
136
|
|
|
106
|
-
if (
|
|
107
|
-
const id =
|
|
137
|
+
if (tracking?.metaPixelId) {
|
|
138
|
+
const id = tracking.metaPixelId
|
|
108
139
|
scripts.push({
|
|
109
140
|
children: `!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init','${id}');fbq('track','PageView');`,
|
|
110
141
|
})
|
|
111
142
|
}
|
|
112
143
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
scripts.push({
|
|
116
|
-
src: custom.src,
|
|
117
|
-
...(custom.defer ? { defer: true } : {}),
|
|
118
|
-
...(custom.async ? { async: true } : {}),
|
|
119
|
-
})
|
|
120
|
-
} else if (custom.inline) {
|
|
121
|
-
scripts.push({ children: custom.inline })
|
|
122
|
-
}
|
|
144
|
+
if (custom?.enabled && custom.head) {
|
|
145
|
+
scripts.push({ children: custom.head })
|
|
123
146
|
}
|
|
124
147
|
|
|
125
148
|
return scripts
|
package/src/runtime/page.tsx
CHANGED
|
@@ -2,10 +2,30 @@
|
|
|
2
2
|
* Runtime interno de Ecomiq (solo lo importa el código generado en .ecomiq).
|
|
3
3
|
* Traduce el contrato público `definePage` a las opciones del router interno.
|
|
4
4
|
*/
|
|
5
|
+
import type { StorefrontSettings } from '@ecomiq/core'
|
|
5
6
|
import type { EcomiqPage, PublicEcomiqConfig } from '../index'
|
|
7
|
+
import { useSettings } from '../providers'
|
|
8
|
+
import { serverApi } from '../server'
|
|
6
9
|
import { StorefrontError } from '../ui/storefront-error'
|
|
7
10
|
import { buildPageHead } from './head'
|
|
8
11
|
|
|
12
|
+
async function resolveSettingsForMeta(
|
|
13
|
+
matches?: Array<{ loaderData?: unknown }>,
|
|
14
|
+
): Promise<StorefrontSettings | undefined> {
|
|
15
|
+
// Preferir el loader del root si ya está en el match (hidratación cliente).
|
|
16
|
+
// En SSR `matches` suele venir stale (updateMatch no muta el array), así que
|
|
17
|
+
// caemos a serverApi: el cliente core cachea settings y no hay HTTP extra.
|
|
18
|
+
const fromRoot = matches?.[0]?.loaderData
|
|
19
|
+
if (fromRoot && typeof fromRoot === 'object' && fromRoot !== null && 'store' in fromRoot) {
|
|
20
|
+
return fromRoot as StorefrontSettings
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return await serverApi.getSettings()
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
export function pageToRouteOptions<
|
|
10
30
|
TData,
|
|
11
31
|
TParams extends Record<string, string>,
|
|
@@ -40,21 +60,32 @@ export function pageToRouteOptions<
|
|
|
40
60
|
})
|
|
41
61
|
}
|
|
42
62
|
: undefined,
|
|
43
|
-
head: ({ loaderData, params, match }: {
|
|
63
|
+
head: async ({ loaderData, params, match, matches }: {
|
|
44
64
|
loaderData: TData
|
|
45
65
|
params: TParams
|
|
46
66
|
match: { search: TSearch }
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
matches?: Array<{ loaderData?: unknown }>
|
|
68
|
+
}) => {
|
|
69
|
+
// Con loader: si TanStack calcula head antes de tener datos, degrada al head global.
|
|
70
|
+
// Sin loader: loaderData es siempre undefined — aún así hay que llamar meta.
|
|
71
|
+
const waitingForLoader = Boolean(page.loader) && loaderData === undefined
|
|
72
|
+
if (waitingForLoader) return buildPageHead(undefined, undefined)
|
|
73
|
+
|
|
74
|
+
const settings = page.meta
|
|
75
|
+
? await resolveSettingsForMeta(matches)
|
|
76
|
+
: undefined
|
|
77
|
+
|
|
78
|
+
return buildPageHead(
|
|
79
|
+
page.meta?.({
|
|
80
|
+
data: loaderData,
|
|
81
|
+
params,
|
|
82
|
+
search: match.search,
|
|
83
|
+
config,
|
|
84
|
+
settings,
|
|
85
|
+
}),
|
|
86
|
+
settings,
|
|
87
|
+
)
|
|
88
|
+
},
|
|
58
89
|
pendingComponent: page.loading,
|
|
59
90
|
errorComponent: page.error
|
|
60
91
|
? ({ error, reset }: { error: Error; reset: () => void }) => page.error!({ error, reset })
|
|
@@ -77,6 +108,7 @@ export function PageBoundary<
|
|
|
77
108
|
params: TParams
|
|
78
109
|
search: TSearch
|
|
79
110
|
}) {
|
|
111
|
+
const settings = useSettings()
|
|
80
112
|
const Component = page.component
|
|
81
|
-
return <Component data={data} params={params} search={search} />
|
|
113
|
+
return <Component data={data} params={params} search={search} settings={settings} />
|
|
82
114
|
}
|
package/templates/__root.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import '../styles.css'
|
|
|
14
14
|
|
|
15
15
|
export const Route = createRootRoute({
|
|
16
16
|
loader: () => serverApi.getSettings(),
|
|
17
|
-
head: () => buildRootHead(
|
|
17
|
+
head: ({ loaderData }) => buildRootHead(loaderData),
|
|
18
18
|
component: RootComponent,
|
|
19
19
|
errorComponent: RootError,
|
|
20
20
|
notFoundComponent: NotFound,
|