@ecomiq/storefront 2.0.21 → 2.1.3

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.
@@ -2,7 +2,14 @@ import { Fragment, type CSSProperties, type ReactNode } from 'react'
2
2
  import type { Collection, ProductSummary } from '@ecomiq/core'
3
3
  import { buttonVariants } from '../ui/button'
4
4
  import { cn } from '../ui/cn'
5
+ import {
6
+ resolveStorefrontSectionStyle,
7
+ scopeStorefrontCss,
8
+ storefrontSectionCssScope,
9
+ STOREFRONT_SITE_CSS_SCOPE,
10
+ } from '@ecomiq/storefront-definition'
5
11
  import type {
12
+ StorefrontPage,
6
13
  CollectionGridStorefrontSection,
7
14
  HeroStorefrontSection,
8
15
  ImageTextStorefrontSection,
@@ -15,9 +22,20 @@ import type {
15
22
  StorefrontFontFamily,
16
23
  StorefrontImage,
17
24
  StorefrontNavigationItem,
25
+ StorefrontContentWidth,
26
+ StorefrontNamedStyle,
18
27
  StorefrontSection,
28
+ StorefrontSectionStyle,
29
+ StorefrontSpace,
19
30
  StorefrontThemeTokens,
20
31
  TestimonialsStorefrontSection,
32
+ FeatureGridStorefrontSection,
33
+ FaqStorefrontSection,
34
+ StatsStorefrontSection,
35
+ LogoWallStorefrontSection,
36
+ GalleryStorefrontSection,
37
+ ColumnsStorefrontSection,
38
+ VideoStorefrontSection,
21
39
  } from '@ecomiq/storefront-definition'
22
40
 
23
41
  export interface DefinitionData {
@@ -34,6 +52,7 @@ export interface SectionContext {
34
52
  locale: string
35
53
  currency: string
36
54
  assets: AssetManifest
55
+ styles: readonly StorefrontNamedStyle[]
37
56
  }
38
57
 
39
58
  export type AssetManifest = Readonly<Record<string, string>>
@@ -77,6 +96,17 @@ export interface SectionRegistry {
77
96
  'image-text': (
78
97
  props: SectionProps<ImageTextStorefrontSection>,
79
98
  ) => ReactNode
99
+ 'feature-grid': (
100
+ props: SectionProps<FeatureGridStorefrontSection>,
101
+ ) => ReactNode
102
+ faq: (props: SectionProps<FaqStorefrontSection>) => ReactNode
103
+ stats: (props: SectionProps<StatsStorefrontSection>) => ReactNode
104
+ 'logo-wall': (
105
+ props: SectionProps<LogoWallStorefrontSection>,
106
+ ) => ReactNode
107
+ gallery: (props: SectionProps<GalleryStorefrontSection>) => ReactNode
108
+ columns: (props: SectionProps<ColumnsStorefrontSection>) => ReactNode
109
+ video: (props: SectionProps<VideoStorefrontSection>) => ReactNode
80
110
  'rich-text': (
81
111
  props: SectionProps<RichTextStorefrontSection>,
82
112
  ) => ReactNode
@@ -90,6 +120,7 @@ export interface SectionRegistry {
90
120
 
91
121
  export interface DefinitionSectionsProps {
92
122
  definition: StorefrontDefinition
123
+ pageId?: string
93
124
  data?: DefinitionData
94
125
  locale?: string
95
126
  currency?: string
@@ -97,6 +128,16 @@ export interface DefinitionSectionsProps {
97
128
  assets?: AssetManifest
98
129
  }
99
130
 
131
+ export function storefrontPageFor(
132
+ definition: StorefrontDefinition,
133
+ pageId?: string,
134
+ ): StorefrontPage | null {
135
+ if (pageId === undefined) {
136
+ return definition.pages.find((page) => page.kind === 'home') ?? null
137
+ }
138
+ return definition.pages.find((page) => page.id === pageId) ?? null
139
+ }
140
+
100
141
  export interface DefinitionShellProps {
101
142
  definition: StorefrontDefinition
102
143
  children: ReactNode
@@ -188,6 +229,152 @@ function sectionSurfaceClass(section: StorefrontSection): string {
188
229
  return 'bg-background'
189
230
  }
190
231
 
232
+ const SPACE_VALUES: Record<StorefrontSpace, string> = {
233
+ none: '0rem',
234
+ xs: '1rem',
235
+ sm: '2rem',
236
+ md: '3.5rem',
237
+ lg: '5.5rem',
238
+ xl: '8rem',
239
+ }
240
+
241
+ const CONTENT_WIDTH_VALUES: Record<StorefrontContentWidth, string> = {
242
+ narrow: '48rem',
243
+ contained: '72rem',
244
+ wide: '90rem',
245
+ full: '100%',
246
+ }
247
+
248
+ const TEXT_STEPS: ReadonlyArray<readonly [string, number]> = [
249
+ ['--text-sm', 0.875],
250
+ ['--text-base', 1],
251
+ ['--text-lg', 1.125],
252
+ ['--text-xl', 1.25],
253
+ ['--text-2xl', 1.5],
254
+ ['--text-3xl', 1.875],
255
+ ['--text-4xl', 2.25],
256
+ ['--text-5xl', 3],
257
+ ['--text-6xl', 3.75],
258
+ ]
259
+
260
+ const FONT_SCALE_VALUES = { sm: 0.875, md: 1, lg: 1.125, xl: 1.25 }
261
+
262
+ /**
263
+ * Superficie derivada del fondo de la seccion: en vez de dejar las tarjetas y
264
+ * los bordes con el token del tema (blanco sobre un fondo oscuro), se acercan
265
+ * al color del texto lo justo para seguir leyendose como una capa aparte.
266
+ */
267
+ function lift(background: string, amount = '8%'): string {
268
+ return `color-mix(in oklab, ${background}, var(--foreground) ${amount})`
269
+ }
270
+
271
+ function readableOn(background: string): string {
272
+ const value = background.slice(1)
273
+ const channels = [0, 2, 4].map((offset) => {
274
+ const channel = Number.parseInt(value.slice(offset, offset + 2), 16) / 255
275
+ return channel <= 0.04045
276
+ ? channel / 12.92
277
+ : ((channel + 0.055) / 1.055) ** 2.4
278
+ })
279
+ const luminance =
280
+ 0.2126 * (channels[0] ?? 0) +
281
+ 0.7152 * (channels[1] ?? 0) +
282
+ 0.0722 * (channels[2] ?? 0)
283
+ return luminance > 0.36 ? '#0b0b0c' : '#ffffff'
284
+ }
285
+
286
+ function sectionFrameStyle(
287
+ style: StorefrontSectionStyle,
288
+ assets: AssetManifest,
289
+ ): CSSProperties {
290
+ const variables: Record<string, string> = {}
291
+
292
+ const backgroundImageSrc = storefrontImageSrc(style.backgroundImage, assets)
293
+ if (style.background) {
294
+ variables['--background'] = style.background
295
+ variables['--muted'] = style.background
296
+ variables['--accent'] = style.background
297
+ variables['--card'] = lift(style.background)
298
+ variables['--secondary'] = lift(style.background)
299
+ variables['--card-foreground'] = 'var(--foreground)'
300
+ variables['--secondary-foreground'] = 'var(--foreground)'
301
+ if (!style.border) variables['--border'] = lift(style.background, '18%')
302
+ }
303
+ if (backgroundImageSrc) {
304
+ variables['--background'] = 'transparent'
305
+ variables['--muted'] = 'transparent'
306
+ variables['--accent'] = 'transparent'
307
+ }
308
+ if (style.text) {
309
+ variables['--foreground'] = style.text
310
+ variables['--accent-foreground'] = style.text
311
+ variables['--muted-foreground'] =
312
+ `color-mix(in oklab, ${style.text} 68%, transparent)`
313
+ }
314
+ if (style.accent) {
315
+ variables['--primary'] = style.accent
316
+ variables['--primary-foreground'] = readableOn(style.accent)
317
+ variables['--ring'] = style.accent
318
+ }
319
+ if (style.border) {
320
+ variables['--border'] = style.border
321
+ variables['--input'] = style.border
322
+ }
323
+ if (style.radius) variables['--radius'] = radiusValue(style.radius)
324
+ if (style.paddingTop) {
325
+ variables['--storefront-section-padding-top'] = SPACE_VALUES[style.paddingTop]
326
+ }
327
+ if (style.paddingBottom) {
328
+ variables['--storefront-section-padding-bottom'] =
329
+ SPACE_VALUES[style.paddingBottom]
330
+ }
331
+ if (style.width) {
332
+ variables['--storefront-content-width'] = CONTENT_WIDTH_VALUES[style.width]
333
+ }
334
+ if (style.fontScale && style.fontScale !== 'md') {
335
+ const factor = FONT_SCALE_VALUES[style.fontScale]
336
+ for (const [token, rem] of TEXT_STEPS) {
337
+ variables[token] = `${(rem * factor).toFixed(4)}rem`
338
+ }
339
+ }
340
+ if (backgroundImageSrc) {
341
+ variables.backgroundImage = `url("${backgroundImageSrc}")`
342
+ variables.backgroundSize = 'cover'
343
+ variables.backgroundPosition = 'center'
344
+ }
345
+
346
+ return variables as CSSProperties
347
+ }
348
+
349
+ function sectionFrameClass(
350
+ style: StorefrontSectionStyle,
351
+ ): string | undefined {
352
+ if (style.visibility === 'desktop') return 'hidden md:block'
353
+ if (style.visibility === 'mobile') return 'md:hidden'
354
+ return undefined
355
+ }
356
+
357
+ export function storefrontPageCss(
358
+ page: StorefrontPage,
359
+ styles: readonly StorefrontNamedStyle[],
360
+ ): string {
361
+ return page.sections
362
+ .filter((section) => section.enabled)
363
+ .map((section) => {
364
+ const css = resolveStorefrontSectionStyle(section, styles).css
365
+ return css
366
+ ? scopeStorefrontCss(css, storefrontSectionCssScope(section.id))
367
+ : ''
368
+ })
369
+ .filter((css) => css.length > 0)
370
+ .join('\n')
371
+ }
372
+
373
+ function StorefrontCustomStyle({ css }: { css: string }) {
374
+ if (css.length === 0) return null
375
+ return <style dangerouslySetInnerHTML={{ __html: css }} />
376
+ }
377
+
191
378
  function StorefrontActionLink({
192
379
  action,
193
380
  variant = 'default',
@@ -269,7 +456,7 @@ function HeroSectionRenderer({
269
456
  ) : null}
270
457
  <div
271
458
  className={cn(
272
- 'mx-auto flex min-h-[inherit] w-full max-w-6xl items-center px-4',
459
+ 'mx-auto flex min-h-[inherit] w-full max-w-(--storefront-content-width) items-center px-4',
273
460
  isCentered && 'justify-center text-center',
274
461
  )}
275
462
  >
@@ -281,18 +468,19 @@ function HeroSectionRenderer({
281
468
  )}
282
469
  >
283
470
  {section.eyebrow ? (
284
- <p className="mb-4 text-xs font-semibold uppercase tracking-[0.16em] opacity-75">
471
+ <p className="mb-4 text-xs font-semibold uppercase tracking-[0.16em] opacity-75" data-storefront-text="eyebrow">
285
472
  {section.eyebrow}
286
473
  </p>
287
474
  ) : null}
288
475
  <h1
289
476
  id={`${section.id}-title`}
477
+ data-storefront-text="title"
290
478
  className="text-balance text-4xl font-semibold leading-[1.02] tracking-[-0.022em] sm:text-5xl lg:text-6xl"
291
479
  >
292
480
  {section.title}
293
481
  </h1>
294
482
  {section.description ? (
295
- <p className="mt-5 max-w-[65ch] text-pretty text-base leading-relaxed opacity-80 sm:text-lg">
483
+ <p className="mt-5 max-w-[65ch] text-pretty text-base leading-relaxed opacity-80 sm:text-lg" data-storefront-text="description">
296
484
  {section.description}
297
485
  </p>
298
486
  ) : null}
@@ -334,12 +522,13 @@ function SectionHeading({
334
522
  <header className="mb-8 max-w-2xl">
335
523
  <h2
336
524
  id={id}
525
+ data-storefront-text="title"
337
526
  className="text-balance text-3xl font-semibold tracking-[-0.022em]"
338
527
  >
339
528
  {title}
340
529
  </h2>
341
530
  {description ? (
342
- <p className="mt-3 text-pretty leading-relaxed text-muted-foreground">
531
+ <p className="mt-3 text-pretty leading-relaxed text-muted-foreground" data-storefront-text="description">
343
532
  {description}
344
533
  </p>
345
534
  ) : null}
@@ -369,7 +558,7 @@ function ProductGridSectionRenderer({
369
558
  aria-labelledby={`${section.id}-title`}
370
559
  data-storefront-section={section.id}
371
560
  >
372
- <div className="mx-auto w-full max-w-6xl px-4">
561
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
373
562
  <SectionHeading
374
563
  id={`${section.id}-title`}
375
564
  title={section.title}
@@ -447,7 +636,7 @@ function CollectionGridSectionRenderer({
447
636
  aria-labelledby={`${section.id}-title`}
448
637
  data-storefront-section={section.id}
449
638
  >
450
- <div className="mx-auto w-full max-w-6xl px-4">
639
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
451
640
  <SectionHeading
452
641
  id={`${section.id}-title`}
453
642
  title={section.title}
@@ -510,21 +699,22 @@ function PromoBannerSectionRenderer({
510
699
  aria-labelledby={`${section.id}-title`}
511
700
  data-storefront-section={section.id}
512
701
  >
513
- <div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:flex-row sm:items-end sm:justify-between">
702
+ <div className="mx-auto flex w-full max-w-(--storefront-content-width) flex-col gap-6 px-4 sm:flex-row sm:items-end sm:justify-between">
514
703
  <div className="max-w-3xl">
515
704
  {section.eyebrow ? (
516
- <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] opacity-75">
705
+ <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] opacity-75" data-storefront-text="eyebrow">
517
706
  {section.eyebrow}
518
707
  </p>
519
708
  ) : null}
520
709
  <h2
521
710
  id={`${section.id}-title`}
711
+ data-storefront-text="title"
522
712
  className="text-balance text-3xl font-semibold tracking-[-0.022em]"
523
713
  >
524
714
  {section.title}
525
715
  </h2>
526
716
  {section.description ? (
527
- <p className="mt-3 max-w-[65ch] text-pretty leading-relaxed opacity-80">
717
+ <p className="mt-3 max-w-[65ch] text-pretty leading-relaxed opacity-80" data-storefront-text="description">
528
718
  {section.description}
529
719
  </p>
530
720
  ) : null}
@@ -555,7 +745,7 @@ function ImageTextSectionRenderer({
555
745
  aria-labelledby={`${section.id}-title`}
556
746
  data-storefront-section={section.id}
557
747
  >
558
- <div className="mx-auto grid w-full max-w-6xl items-center gap-8 px-4 md:grid-cols-2 md:gap-12">
748
+ <div className="mx-auto grid w-full max-w-(--storefront-content-width) items-center gap-8 px-4 md:grid-cols-2 md:gap-12">
559
749
  <img
560
750
  src={imageSrc}
561
751
  alt={storefrontImageAlt(section.image, section.imageAlt)}
@@ -569,17 +759,18 @@ function ImageTextSectionRenderer({
569
759
  />
570
760
  <div className={cn(!imageFirst && 'md:order-1')}>
571
761
  {section.eyebrow ? (
572
- <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
762
+ <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground" data-storefront-text="eyebrow">
573
763
  {section.eyebrow}
574
764
  </p>
575
765
  ) : null}
576
766
  <h2
577
767
  id={`${section.id}-title`}
768
+ data-storefront-text="title"
578
769
  className="text-balance text-3xl font-semibold tracking-[-0.022em]"
579
770
  >
580
771
  {section.title}
581
772
  </h2>
582
- <p className="mt-4 max-w-[65ch] whitespace-pre-line text-pretty leading-relaxed text-muted-foreground">
773
+ <p className="mt-4 max-w-[65ch] whitespace-pre-line text-pretty leading-relaxed text-muted-foreground" data-storefront-text="body">
583
774
  {section.body}
584
775
  </p>
585
776
  {section.action ? (
@@ -615,6 +806,7 @@ function RichTextSectionRenderer({
615
806
  {section.title ? (
616
807
  <h2
617
808
  id={`${section.id}-title`}
809
+ data-storefront-text="title"
618
810
  className="text-balance text-3xl font-semibold tracking-[-0.022em]"
619
811
  >
620
812
  {section.title}
@@ -653,7 +845,7 @@ function TestimonialsSectionRenderer({
653
845
  aria-labelledby={`${section.id}-title`}
654
846
  data-storefront-section={section.id}
655
847
  >
656
- <div className="mx-auto w-full max-w-6xl px-4">
848
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
657
849
  <SectionHeading
658
850
  id={`${section.id}-title`}
659
851
  title={section.title}
@@ -662,7 +854,7 @@ function TestimonialsSectionRenderer({
662
854
  <div
663
855
  className={cn('grid gap-5', testimonialColumnsClass(section.columns))}
664
856
  >
665
- {section.items.map((item) => (
857
+ {section.blocks.map((item) => (
666
858
  <figure
667
859
  key={item.id}
668
860
  className="flex h-full flex-col justify-between border-t pt-6 text-foreground"
@@ -698,21 +890,22 @@ function NewsletterSectionRenderer({
698
890
  aria-labelledby={`${section.id}-title`}
699
891
  data-storefront-section={section.id}
700
892
  >
701
- <div className="mx-auto grid w-full max-w-6xl gap-7 px-4 md:grid-cols-[1fr_minmax(280px,440px)] md:items-end">
893
+ <div className="mx-auto grid w-full max-w-(--storefront-content-width) gap-7 px-4 md:grid-cols-[1fr_minmax(280px,440px)] md:items-end">
702
894
  <div className="max-w-2xl">
703
895
  {section.eyebrow ? (
704
- <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
896
+ <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground" data-storefront-text="eyebrow">
705
897
  {section.eyebrow}
706
898
  </p>
707
899
  ) : null}
708
900
  <h2
709
901
  id={`${section.id}-title`}
902
+ data-storefront-text="title"
710
903
  className="text-balance text-3xl font-semibold tracking-[-0.022em]"
711
904
  >
712
905
  {section.title}
713
906
  </h2>
714
907
  {section.description ? (
715
- <p className="mt-3 text-pretty leading-relaxed text-muted-foreground">
908
+ <p className="mt-3 text-pretty leading-relaxed text-muted-foreground" data-storefront-text="description">
716
909
  {section.description}
717
910
  </p>
718
911
  ) : null}
@@ -742,6 +935,293 @@ function NewsletterSectionRenderer({
742
935
  )
743
936
  }
744
937
 
938
+
939
+ function FeatureGridSectionRenderer({
940
+ section,
941
+ }: SectionProps<FeatureGridStorefrontSection>) {
942
+ return (
943
+ <section
944
+ className={cn(
945
+ 'storefront-definition-section',
946
+ sectionSurfaceClass(section),
947
+ )}
948
+ data-storefront-section={section.id}
949
+ >
950
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
951
+ {section.title ? (
952
+ <SectionHeading
953
+ id={`${section.id}-title`}
954
+ title={section.title}
955
+ description={section.description}
956
+ />
957
+ ) : null}
958
+ <div className={cn('grid gap-6', gridColumnsClass(section.columns))}>
959
+ {section.blocks.map((block) => (
960
+ <div key={block.id}>
961
+ <h3
962
+ className="text-base font-semibold"
963
+ data-storefront-text="title"
964
+ >
965
+ {block.title}
966
+ </h3>
967
+ {block.description ? (
968
+ <p
969
+ className="mt-2 text-pretty text-sm leading-relaxed text-muted-foreground"
970
+ data-storefront-text="description"
971
+ >
972
+ {block.description}
973
+ </p>
974
+ ) : null}
975
+ </div>
976
+ ))}
977
+ </div>
978
+ </div>
979
+ </section>
980
+ )
981
+ }
982
+
983
+ function FaqSectionRenderer({ section }: SectionProps<FaqStorefrontSection>) {
984
+ return (
985
+ <section
986
+ className={cn(
987
+ 'storefront-definition-section',
988
+ sectionSurfaceClass(section),
989
+ )}
990
+ aria-labelledby={`${section.id}-title`}
991
+ data-storefront-section={section.id}
992
+ >
993
+ <div className="mx-auto w-full max-w-3xl px-4">
994
+ <SectionHeading
995
+ id={`${section.id}-title`}
996
+ title={section.title}
997
+ description={section.description}
998
+ />
999
+ <dl className="divide-y border-t">
1000
+ {section.blocks.map((block) => (
1001
+ <div key={block.id} className="py-5">
1002
+ <dt className="text-base font-semibold">{block.question}</dt>
1003
+ <dd className="mt-2 whitespace-pre-line text-pretty leading-relaxed text-muted-foreground">
1004
+ {block.answer}
1005
+ </dd>
1006
+ </div>
1007
+ ))}
1008
+ </dl>
1009
+ </div>
1010
+ </section>
1011
+ )
1012
+ }
1013
+
1014
+ function StatsSectionRenderer({
1015
+ section,
1016
+ }: SectionProps<StatsStorefrontSection>) {
1017
+ return (
1018
+ <section
1019
+ className={cn(
1020
+ 'storefront-definition-section',
1021
+ sectionSurfaceClass(section),
1022
+ )}
1023
+ data-storefront-section={section.id}
1024
+ >
1025
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
1026
+ {section.title ? (
1027
+ <SectionHeading id={`${section.id}-title`} title={section.title} />
1028
+ ) : null}
1029
+ <dl className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
1030
+ {section.blocks.map((block) => (
1031
+ <div key={block.id}>
1032
+ <dt className="text-3xl font-semibold tracking-[-0.02em]">
1033
+ {block.value}
1034
+ </dt>
1035
+ <dd className="mt-1 text-sm text-muted-foreground">
1036
+ {block.label}
1037
+ </dd>
1038
+ </div>
1039
+ ))}
1040
+ </dl>
1041
+ </div>
1042
+ </section>
1043
+ )
1044
+ }
1045
+
1046
+ function LogoWallSectionRenderer({
1047
+ section,
1048
+ context,
1049
+ }: SectionProps<LogoWallStorefrontSection>) {
1050
+ return (
1051
+ <section
1052
+ className={cn(
1053
+ 'storefront-definition-section',
1054
+ sectionSurfaceClass(section),
1055
+ )}
1056
+ data-storefront-section={section.id}
1057
+ >
1058
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
1059
+ {section.title ? (
1060
+ <SectionHeading id={`${section.id}-title`} title={section.title} />
1061
+ ) : null}
1062
+ <ul className="flex flex-wrap items-center justify-center gap-x-10 gap-y-6">
1063
+ {section.blocks.map((block) => {
1064
+ const src = storefrontImageSrc(block.image, context.assets)
1065
+ if (!src) return null
1066
+ return (
1067
+ <li key={block.id}>
1068
+ <img
1069
+ alt={block.alt}
1070
+ className="h-8 w-auto opacity-70 transition-opacity hover:opacity-100"
1071
+ loading="lazy"
1072
+ src={src}
1073
+ />
1074
+ </li>
1075
+ )
1076
+ })}
1077
+ </ul>
1078
+ </div>
1079
+ </section>
1080
+ )
1081
+ }
1082
+
1083
+ function GallerySectionRenderer({
1084
+ section,
1085
+ context,
1086
+ }: SectionProps<GalleryStorefrontSection>) {
1087
+ return (
1088
+ <section
1089
+ className={cn(
1090
+ 'storefront-definition-section',
1091
+ sectionSurfaceClass(section),
1092
+ )}
1093
+ data-storefront-section={section.id}
1094
+ >
1095
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
1096
+ {section.title ? (
1097
+ <SectionHeading
1098
+ id={`${section.id}-title`}
1099
+ title={section.title}
1100
+ description={section.description}
1101
+ />
1102
+ ) : null}
1103
+ <div className={cn('grid gap-3', gridColumnsClass(section.columns))}>
1104
+ {section.blocks.map((block) => {
1105
+ const src = storefrontImageSrc(block.image, context.assets)
1106
+ if (!src) return null
1107
+ return (
1108
+ <figure key={block.id}>
1109
+ <img
1110
+ alt={block.alt}
1111
+ className="aspect-[4/3] w-full rounded-md object-cover"
1112
+ loading="lazy"
1113
+ src={src}
1114
+ />
1115
+ {block.caption ? (
1116
+ <figcaption className="mt-2 text-xs text-muted-foreground">
1117
+ {block.caption}
1118
+ </figcaption>
1119
+ ) : null}
1120
+ </figure>
1121
+ )
1122
+ })}
1123
+ </div>
1124
+ </div>
1125
+ </section>
1126
+ )
1127
+ }
1128
+
1129
+ function ColumnsSectionRenderer({
1130
+ section,
1131
+ context,
1132
+ }: SectionProps<ColumnsStorefrontSection>) {
1133
+ const columns = Math.min(Math.max(section.blocks.length, 2), 4) as 2 | 3 | 4
1134
+ return (
1135
+ <section
1136
+ className={cn(
1137
+ 'storefront-definition-section',
1138
+ sectionSurfaceClass(section),
1139
+ )}
1140
+ data-storefront-section={section.id}
1141
+ >
1142
+ <div className="mx-auto w-full max-w-(--storefront-content-width) px-4">
1143
+ {section.title ? (
1144
+ <SectionHeading
1145
+ id={`${section.id}-title`}
1146
+ title={section.title}
1147
+ description={section.description}
1148
+ />
1149
+ ) : null}
1150
+ <div className={cn('grid gap-8', gridColumnsClass(columns))}>
1151
+ {section.blocks.map((block) => {
1152
+ const src = storefrontImageSrc(block.image, context.assets)
1153
+ return (
1154
+ <div key={block.id}>
1155
+ {src ? (
1156
+ <img
1157
+ alt={block.imageAlt ?? ''}
1158
+ className="mb-4 aspect-[4/3] w-full rounded-md object-cover"
1159
+ loading="lazy"
1160
+ src={src}
1161
+ />
1162
+ ) : null}
1163
+ <h3
1164
+ className="text-lg font-semibold"
1165
+ data-storefront-text="title"
1166
+ >
1167
+ {block.title}
1168
+ </h3>
1169
+ {block.body ? (
1170
+ <p className="mt-2 text-pretty leading-relaxed text-muted-foreground">
1171
+ {block.body}
1172
+ </p>
1173
+ ) : null}
1174
+ {block.action ? (
1175
+ <div className="mt-4">
1176
+ <StorefrontActionLink action={block.action} />
1177
+ </div>
1178
+ ) : null}
1179
+ </div>
1180
+ )
1181
+ })}
1182
+ </div>
1183
+ </div>
1184
+ </section>
1185
+ )
1186
+ }
1187
+
1188
+ function VideoSectionRenderer({
1189
+ section,
1190
+ context,
1191
+ }: SectionProps<VideoStorefrontSection>) {
1192
+ const poster = storefrontImageSrc(section.poster, context.assets)
1193
+ return (
1194
+ <section
1195
+ className={cn(
1196
+ 'storefront-definition-section',
1197
+ sectionSurfaceClass(section),
1198
+ )}
1199
+ data-storefront-section={section.id}
1200
+ >
1201
+ <div className="mx-auto w-full max-w-4xl px-4">
1202
+ {section.title ? (
1203
+ <SectionHeading
1204
+ id={`${section.id}-title`}
1205
+ title={section.title}
1206
+ description={section.description}
1207
+ />
1208
+ ) : null}
1209
+ <div className="overflow-hidden rounded-lg border bg-muted">
1210
+ <video
1211
+ className="aspect-video w-full"
1212
+ controls
1213
+ preload="none"
1214
+ {...(poster ? { poster } : {})}
1215
+ src={section.url}
1216
+ >
1217
+ {section.posterAlt ?? ''}
1218
+ </video>
1219
+ </div>
1220
+ </div>
1221
+ </section>
1222
+ )
1223
+ }
1224
+
745
1225
  export const defaultStorefrontSectionRegistry: SectionRegistry = {
746
1226
  hero: HeroSectionRenderer,
747
1227
  'product-grid': ProductGridSectionRenderer,
@@ -751,12 +1231,70 @@ export const defaultStorefrontSectionRegistry: SectionRegistry = {
751
1231
  'rich-text': RichTextSectionRenderer,
752
1232
  testimonials: TestimonialsSectionRenderer,
753
1233
  newsletter: NewsletterSectionRenderer,
1234
+ 'feature-grid': FeatureGridSectionRenderer,
1235
+ faq: FaqSectionRenderer,
1236
+ stats: StatsSectionRenderer,
1237
+ 'logo-wall': LogoWallSectionRenderer,
1238
+ gallery: GallerySectionRenderer,
1239
+ columns: ColumnsSectionRenderer,
1240
+ video: VideoSectionRenderer,
1241
+ }
1242
+
1243
+ function StorefrontSectionFrame({
1244
+ section,
1245
+ context,
1246
+ children,
1247
+ }: {
1248
+ section: StorefrontSection
1249
+ context: SectionContext
1250
+ children: ReactNode
1251
+ }) {
1252
+ const resolved = resolveStorefrontSectionStyle(section, context.styles)
1253
+ const style = sectionFrameStyle(resolved, context.assets)
1254
+ const className = sectionFrameClass(resolved)
1255
+ const overlay = resolved.backgroundOverlay
1256
+ const hasFrame =
1257
+ Object.keys(style).length > 0 || className !== undefined || Boolean(overlay)
1258
+ if (!hasFrame) return children
1259
+
1260
+ return (
1261
+ <div
1262
+ className={cn(
1263
+ 'storefront-definition-frame text-foreground',
1264
+ Boolean(resolved.backgroundImage) && 'relative isolate',
1265
+ className,
1266
+ )}
1267
+ style={style}
1268
+ data-storefront-section-frame={section.id}
1269
+ >
1270
+ {overlay ? (
1271
+ <div
1272
+ aria-hidden
1273
+ className="absolute inset-0 -z-10 bg-black"
1274
+ style={{ opacity: overlay }}
1275
+ />
1276
+ ) : null}
1277
+ {children}
1278
+ </div>
1279
+ )
754
1280
  }
755
1281
 
756
1282
  export function renderStorefrontSection(
757
1283
  section: StorefrontSection,
758
1284
  registry: SectionRegistry,
759
1285
  context: SectionContext,
1286
+ ): ReactNode {
1287
+ return (
1288
+ <StorefrontSectionFrame section={section} context={context}>
1289
+ {renderStorefrontSectionBody(section, registry, context)}
1290
+ </StorefrontSectionFrame>
1291
+ )
1292
+ }
1293
+
1294
+ function renderStorefrontSectionBody(
1295
+ section: StorefrontSection,
1296
+ registry: SectionRegistry,
1297
+ context: SectionContext,
760
1298
  ): ReactNode {
761
1299
  if (section.type === 'hero') return registry.hero({ section, context })
762
1300
  if (section.type === 'product-grid') {
@@ -777,6 +1315,27 @@ export function renderStorefrontSection(
777
1315
  if (section.type === 'testimonials') {
778
1316
  return registry.testimonials({ section, context })
779
1317
  }
1318
+ if (section.type === 'feature-grid') {
1319
+ return registry['feature-grid']({ section, context })
1320
+ }
1321
+ if (section.type === 'faq') {
1322
+ return registry.faq({ section, context })
1323
+ }
1324
+ if (section.type === 'stats') {
1325
+ return registry.stats({ section, context })
1326
+ }
1327
+ if (section.type === 'logo-wall') {
1328
+ return registry['logo-wall']({ section, context })
1329
+ }
1330
+ if (section.type === 'gallery') {
1331
+ return registry.gallery({ section, context })
1332
+ }
1333
+ if (section.type === 'columns') {
1334
+ return registry.columns({ section, context })
1335
+ }
1336
+ if (section.type === 'video') {
1337
+ return registry.video({ section, context })
1338
+ }
780
1339
  return registry.newsletter({ section, context })
781
1340
  }
782
1341
 
@@ -795,28 +1354,28 @@ function StorefrontChrome({
795
1354
  >
796
1355
  Ir al contenido
797
1356
  </a>
798
- {definition.navigation.items.length > 0 ? (
799
- <header className="border-b bg-background">
1357
+ {definition.site.navigation.items.length > 0 ? (
1358
+ <header className="border-b bg-background" data-storefront-chrome="navigation">
800
1359
  <nav
801
- className="mx-auto flex min-h-14 w-full max-w-6xl flex-wrap items-center gap-x-4 px-4"
1360
+ className="mx-auto flex min-h-14 w-full max-w-(--storefront-content-width) flex-wrap items-center gap-x-4 px-4"
802
1361
  aria-label="Navegación principal"
803
1362
  >
804
- {definition.navigation.items.map((item) => (
1363
+ {definition.site.navigation.items.map((item) => (
805
1364
  <StorefrontNavigationLink key={item.id} item={item} />
806
1365
  ))}
807
1366
  </nav>
808
1367
  </header>
809
1368
  ) : null}
810
1369
  <main id="storefront-definition-main">{children}</main>
811
- <footer className="border-t bg-muted/40">
812
- <div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
813
- {definition.footer.text ? <p>{definition.footer.text}</p> : null}
814
- {definition.footer.items.length > 0 ? (
1370
+ <footer className="border-t bg-muted/40" data-storefront-chrome="footer">
1371
+ <div className="mx-auto flex w-full max-w-(--storefront-content-width) flex-col gap-4 px-4 py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
1372
+ {definition.site.footer.text ? <p>{definition.site.footer.text}</p> : null}
1373
+ {definition.site.footer.items.length > 0 ? (
815
1374
  <nav
816
1375
  className="flex flex-wrap gap-x-3"
817
1376
  aria-label="Navegación del pie"
818
1377
  >
819
- {definition.footer.items.map((item) => (
1378
+ {definition.site.footer.items.map((item) => (
820
1379
  <StorefrontNavigationLink key={item.id} item={item} />
821
1380
  ))}
822
1381
  </nav>
@@ -829,6 +1388,7 @@ function StorefrontChrome({
829
1388
 
830
1389
  export function DefinitionSections({
831
1390
  definition,
1391
+ pageId,
832
1392
  data = {},
833
1393
  locale = 'es-PE',
834
1394
  currency = 'PEN',
@@ -840,15 +1400,26 @@ export function DefinitionSections({
840
1400
  locale,
841
1401
  currency,
842
1402
  assets,
1403
+ styles: definition.site.styles,
843
1404
  }
844
1405
 
845
- return definition.sections
846
- .filter((section) => section.enabled)
847
- .map((section) => (
848
- <Fragment key={section.id}>
849
- {renderStorefrontSection(section, registry, context)}
850
- </Fragment>
851
- ))
1406
+ const page = storefrontPageFor(definition, pageId)
1407
+ if (!page) return null
1408
+
1409
+ return (
1410
+ <>
1411
+ <StorefrontCustomStyle
1412
+ css={storefrontPageCss(page, definition.site.styles)}
1413
+ />
1414
+ {page.sections
1415
+ .filter((section) => section.enabled)
1416
+ .map((section) => (
1417
+ <Fragment key={section.id}>
1418
+ {renderStorefrontSection(section, registry, context)}
1419
+ </Fragment>
1420
+ ))}
1421
+ </>
1422
+ )
852
1423
  }
853
1424
 
854
1425
  export function DefinitionShell({
@@ -869,10 +1440,13 @@ export function DefinitionShell({
869
1440
  'storefront-definition min-h-full bg-background text-foreground antialiased',
870
1441
  className,
871
1442
  )}
872
- style={themeStyle(definition.theme)}
1443
+ style={themeStyle(definition.site.theme)}
873
1444
  data-storefront-definition-version={definition.version}
874
1445
  data-storefront-definition-revision={definition.revision}
875
1446
  >
1447
+ <StorefrontCustomStyle
1448
+ css={scopeStorefrontCss(definition.site.css, STOREFRONT_SITE_CSS_SCOPE)}
1449
+ />
876
1450
  {content}
877
1451
  </div>
878
1452
  )
@@ -880,6 +1454,7 @@ export function DefinitionShell({
880
1454
 
881
1455
  export function DefinitionRenderer({
882
1456
  definition,
1457
+ pageId,
883
1458
  data,
884
1459
  locale,
885
1460
  currency,
@@ -895,6 +1470,7 @@ export function DefinitionRenderer({
895
1470
  >
896
1471
  <DefinitionSections
897
1472
  definition={definition}
1473
+ pageId={pageId}
898
1474
  data={data}
899
1475
  locale={locale}
900
1476
  currency={currency}