@ecomiq/storefront 2.0.9 → 2.0.11

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/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'
@@ -202,12 +199,14 @@ export interface PageProps<
202
199
  data: TData
203
200
  params: TParams
204
201
  search: TSearch
202
+ /** Settings storefront (CMS, store, menus). Cargado en el root; sin request extra. */
203
+ settings: StorefrontSettings
205
204
  }
206
205
 
207
206
  export interface PageMeta {
208
207
  title?: string
209
208
  description?: string
210
- /** Imagen OG específica de esta página (sobreescribe seo.ogImage). */
209
+ /** Imagen OG específica de esta página (sobreescribe branding.socialImageUrl). */
211
210
  ogImage?: string
212
211
  /** Marca la página como noindex,nofollow. */
213
212
  noIndex?: boolean
@@ -254,6 +253,11 @@ export interface EcomiqPage<
254
253
  params: TParams
255
254
  search: TSearch
256
255
  config: PublicEcomiqConfig
256
+ /**
257
+ * Settings storefront cargados en el root. Opcional: puede faltar si el
258
+ * `<head>` se calcula antes de que el match raíz tenga sus datos.
259
+ */
260
+ settings?: StorefrontSettings
257
261
  }) => PageMeta
258
262
  /** UI de la página. */
259
263
  component: (props: PageProps<TData, TParams, TSearch>) => ReactNode
@@ -7,38 +7,18 @@ export interface EcomiqThemeConfig {
7
7
  radius?: string
8
8
  }
9
9
 
10
- export interface EcomiqSeoConfig {
11
- titleTemplate?: string
12
- keywords?: string[]
13
- favicon?: string
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
- name: string
35
- /** Identidad de tienda para x-Store-Domain (ej. 'marketplace'). */
36
- slug: string
37
- description?: string
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, config, settings }: LayoutProps) {
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
- : (layout.nav ?? [{ label: 'Productos', to: '/' }]).map((item) => ({
23
- ...item,
24
- target: null as string | null | undefined,
25
- }))
15
+ : [{ label: 'Productos', to: '/', target: null as string | null }]
26
16
 
27
- const sticky = layout.stickyHeader !== false
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>{layout.footerText ?? `${storeName} — creado con Ecomiq`}</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) => (
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Runtime interno: construcción del <head>.
3
- * Mezcla el SEO global de ecomiq.config.js con el `meta` de cada página
4
- * (la página siempre gana) y genera los scripts de analytics declarados.
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 { PageMeta, PublicEcomiqConfig } from '../index'
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
- /** Aplica titleTemplate ('%s · Tienda') o cae al patrón `Título · Tienda`. */
26
- export function resolveTitle(pageTitle: string | undefined, config: PublicEcomiqConfig): string {
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
- /** Head global: charset, viewport, favicon, metas/links extra y analytics. Va en el root. */
34
- export function buildRootHead(config: PublicEcomiqConfig): HeadResult {
35
- const seo = config.seo ?? {}
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.keywords?.length) meta.push({ name: 'keywords', content: seo.keywords.join(', ') })
42
- for (const extra of seo.meta ?? []) meta.push(extra)
65
+ if (seo?.robotsIndex === false) {
66
+ meta.push({ name: 'robots', content: 'noindex, nofollow' })
67
+ }
43
68
 
44
- const links: LinkTag[] = [{ rel: 'icon', href: seo.favicon ?? '/favicon.ico' }]
45
- for (const extra of seo.links ?? []) links.push(extra)
69
+ const links: LinkTag[] = [
70
+ { rel: 'icon', href: branding?.faviconUrl ?? '/favicon.ico' },
71
+ ]
46
72
 
47
- return { meta, links, scripts: buildAnalyticsScripts(config) }
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
- config: PublicEcomiqConfig,
79
+ settings: StorefrontSettings | null | undefined,
54
80
  ): HeadResult {
55
- const seo = config.seo ?? {}
56
- const title = resolveTitle(pageMeta?.title, config)
57
- const description = pageMeta?.description ?? config.description
58
- const ogImage = pageMeta?.ogImage ?? seo.ogImage
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) meta.push({ name: 'robots', content: 'noindex, nofollow' })
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: config.name })
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: seo.twitterCard ?? 'summary_large_image' })
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 el bloque `analytics` del config a etiquetas <script>. */
86
- export function buildAnalyticsScripts(config: PublicEcomiqConfig): ScriptTag[] {
87
- const analytics = config.analytics ?? {}
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 (analytics.googleAnalytics) {
91
- const id = analytics.googleAnalytics
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 (analytics.plausible) {
130
+ if (tracking?.googleTagManagerContainerId) {
131
+ const id = tracking.googleTagManagerContainerId
99
132
  scripts.push({
100
- src: 'https://plausible.io/js/script.js',
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 (analytics.metaPixel) {
107
- const id = analytics.metaPixel
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
- for (const custom of analytics.scripts ?? []) {
114
- if (custom.src) {
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
@@ -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
- }) => buildPageHead(
48
- loaderData === undefined
49
- ? undefined
50
- : page.meta?.({
51
- data: loaderData,
52
- params,
53
- search: match.search,
54
- config,
55
- }),
56
- config,
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
  }
@@ -2,7 +2,7 @@ import type { CreateApiOptions } from '@ecomiq/core'
2
2
  import { createServerOnlyFn } from '@tanstack/react-start'
3
3
 
4
4
  export interface ServerEcomiqConfig {
5
- slug: string
5
+ domain: string
6
6
  api?: CreateApiOptions
7
7
  }
8
8
 
@@ -14,7 +14,7 @@ import '../styles.css'
14
14
 
15
15
  export const Route = createRootRoute({
16
16
  loader: () => serverApi.getSettings(),
17
- head: () => buildRootHead(publicConfig),
17
+ head: ({ loaderData }) => buildRootHead(loaderData),
18
18
  component: RootComponent,
19
19
  errorComponent: RootError,
20
20
  notFoundComponent: NotFound,