@salifbiaye/create-boutique 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +5 -2
  2. package/templates/storefront/locales/en.json +16 -1
  3. package/templates/storefront/locales/fr.json +16 -1
  4. package/templates/storefront/src/app/(main)/page.tsx +123 -0
  5. package/templates/storefront/src/app/layout.tsx +5 -0
  6. package/templates/storefront/src/lib/boutique/schema.ts +1 -1
  7. package/templates/storefront/src/modules/ub/album.tsx +137 -0
  8. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
  9. package/templates/storefront/src/modules/ub/category-tiles.tsx +2 -1
  10. package/templates/storefront/src/modules/ub/cinema.tsx +180 -0
  11. package/templates/storefront/src/modules/ub/fade-img.tsx +35 -0
  12. package/templates/storefront/src/modules/ub/gala.tsx +178 -0
  13. package/templates/storefront/src/modules/ub/harvest.tsx +6 -2
  14. package/templates/storefront/src/modules/ub/product-card.tsx +2 -1
  15. package/templates/storefront/src/modules/ub/showcase.tsx +3 -2
  16. package/templates/storefront/src/modules/ub/tech.tsx +279 -0
  17. package/templates/storefront/src/styles/ub/album.css +205 -0
  18. package/templates/storefront/src/styles/ub/cinema.css +169 -0
  19. package/templates/storefront/src/styles/ub/gala.css +242 -0
  20. package/templates/storefront/src/styles/ub/harvest.css +2 -1
  21. package/templates/storefront/src/styles/ub/media.css +37 -0
  22. package/templates/storefront/src/styles/ub/tech.css +421 -0
  23. package/templates/storefront/src/styles/ub/variants.css +1 -5
  24. package/templates/storefront/themes/cyber-monday/theme.json +4 -3
  25. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +1 -1
  26. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +1 -1
  27. package/templates/storefront/themes/cyber-monday-2/theme.json +5 -4
  28. package/templates/storefront/themes/halloween/theme.json +3 -2
  29. package/templates/storefront/themes/halloween-2/theme.json +2 -1
  30. package/templates/storefront/themes/noel/theme.json +3 -2
  31. package/templates/storefront/themes/noel-2/theme.json +3 -2
  32. package/templates/storefront/themes/nouvel-an/campaign.en.json +3 -3
  33. package/templates/storefront/themes/nouvel-an/campaign.fr.json +2 -2
  34. package/templates/storefront/themes/nouvel-an/theme.json +2 -1
  35. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +5 -5
  36. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +5 -5
  37. package/templates/storefront/themes/nouvel-an-2/theme.json +4 -3
@@ -0,0 +1,178 @@
1
+ import Link from "next/link"
2
+ import { preload } from "react-dom"
3
+ import type { Campaign } from "@lib/boutique/campaign"
4
+ import { t } from "@lib/boutique/i18n"
5
+ import { getProductPrice } from "@lib/util/get-product-price"
6
+ import { HttpTypes } from "@medusajs/types"
7
+ import { CampaignCountdown } from "./campaign-blocks"
8
+ import FadeImg from "./fade-img"
9
+ import { Icon } from "./icons"
10
+
11
+ /**
12
+ * Accueil « Gala » (thèmes Nouvel An, theme.json → "home": "gala") : noir, or et argent.
13
+ * Variante 1 : texte à gauche. Variante 2 (data-variant) : tout centré, l'année en grand chiffre argenté.
14
+ * Sections : hero plein cadre, bande « minuit » (compte à rebours), sélection encadrée d'or,
15
+ * « les 12 coups de minuit » (chiffres romains), bannière feuille d'or. CSS : styles/ub/gala.css.
16
+ */
17
+
18
+ type P = HttpTypes.StoreProduct
19
+ const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
20
+ const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"]
21
+
22
+ export function GalaHero({ campaign }: { campaign: Campaign }) {
23
+ const { hero } = campaign
24
+ const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
25
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
26
+ const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
27
+ const before = hl ? hero.title.slice(0, hero.title.indexOf(hl)).trim() : hero.title
28
+ const after = hl ? hero.title.slice(hero.title.indexOf(hl) + hl.length).trim() : ""
29
+ const year = new Date(campaign.endsAt).getFullYear()
30
+
31
+ return (
32
+ <section className="ub-gl-hero" style={image ? { ["--gl-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
33
+ <div className="ub-gl-hero__sparkle" aria-hidden />
34
+ <div className="ub-wrap ub-gl-hero__in">
35
+ <div className="ub-gl-hero__text">
36
+ {hero.eyebrow && <p className="ub-gl-eyebrow">{hero.eyebrow}</p>}
37
+ {/* Variante 2 : l'année en très grand (masquée en variante 1 par le CSS) */}
38
+ <p className="ub-gl-year" aria-hidden>
39
+ {year}
40
+ </p>
41
+ <h1 className="ub-gl-title">
42
+ <span className="ub-gl-title__a">{before}</span>
43
+ {hl && before && " "}
44
+ {hl &&<span className="ub-gl-title__b">{hl}</span>}
45
+ {after && <span className="ub-gl-title__a"> {after}</span>}
46
+ </h1>
47
+ {hero.sub && <p className="ub-gl-sub">{hero.sub}</p>}
48
+ <div className="ub-gl-actions">
49
+ <Link href="/store" className="ub-gl-btn ub-gl-btn--solid">
50
+ {hero.cta}
51
+ </Link>
52
+ {hero.cta2 && (
53
+ <Link href="/store" className="ub-gl-btn ub-gl-btn--ghost">
54
+ {hero.cta2}
55
+ </Link>
56
+ )}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </section>
61
+ )
62
+ }
63
+
64
+ /** Bande « minuit » : grand compte à rebours doré + texte promo + bouton. */
65
+ export function GalaMidnight({ campaign }: { campaign: Campaign }) {
66
+ if (campaign.showCountdown === false) return null
67
+ return (
68
+ <section className="ub-gl-midnight">
69
+ <div className="ub-wrap ub-gl-midnight__in">
70
+ <div>
71
+ <p className="ub-gl-eyebrow">{t("gala.midnight")}</p>
72
+ <div className="ub-gl-midnight__cd">
73
+ <CampaignCountdown endsAt={campaign.endsAt} />
74
+ </div>
75
+ </div>
76
+ <div className="ub-gl-midnight__text">
77
+ {campaign.promo?.title && <h2 className="ub-gl-h2">{campaign.promo.title}</h2>}
78
+ {campaign.promo?.text && <p>{campaign.promo.text}</p>}
79
+ <Link href="/store" className="ub-gl-link">
80
+ {campaign.promo?.cta ?? t("nav.cta")} <Icon name="arrowRight" />
81
+ </Link>
82
+ </div>
83
+ </div>
84
+ </section>
85
+ )
86
+ }
87
+
88
+ function price(p: P) {
89
+ const { cheapestPrice } = getProductPrice({ product: p })
90
+ return { price: cheapestPrice, onSale: cheapestPrice?.price_type === "sale" }
91
+ }
92
+
93
+ /** Sélection : cartes sombres avec cadre intérieur doré, prix en or. */
94
+ export function GalaSelection({ products, title }: { products: P[]; title: string }) {
95
+ const items = products.slice(0, 6)
96
+ if (!items.length) return null
97
+ return (
98
+ <section className="ub-wrap ub-gl-section">
99
+ <div className="ub-gl-head">
100
+ <span className="ub-gl-rule" aria-hidden />
101
+ <h2 className="ub-gl-h2">{title}</h2>
102
+ <span className="ub-gl-rule" aria-hidden />
103
+ </div>
104
+ <ul className="ub-gl-grid">
105
+ {items.map((p) => {
106
+ const { price: pr, onSale } = price(p)
107
+ const image = imageOf(p)
108
+ return (
109
+ <li key={p.id}>
110
+ <Link href={`/products/${p.handle}`} className="ub-gl-card">
111
+ <span className="ub-gl-card__media">
112
+ {image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}
113
+ {onSale && pr?.percentage_diff && <span className="ub-gl-card__off">-{pr.percentage_diff}%</span>}
114
+ </span>
115
+ <span className="ub-gl-card__name">{p.title}</span>
116
+ {pr && (
117
+ <span className="ub-gl-card__price">
118
+ <b>{pr.calculated_price}</b>
119
+ {onSale && <s>{pr.original_price}</s>}
120
+ </span>
121
+ )}
122
+ </Link>
123
+ </li>
124
+ )
125
+ })}
126
+ </ul>
127
+ </section>
128
+ )
129
+ }
130
+
131
+ /** « Les 12 coups de minuit » : jusqu'à 12 offres numérotées en chiffres romains, défilement horizontal. */
132
+ export function GalaStrokes({ products }: { products: P[] }) {
133
+ const items = products.slice(0, 12)
134
+ if (items.length < 4) return null
135
+ return (
136
+ <section className="ub-gl-section ub-gl-strokes">
137
+ <div className="ub-wrap ub-gl-head ub-gl-head--left">
138
+ <h2 className="ub-gl-h2">{t("gala.strokes")}</h2>
139
+ <p>{t("gala.strokesSub")}</p>
140
+ </div>
141
+ <ol className="ub-gl-strokes__row">
142
+ {items.map((p, i) => {
143
+ const { price: pr } = price(p)
144
+ const image = imageOf(p)
145
+ return (
146
+ <li key={p.id}>
147
+ <Link href={`/products/${p.handle}`} className="ub-gl-stroke">
148
+ <span className="ub-gl-stroke__num" aria-hidden>
149
+ {ROMAN[i]}
150
+ </span>
151
+ <span className="ub-gl-stroke__img">{image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}</span>
152
+ <span className="ub-gl-stroke__name">{p.title}</span>
153
+ {pr && <b className="ub-gl-stroke__price">{pr.calculated_price}</b>}
154
+ </Link>
155
+ </li>
156
+ )
157
+ })}
158
+ </ol>
159
+ </section>
160
+ )
161
+ }
162
+
163
+ /** Bannière feuille d'or : titre du hero, bouton noir. */
164
+ export function GalaFoil({ campaign }: { campaign: Campaign }) {
165
+ return (
166
+ <section className="ub-wrap ub-gl-section">
167
+ <div className="ub-gl-foil">
168
+ <span className="ub-gl-foil__confetti" aria-hidden />
169
+ {campaign.hero.eyebrow && <p className="ub-gl-eyebrow">{campaign.hero.eyebrow}</p>}
170
+ <h2 className="ub-gl-h2">{campaign.hero.title}</h2>
171
+ {campaign.hero.sub && <p>{campaign.hero.sub}</p>}
172
+ <Link href="/store" className="ub-gl-btn ub-gl-btn--dark">
173
+ {campaign.hero.cta}
174
+ </Link>
175
+ </div>
176
+ </section>
177
+ )
178
+ }
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link"
2
+ import { preload } from "react-dom"
2
3
  import type { Campaign } from "@lib/boutique/campaign"
3
4
  import { getBoutique } from "@lib/boutique/config"
4
5
  import { t } from "@lib/boutique/i18n"
@@ -7,6 +8,7 @@ import { HttpTypes } from "@medusajs/types"
7
8
  import { CampaignCountdown } from "./campaign-blocks"
8
9
  import { Icon, type IconName } from "./icons"
9
10
 
11
+ import FadeImg from "./fade-img"
10
12
  /**
11
13
  * Accueil « Harvest » (thèmes Thanksgiving, theme.json → "home": "harvest") : structure différente de l'accueil
12
14
  * standard. Hero plein cadre avec titre condensé géant + mot manuscrit et bas en vague (« tache »), services en ligne,
@@ -28,6 +30,8 @@ function Drip() {
28
30
  export function HarvestHero({ campaign }: { campaign: Campaign }) {
29
31
  const { hero } = campaign
30
32
  const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
33
+ // Image de fond (CSS) : préchargée en priorité pour qu'elle arrive avant le reste de la page
34
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
31
35
  // Mot manuscrit : en fin de titre, il passe sur sa propre ligne sous le titre condensé ; au milieu de la phrase,
32
36
  // il reste à sa place (sinon la phrase devient incompréhensible).
33
37
  const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
@@ -120,7 +124,7 @@ export function HarvestGrid({ products, title, sub }: { products: P[]; title: st
120
124
  <span className="ub-hv-card__media">
121
125
  {image ? (
122
126
  // eslint-disable-next-line @next/next/no-img-element
123
- <img src={image} alt="" loading="lazy" />
127
+ <FadeImg src={image} alt="" />
124
128
  ) : (
125
129
  <span className="ub-ph" />
126
130
  )}
@@ -186,7 +190,7 @@ export function HarvestStrip({ products }: { products: P[] }) {
186
190
  <li key={p.id}>
187
191
  <Link href={`/products/${p.handle}`} aria-label={p.title ?? ""}>
188
192
  {/* eslint-disable-next-line @next/next/no-img-element */}
189
- <img src={imageOf(p)} alt="" loading="lazy" />
193
+ <FadeImg src={imageOf(p)} alt="" />
190
194
  </Link>
191
195
  </li>
192
196
  ))}
@@ -4,6 +4,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
4
4
  import { HttpTypes } from "@medusajs/types"
5
5
  import { Icon } from "./icons"
6
6
 
7
+ import FadeImg from "./fade-img"
7
8
  /** Carte produit des maquettes. Prix calculés par Medusa pour la région de la boutique. */
8
9
  export default function ProductCard({ product }: { product: HttpTypes.StoreProduct }) {
9
10
  const { cheapestPrice: price } = getProductPrice({ product })
@@ -24,7 +25,7 @@ export default function ProductCard({ product }: { product: HttpTypes.StoreProdu
24
25
  >
25
26
  {image && (
26
27
  // eslint-disable-next-line @next/next/no-img-element
27
- <img src={image} alt="" loading="lazy" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
28
+ <FadeImg src={image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
28
29
  )}
29
30
  </Link>
30
31
  <div className="ub-card__body">
@@ -4,6 +4,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
4
4
  import { HttpTypes } from "@medusajs/types"
5
5
  import { Icon } from "./icons"
6
6
 
7
+ import FadeImg from "./fade-img"
7
8
  type P = HttpTypes.StoreProduct
8
9
  const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
9
10
  const priceOf = (p: P) => {
@@ -32,7 +33,7 @@ export function DealsFold({ products, title }: { products: P[]; title: string })
32
33
  <Link href={`/products/${p.handle}`} className="ub-fold__link" aria-label={t("card.view", { name: p.title ?? "" })}>
33
34
  {image ? (
34
35
  // eslint-disable-next-line @next/next/no-img-element
35
- <img className="ub-fold__img" src={image} alt="" loading="lazy" />
36
+ <FadeImg className="ub-fold__img" src={image} alt="" />
36
37
  ) : (
37
38
  <span className="ub-fold__img ub-ph" />
38
39
  )}
@@ -92,7 +93,7 @@ export function DealsSpotlight({ products, eyebrow, title }: { products: P[]; ey
92
93
  <div key={p.id} className="ub-spot__media" data-i={i}>
93
94
  {image ? (
94
95
  // eslint-disable-next-line @next/next/no-img-element
95
- <img src={image} alt={p.title ?? ""} loading="lazy" />
96
+ <FadeImg src={image} alt={p.title ?? ""} />
96
97
  ) : (
97
98
  <span className="ub-ph" />
98
99
  )}
@@ -0,0 +1,279 @@
1
+ import Link from "next/link"
2
+ import { preload } from "react-dom"
3
+ import type { Campaign } from "@lib/boutique/campaign"
4
+ import { t } from "@lib/boutique/i18n"
5
+ import { getProductPrice } from "@lib/util/get-product-price"
6
+ import { HttpTypes } from "@medusajs/types"
7
+ import { CampaignCountdown } from "./campaign-blocks"
8
+ import FadeImg from "./fade-img"
9
+ import { Icon, type IconName } from "./icons"
10
+
11
+ /**
12
+ * Accueil « Tech » (thèmes Cyber Monday, theme.json → "home": "tech") : la boutique comme un lancement produit.
13
+ * Cyber Monday 1 : nuit violette, texte à gauche, visuel néon sur le côté.
14
+ * Cyber Monday 2 (data-variant) : blanc, le visuel couvre tout le hero, mot en lettres « tuiles ».
15
+ * Sections : hero, bandeau défilant, « Flash deals » (compte à rebours + cartes), bento de l'offre phare,
16
+ * grille « Deals du moment », bannière finale. CSS : styles/ub/tech.css.
17
+ */
18
+
19
+ type P = HttpTypes.StoreProduct
20
+ const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
21
+ const priceOf = (p: P) => {
22
+ const { cheapestPrice } = getProductPrice({ product: p })
23
+ return { price: cheapestPrice, onSale: cheapestPrice?.price_type === "sale" }
24
+ }
25
+
26
+ export function TechHero({ campaign }: { campaign: Campaign }) {
27
+ const { hero } = campaign
28
+ const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
29
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
30
+ const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
31
+ const before = hl ? hero.title.slice(0, hero.title.indexOf(hl)).trim() : hero.title
32
+ const after = hl ? hero.title.slice(hero.title.indexOf(hl) + hl.length).trim() : ""
33
+ const [b1, b2, b3] = hero.bubble ?? []
34
+
35
+ return (
36
+ <section className="ub-tc-hero" data-has-image={image ? "" : undefined}>
37
+ <div className="ub-tc-hero__grid" aria-hidden />
38
+ <div className="ub-tc-hero__beam" aria-hidden />
39
+ {/* Cyber Monday 2 : le visuel couvre tout le hero (masqué par le CSS en variante 1) */}
40
+ {image && (
41
+ <div className="ub-tc-hero__cover" aria-hidden>
42
+ <img src={image} alt="" fetchPriority="high" decoding="async" />
43
+ </div>
44
+ )}
45
+ <div className="ub-wrap ub-tc-hero__in">
46
+ <div className="ub-tc-hero__text">
47
+ {hero.eyebrow && (
48
+ <p className="ub-tc-chip">
49
+ <span className="ub-tc-chip__dot" aria-hidden />
50
+ {hero.eyebrow}
51
+ </p>
52
+ )}
53
+ <h1 className="ub-tc-title">
54
+ {before && <span className="ub-tc-title__a">{before} </span>}
55
+ {hl && (
56
+ <span className="ub-tc-title__hl">
57
+ {/* Variante 2 : une tuile par lettre (comme les lettres du visuel) */}
58
+ {[...hl].map((c, i) => (
59
+ <span key={i} className={c === " " ? "ub-tc-tile ub-tc-tile--sp" : "ub-tc-tile"}>
60
+ {c}
61
+ </span>
62
+ ))}
63
+ </span>
64
+ )}
65
+ {after && <span className="ub-tc-title__a"> {after}</span>}
66
+ </h1>
67
+ {hero.sub && <p className="ub-tc-sub">{hero.sub}</p>}
68
+ <div className="ub-tc-actions">
69
+ <Link href="/store" className="ub-tc-btn ub-tc-btn--solid">
70
+ {hero.cta} <Icon name="arrowRight" />
71
+ </Link>
72
+ {hero.cta2 && (
73
+ <Link href="/store" className="ub-tc-btn ub-tc-btn--ghost">
74
+ {hero.cta2}
75
+ </Link>
76
+ )}
77
+ </div>
78
+ {campaign.showCountdown !== false && (
79
+ <div className="ub-tc-hero__cd">
80
+ <CampaignCountdown endsAt={campaign.endsAt} />
81
+ </div>
82
+ )}
83
+ </div>
84
+ {/* Cyber Monday 1 : visuel sur le côté, cadre lumineux + pastille de remise */}
85
+ <div className="ub-tc-hero__side">
86
+ {image ? <img src={image} alt={t("hero.photo")} fetchPriority="high" decoding="async" /> : <span className="ub-ph" />}
87
+ {b2 && (
88
+ <p className="ub-tc-badge">
89
+ <small>{b1}</small>
90
+ <b>{b2}</b>
91
+ <small>{b3}</small>
92
+ </p>
93
+ )}
94
+ </div>
95
+ </div>
96
+ </section>
97
+ )
98
+ }
99
+
100
+ /** Bandeau défilant (textes de la barre du haut + remise), dupliqué pour une boucle continue. */
101
+ export function TechTicker({ campaign }: { campaign: Campaign }) {
102
+ const items = [campaign.hero.eyebrow, ...(campaign.topbar ?? [])].filter(Boolean) as string[]
103
+ if (!items.length) return null
104
+ const row = (hidden?: boolean) => (
105
+ <ul className="ub-tc-ticker__row" aria-hidden={hidden || undefined}>
106
+ {items.map((s, i) => (
107
+ <li key={i}>
108
+ <Icon name="sparkle" /> {s}
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ )
113
+ return (
114
+ <div className="ub-tc-ticker">
115
+ <div className="ub-tc-ticker__track">
116
+ {row()}
117
+ {row(true)}
118
+ </div>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ function Card({ p, rank }: { p: P; rank?: number }) {
124
+ const { price, onSale } = priceOf(p)
125
+ const image = imageOf(p)
126
+ return (
127
+ <Link href={`/products/${p.handle}`} className="ub-tc-card">
128
+ <span className="ub-tc-card__media">
129
+ {image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}
130
+ {onSale && price?.percentage_diff && <span className="ub-tc-off">-{price.percentage_diff}%</span>}
131
+ {rank != null && <span className="ub-tc-card__rank">#{String(rank).padStart(2, "0")}</span>}
132
+ </span>
133
+ <span className="ub-tc-card__body">
134
+ <span className="ub-tc-card__name">{p.title}</span>
135
+ {price && (
136
+ <span className="ub-tc-card__price">
137
+ <b>{price.calculated_price}</b>
138
+ {onSale && <s>{price.original_price}</s>}
139
+ </span>
140
+ )}
141
+ </span>
142
+ <span className="ub-tc-card__go" aria-hidden>
143
+ <Icon name="arrowUpRight" />
144
+ </span>
145
+ </Link>
146
+ )
147
+ }
148
+
149
+ /** « Flash deals » : compte à rebours façon écran + les offres les plus remisées en défilement horizontal. */
150
+ export function TechFlash({ campaign, products }: { campaign: Campaign; products: P[] }) {
151
+ const items = products.slice(0, 8)
152
+ if (!items.length) return null
153
+ return (
154
+ <section className="ub-tc-section ub-tc-flash">
155
+ <div className="ub-wrap">
156
+ <div className="ub-tc-flash__head">
157
+ <div>
158
+ <p className="ub-tc-kicker">
159
+ <Icon name="sparkle" /> {t("tech.flash")}
160
+ </p>
161
+ <h2 className="ub-tc-h2">{campaign.promo?.title ?? t("tech.flashTitle")}</h2>
162
+ </div>
163
+ {campaign.showCountdown !== false && (
164
+ <div className="ub-tc-flash__cd">
165
+ <CampaignCountdown endsAt={campaign.endsAt} />
166
+ </div>
167
+ )}
168
+ </div>
169
+ <ol className="ub-tc-flash__row">
170
+ {items.map((p, i) => (
171
+ <li key={p.id}>
172
+ <Card p={p} rank={i + 1} />
173
+ </li>
174
+ ))}
175
+ </ol>
176
+ </div>
177
+ </section>
178
+ )
179
+ }
180
+
181
+ /** Bento : l'offre phare en grande carte + 4 cartes. */
182
+ export function TechBento({ campaign, products }: { campaign: Campaign; products: P[] }) {
183
+ const [hero, ...rest] = products.filter((p) => imageOf(p))
184
+ if (!hero) return null
185
+ const { price, onSale } = priceOf(hero)
186
+ const image = imageOf(hero)
187
+ return (
188
+ <section className="ub-tc-section">
189
+ <div className="ub-wrap ub-tc-bento">
190
+ <Link href={`/products/${hero.handle}`} className="ub-tc-bento__main">
191
+ <span className="ub-tc-bento__img">{image && <FadeImg src={image} alt="" />}</span>
192
+ <span className="ub-tc-bento__txt">
193
+ <span className="ub-tc-kicker">{campaign.promo?.eyebrow ?? t("tech.deal")}</span>
194
+ <span className="ub-tc-bento__name">{hero.title}</span>
195
+ {price && (
196
+ <span className="ub-tc-card__price ub-tc-card__price--lg">
197
+ <b>{price.calculated_price}</b>
198
+ {onSale && <s>{price.original_price}</s>}
199
+ {onSale && price.percentage_diff && <span className="ub-tc-off">-{price.percentage_diff}%</span>}
200
+ </span>
201
+ )}
202
+ <span className="ub-tc-btn ub-tc-btn--solid">
203
+ {campaign.promo?.cta ?? t("nav.cta")} <Icon name="arrowRight" />
204
+ </span>
205
+ </span>
206
+ </Link>
207
+ {rest.slice(0, 4).map((p) => (
208
+ <Card key={p.id} p={p} />
209
+ ))}
210
+ </div>
211
+ </section>
212
+ )
213
+ }
214
+
215
+ /** Grille de tous les produits. */
216
+ export function TechGrid({ products, title }: { products: P[]; title: string }) {
217
+ if (!products.length) return null
218
+ return (
219
+ <section className="ub-tc-section">
220
+ <div className="ub-wrap">
221
+ <div className="ub-tc-head">
222
+ <h2 className="ub-tc-h2">{title}</h2>
223
+ <Link href="/store" className="ub-tc-link">
224
+ {t("tech.all")} <Icon name="arrowRight" />
225
+ </Link>
226
+ </div>
227
+ <ul className="ub-tc-grid">
228
+ {products.map((p) => (
229
+ <li key={p.id}>
230
+ <Card p={p} />
231
+ </li>
232
+ ))}
233
+ </ul>
234
+ </div>
235
+ </section>
236
+ )
237
+ }
238
+
239
+ /** Avantages (livraison, paiement, retours) en ligne, style fiche technique. */
240
+ export function TechSpecs({ freeShipping }: { freeShipping?: string }) {
241
+ const specs: [IconName, string, string][] = [
242
+ ["truck", t("trust.shipping"), freeShipping ? t("trust.shippingSub", { freeShipping }) : t("trust.shippingSubAll")],
243
+ ["lock", t("trust.secure"), t("trust.secureSub")],
244
+ ["returns", t("trust.returns"), t("trust.returnsSub")],
245
+ ["shield", t("tech.warranty"), t("tech.warrantySub")],
246
+ ]
247
+ return (
248
+ <section className="ub-wrap ub-tc-specs">
249
+ {specs.map(([ic, a, b]) => (
250
+ <div key={a} className="ub-tc-spec">
251
+ <Icon name={ic} />
252
+ <span>
253
+ <b>{a}</b>
254
+ <small>{b}</small>
255
+ </span>
256
+ </div>
257
+ ))}
258
+ </section>
259
+ )
260
+ }
261
+
262
+ /** Bannière finale : texte promo + bouton, sur fond lumineux (variante 1) ou noir (variante 2). */
263
+ export function TechFinal({ campaign }: { campaign: Campaign }) {
264
+ const promo = campaign.promo
265
+ if (!promo?.title) return null
266
+ return (
267
+ <section className="ub-wrap ub-tc-section">
268
+ <div className="ub-tc-final">
269
+ <div className="ub-tc-final__glow" aria-hidden />
270
+ {promo.eyebrow && <p className="ub-tc-kicker">{promo.eyebrow}</p>}
271
+ <h2 className="ub-tc-final__title">{promo.title}</h2>
272
+ {promo.text && <p className="ub-tc-final__text">{promo.text}</p>}
273
+ <Link href="/store" className="ub-tc-btn ub-tc-btn--solid">
274
+ {promo.cta ?? t("nav.cta")} <Icon name="arrowRight" />
275
+ </Link>
276
+ </div>
277
+ </section>
278
+ )
279
+ }