@salifbiaye/create-boutique 0.3.6 → 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 (29) 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 +4 -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/cinema.tsx +180 -0
  9. package/templates/storefront/src/modules/ub/gala.tsx +178 -0
  10. package/templates/storefront/src/modules/ub/tech.tsx +279 -0
  11. package/templates/storefront/src/styles/ub/album.css +205 -0
  12. package/templates/storefront/src/styles/ub/cinema.css +169 -0
  13. package/templates/storefront/src/styles/ub/gala.css +242 -0
  14. package/templates/storefront/src/styles/ub/tech.css +421 -0
  15. package/templates/storefront/src/styles/ub/variants.css +1 -5
  16. package/templates/storefront/themes/cyber-monday/theme.json +4 -3
  17. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +1 -1
  18. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +1 -1
  19. package/templates/storefront/themes/cyber-monday-2/theme.json +5 -4
  20. package/templates/storefront/themes/halloween/theme.json +3 -2
  21. package/templates/storefront/themes/halloween-2/theme.json +2 -1
  22. package/templates/storefront/themes/noel/theme.json +3 -2
  23. package/templates/storefront/themes/noel-2/theme.json +3 -2
  24. package/templates/storefront/themes/nouvel-an/campaign.en.json +3 -3
  25. package/templates/storefront/themes/nouvel-an/campaign.fr.json +2 -2
  26. package/templates/storefront/themes/nouvel-an/theme.json +2 -1
  27. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +5 -5
  28. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +5 -5
  29. package/templates/storefront/themes/nouvel-an-2/theme.json +4 -3
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Usine à Boutiques : génère un projet complet (backend Medusa + une boutique Next.js par campagne) prêt pour docker compose",
5
5
  "type": "module",
6
- "repository": { "type": "git", "url": "git+https://github.com/salifbiaye/create-boutique.git" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/salifbiaye/create-boutique.git"
9
+ },
7
10
  "license": "UNLICENSED",
8
11
  "bin": {
9
12
  "create-boutique": "cli/dist/index.js"
@@ -308,5 +308,20 @@
308
308
  "checkout.testMode.text": "Card payments are in test mode: you will not be charged. Use the test card below with any future expiry date (e.g. 12/34) and any CVC (e.g. 123).",
309
309
  "harvest.gift": "Gift wrapping",
310
310
  "harvest.giftSub": "Free on request",
311
- "harvest.strip": "The season in pictures"
311
+ "harvest.strip": "The season in pictures",
312
+ "gala.midnight": "Midnight in",
313
+ "gala.strokes": "The 12 strokes of midnight",
314
+ "gala.strokesSub": "Twelve deals to end the year in style.",
315
+ "cinema.catalog": "Browse the catalog",
316
+ "cinema.next": "Up next",
317
+ "cinema.top": "Top 10 this week",
318
+ "cinema.badgeStock": "Limited stock",
319
+ "cinema.badgeShipping": "48h delivery",
320
+ "album.offer": "The Christmas offer",
321
+ "tech.flash": "Flash deals",
322
+ "tech.flashTitle": "The best deals, for 24 hours",
323
+ "tech.deal": "Top deal",
324
+ "tech.all": "See all",
325
+ "tech.warranty": "2-year warranty",
326
+ "tech.warrantySub": "On all tech"
312
327
  }
@@ -308,5 +308,20 @@
308
308
  "checkout.testMode.text": "Le paiement par carte est en mode test : aucune somme ne sera débitée. Utilisez la carte de test ci-dessous, avec une date d'expiration future (ex. 12/34) et n'importe quel code (ex. 123).",
309
309
  "harvest.gift": "Emballage cadeau",
310
310
  "harvest.giftSub": "Offert sur demande",
311
- "harvest.strip": "La saison en images"
311
+ "harvest.strip": "La saison en images",
312
+ "gala.midnight": "Minuit dans",
313
+ "gala.strokes": "Les 12 coups de minuit",
314
+ "gala.strokesSub": "Douze offres pour finir l'année en beauté.",
315
+ "cinema.catalog": "Voir le catalogue",
316
+ "cinema.next": "À suivre",
317
+ "cinema.top": "Top 10 de la semaine",
318
+ "cinema.badgeStock": "Stock limité",
319
+ "cinema.badgeShipping": "Livraison 48 h",
320
+ "album.offer": "L'offre de Noël",
321
+ "tech.flash": "Flash deals",
322
+ "tech.flashTitle": "Les meilleures offres, pour 24 h",
323
+ "tech.deal": "Offre phare",
324
+ "tech.all": "Tout voir",
325
+ "tech.warranty": "Garantie 2 ans",
326
+ "tech.warrantySub": "Sur toute la tech"
312
327
  }
@@ -11,6 +11,10 @@ import CategoryTiles from "@modules/ub/category-tiles"
11
11
  import { ProductGrid } from "@modules/ub/product-card"
12
12
  import { DealsFold, DealsSpotlight } from "@modules/ub/showcase"
13
13
  import { HarvestBanner, HarvestGrid, HarvestHero, HarvestServices, HarvestStrip } from "@modules/ub/harvest"
14
+ import { GalaFoil, GalaHero, GalaMidnight, GalaSelection, GalaStrokes } from "@modules/ub/gala"
15
+ import { CinemaHero, CinemaRow, CinemaScreening, CinemaTop } from "@modules/ub/cinema"
16
+ import { TechBento, TechFinal, TechFlash, TechGrid, TechHero, TechSpecs, TechTicker } from "@modules/ub/tech"
17
+ import { AlbumGarland, AlbumHero, AlbumPolaroids, AlbumStory, AlbumTicket } from "@modules/ub/album"
14
18
  import { getBoutique } from "@lib/boutique/config"
15
19
  import type { Campaign } from "@lib/boutique/campaign"
16
20
 
@@ -91,6 +95,78 @@ async function Promo({ campaign }: { campaign: Campaign }) {
91
95
  return <PromoBanner campaign={campaign} fallbackImage={best?.thumbnail ?? undefined} />
92
96
  }
93
97
 
98
+ /** Accueil « Cinéma » (thèmes Halloween) : produits triés par remise pour le Top 10 et « À suivre ». */
99
+ async function CinemaPage({ campaign }: { campaign: Campaign }) {
100
+ const {
101
+ response: { products },
102
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
103
+ const off = (p: (typeof products)[number]) =>
104
+ Math.max(0, ...(p.variants ?? []).map((v) => {
105
+ const c = v.calculated_price
106
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
107
+ }))
108
+ const sorted = [...products].sort((a, b) => off(b) - off(a))
109
+ return (
110
+ <>
111
+ <CinemaHero campaign={campaign} next={sorted} />
112
+ <CinemaRow products={products} title={campaign.sections?.products ?? t("section.products")} />
113
+ <CinemaTop products={sorted} />
114
+ <CinemaScreening campaign={campaign} image={sorted.find((p) => p.thumbnail)?.thumbnail ?? undefined} />
115
+ </>
116
+ )
117
+ }
118
+
119
+ /** Accueil « Album » (thèmes Noël). */
120
+ async function AlbumPage({ campaign }: { campaign: Campaign }) {
121
+ const {
122
+ response: { products },
123
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
124
+ return (
125
+ <>
126
+ <AlbumStory campaign={campaign} products={products} />
127
+ <AlbumGarland />
128
+ <AlbumPolaroids products={products} title={campaign.sections?.products ?? t("section.products")} />
129
+ <AlbumGarland />
130
+ <AlbumTicket campaign={campaign} />
131
+ </>
132
+ )
133
+ }
134
+
135
+ /** Accueil « Tech » (thèmes Cyber Monday) : offres les plus remisées en « Flash deals » et dans le bento. */
136
+ async function TechPage({ campaign }: { campaign: Campaign }) {
137
+ const {
138
+ response: { products },
139
+ } = await listProducts({ queryParams: { limit: 16, fields: "*categories,*collection" } })
140
+ const off = (p: (typeof products)[number]) =>
141
+ Math.max(0, ...(p.variants ?? []).map((v) => {
142
+ const c = v.calculated_price
143
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
144
+ }))
145
+ const sorted = [...products].sort((a, b) => off(b) - off(a))
146
+ return (
147
+ <>
148
+ <TechFlash campaign={campaign} products={sorted} />
149
+ <TechBento campaign={campaign} products={sorted.length > 12 ? sorted.slice(8) : [...sorted].reverse()} />
150
+ <TechGrid products={products} title={campaign.sections?.products ?? t("section.products")} />
151
+ <TechFinal campaign={campaign} />
152
+ </>
153
+ )
154
+ }
155
+
156
+ /** Accueil « Gala » (thèmes Nouvel An). */
157
+ async function GalaProducts({ campaign }: { campaign: Campaign }) {
158
+ const {
159
+ response: { products },
160
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
161
+ return (
162
+ <>
163
+ <GalaSelection products={products} title={campaign.sections?.products ?? t("section.products")} />
164
+ <GalaStrokes products={products} />
165
+ <GalaFoil campaign={campaign} />
166
+ </>
167
+ )
168
+ }
169
+
94
170
  function GridFallback() {
95
171
  return <div className="ub-section" aria-busy="true" style={{ minHeight: 320 }} />
96
172
  }
@@ -103,6 +179,53 @@ export default async function Home() {
103
179
  return <CampaignEnded nextUrl={campaign.nextCampaignUrl} />
104
180
  }
105
181
 
182
+ if (getBoutique().theme.home === "tech") {
183
+ return (
184
+ <div className="ub-tech">
185
+ <TechHero campaign={campaign} />
186
+ <TechTicker campaign={campaign} />
187
+ <TechSpecs freeShipping={campaign.freeShipping} />
188
+ <Suspense fallback={<GridFallback />}>
189
+ <TechPage campaign={campaign} />
190
+ </Suspense>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ if (getBoutique().theme.home === "album") {
196
+ return (
197
+ <div className="ub-album">
198
+ <AlbumHero campaign={campaign} />
199
+ <AlbumGarland />
200
+ <Suspense fallback={<GridFallback />}>
201
+ <AlbumPage campaign={campaign} />
202
+ </Suspense>
203
+ </div>
204
+ )
205
+ }
206
+
207
+ if (getBoutique().theme.home === "cinema") {
208
+ return (
209
+ <div className="ub-cinema">
210
+ <Suspense fallback={<GridFallback />}>
211
+ <CinemaPage campaign={campaign} />
212
+ </Suspense>
213
+ </div>
214
+ )
215
+ }
216
+
217
+ if (getBoutique().theme.home === "gala") {
218
+ return (
219
+ <div className="ub-gala">
220
+ <GalaHero campaign={campaign} />
221
+ <GalaMidnight campaign={campaign} />
222
+ <Suspense fallback={<GridFallback />}>
223
+ <GalaProducts campaign={campaign} />
224
+ </Suspense>
225
+ </div>
226
+ )
227
+ }
228
+
106
229
  if (getBoutique().theme.home === "harvest") {
107
230
  return (
108
231
  <div className="ub-harvest">
@@ -14,6 +14,10 @@ import "styles/ub/forms.css"
14
14
  import "styles/ub/account.css"
15
15
  import "styles/ub/black-friday.css"
16
16
  import "styles/ub/harvest.css"
17
+ import "styles/ub/gala.css"
18
+ import "styles/ub/cinema.css"
19
+ import "styles/ub/album.css"
20
+ import "styles/ub/tech.css"
17
21
  import "styles/ub/media.css"
18
22
  import "styles/ub/responsive.css"
19
23
  import "styles/ub/fonts.css"
@@ -71,7 +71,7 @@ export const themeSchema = z.object({
71
71
  /** Variante : reprend la mise en page (règles CSS) de ce thème, avec la palette de styles/ub/variants.css. */
72
72
  layout: z.string().regex(SLUG).optional(),
73
73
  /** Page d'accueil : « default » (sections standard) ou « harvest » (accueil éditorial des thèmes Thanksgiving) */
74
- home: z.enum(["default", "harvest"]).optional(),
74
+ home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech"]).optional(),
75
75
  fonts: z.object({
76
76
  head: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
77
77
  body: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
@@ -0,0 +1,137 @@
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
+
10
+ /**
11
+ * Accueil « Album » (thèmes Noël, theme.json → "home": "album") : un album de Noël fait main.
12
+ * Hero velours avec titre calligraphié, guirlande, panneau en bois + photos suspendues, polaroïds avec étiquettes
13
+ * en papier déchiré, ticket d'offre perforé. CSS : styles/ub/album.css.
14
+ */
15
+
16
+ type P = HttpTypes.StoreProduct
17
+ const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
18
+
19
+ export function AlbumHero({ campaign }: { campaign: Campaign }) {
20
+ const { hero } = campaign
21
+ const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
22
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
23
+ return (
24
+ <section className="ub-al-hero" style={image ? { ["--al-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
25
+ <span className="ub-al-hero__curtain" aria-hidden />
26
+ <div className="ub-wrap ub-al-hero__in">
27
+ {hero.eyebrow && <p className="ub-al-eyebrow">{hero.eyebrow}</p>}
28
+ <h1 className="ub-al-title">{hero.title}</h1>
29
+ {hero.sub && <p className="ub-al-sub">{hero.sub}</p>}
30
+ <Link href="/store" className="ub-al-btn">
31
+ {hero.cta}
32
+ </Link>
33
+ </div>
34
+ </section>
35
+ )
36
+ }
37
+
38
+ /** Guirlande de sapin avec boules (séparateur entre les sections). */
39
+ export function AlbumGarland() {
40
+ return <div className="ub-al-garland" aria-hidden />
41
+ }
42
+
43
+ /** Section verte : panneau en bois (texte du bandeau promo) et deux photos suspendues. */
44
+ export function AlbumStory({ campaign, products }: { campaign: Campaign; products: P[] }) {
45
+ const promo = campaign.promo
46
+ const photos = products.filter(imageOf).slice(0, 2)
47
+ return (
48
+ <section className="ub-al-story">
49
+ <div className="ub-wrap ub-al-story__in">
50
+ <div className="ub-al-hang">
51
+ {photos.map((p, i) => (
52
+ <Link key={p.id} href={`/products/${p.handle}`} className="ub-al-hang__item" style={{ ["--i" as string]: i }} aria-label={p.title ?? ""}>
53
+ <span className="ub-al-hang__ribbon" aria-hidden />
54
+ <span className="ub-al-frame">
55
+ <FadeImg src={imageOf(p)} alt="" />
56
+ </span>
57
+ </Link>
58
+ ))}
59
+ </div>
60
+ <div className="ub-al-sign">
61
+ {promo?.eyebrow && <p className="ub-al-sign__eyebrow">{promo.eyebrow}</p>}
62
+ <h2 className="ub-al-h2">{promo?.title ?? campaign.hero.title}</h2>
63
+ {promo?.text && <p>{promo.text}</p>}
64
+ <span className="ub-al-sign__snow" aria-hidden />
65
+ </div>
66
+ </div>
67
+ </section>
68
+ )
69
+ }
70
+
71
+ /** Section crème : polaroïds inclinés avec ruban adhésif + étiquettes en papier déchiré (nom, prix). */
72
+ export function AlbumPolaroids({ products, title }: { products: P[]; title: string }) {
73
+ const items = products.slice(0, 6)
74
+ if (!items.length) return null
75
+ return (
76
+ <section className="ub-al-cream">
77
+ <div className="ub-wrap">
78
+ <h2 className="ub-al-h2 ub-al-h2--script">{title}</h2>
79
+ <ul className="ub-al-list">
80
+ {items.map((p, i) => {
81
+ const { cheapestPrice: price } = getProductPrice({ product: p })
82
+ const onSale = price?.price_type === "sale"
83
+ const image = imageOf(p)
84
+ return (
85
+ <li key={p.id} className="ub-al-item" style={{ ["--i" as string]: i }}>
86
+ <Link href={`/products/${p.handle}`} className="ub-al-polaroid">
87
+ <span className="ub-al-tape" aria-hidden />
88
+ <span className="ub-al-polaroid__img">{image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}</span>
89
+ </Link>
90
+ <Link href={`/products/${p.handle}`} className="ub-al-label">
91
+ <b>{p.title}</b>
92
+ {price && (
93
+ <span>
94
+ {price.calculated_price}
95
+ {onSale && <s>{price.original_price}</s>}
96
+ </span>
97
+ )}
98
+ {onSale && price?.percentage_diff && <em>-{price.percentage_diff}%</em>}
99
+ </Link>
100
+ </li>
101
+ )
102
+ })}
103
+ </ul>
104
+ <div className="ub-al-more">
105
+ <Link href="/store" className="ub-al-btn ub-al-btn--dark">
106
+ {t("section.viewAll")}
107
+ </Link>
108
+ </div>
109
+ </div>
110
+ </section>
111
+ )
112
+ }
113
+
114
+ /** Section rouge : ticket d'offre perforé avec compte à rebours. */
115
+ export function AlbumTicket({ campaign }: { campaign: Campaign }) {
116
+ const { hero, promo } = campaign
117
+ return (
118
+ <section className="ub-al-red">
119
+ <div className="ub-wrap">
120
+ <h2 className="ub-al-h2 ub-al-h2--script ub-al-h2--light">{t("album.offer")}</h2>
121
+ <div className="ub-al-ticket">
122
+ <div className="ub-al-ticket__main">
123
+ {promo?.seal && <p className="ub-al-sign__eyebrow">{promo.seal}</p>}
124
+ <p className="ub-al-ticket__big">{hero.bubble?.[1] ? `-${String(hero.bubble[1]).replace(/^-/, "")}` : promo?.title}</p>
125
+ <p>{hero.sub}</p>
126
+ </div>
127
+ <div className="ub-al-ticket__stub">
128
+ {campaign.showCountdown !== false && <CampaignCountdown endsAt={campaign.endsAt} />}
129
+ <Link href="/store" className="ub-al-btn">
130
+ {promo?.cta ?? hero.cta}
131
+ </Link>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </section>
136
+ )
137
+ }
@@ -0,0 +1,180 @@
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 « Cinéma » (thèmes Halloween, theme.json → "home": "cinema") : la boutique comme une affiche de film.
13
+ * Hero plein cadre (titre condensé + mot au pinceau, fiche « séance » à droite, « À suivre »), rangée d'affiches,
14
+ * Top 10 à grands chiffres, bannière « séance de minuit » au format cinéma. CSS : styles/ub/cinema.css.
15
+ */
16
+
17
+ type P = HttpTypes.StoreProduct
18
+ const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
19
+ const priceOf = (p: P) => {
20
+ const { cheapestPrice } = getProductPrice({ product: p })
21
+ return { price: cheapestPrice, onSale: cheapestPrice?.price_type === "sale" }
22
+ }
23
+
24
+ export function CinemaHero({ campaign, next }: { campaign: Campaign; next: P[] }) {
25
+ const { hero, promo } = campaign
26
+ const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
27
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
28
+ const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
29
+ const before = hl ? hero.title.slice(0, hero.title.indexOf(hl)).replace(/[\s,;:–-]+$/, "") : hero.title
30
+ const after = hl ? hero.title.slice(hero.title.indexOf(hl) + hl.length).trim() : ""
31
+ const badges = [hero.bubble?.[1] ? `-${String(hero.bubble[1]).replace(/^-/, "")}` : undefined, promo?.seal, t("cinema.badgeStock"), t("cinema.badgeShipping")].filter(
32
+ Boolean
33
+ ) as string[]
34
+ const upNext = next.filter(imageOf).slice(0, 2)
35
+
36
+ return (
37
+ <section className="ub-cn-hero" style={image ? { ["--cn-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
38
+ <div className="ub-wrap ub-cn-hero__in">
39
+ <div className="ub-cn-hero__poster">
40
+ {hero.eyebrow && <p className="ub-cn-tag">{hero.eyebrow}</p>}
41
+ <h1 className="ub-cn-title">
42
+ <span className="ub-cn-title__main">{before}</span>
43
+ {hl && <span className="ub-cn-title__hl">{hl}</span>}
44
+ {after && <span className="ub-cn-title__main">{after}</span>}
45
+ </h1>
46
+ <Link href="/store" className="ub-cn-fav">
47
+ <Icon name="sparkle" /> {hero.cta2 ?? t("cinema.catalog")}
48
+ </Link>
49
+ </div>
50
+
51
+ <aside className="ub-cn-panel">
52
+ <h2 className="ub-cn-panel__title">{promo?.title ?? hero.title}</h2>
53
+ <ul className="ub-cn-badges">
54
+ {badges.map((b) => (
55
+ <li key={b}>{b}</li>
56
+ ))}
57
+ </ul>
58
+ <p className="ub-cn-panel__text">{hero.sub}</p>
59
+ <div className="ub-cn-panel__actions">
60
+ <Link href="/store" className="ub-cn-btn ub-cn-btn--play">
61
+ <span aria-hidden>▶</span> {hero.cta}
62
+ </Link>
63
+ <Link href="/store" className="ub-cn-btn ub-cn-btn--ghost">
64
+ {t("cinema.catalog")}
65
+ </Link>
66
+ </div>
67
+ {upNext.length > 0 && (
68
+ <div className="ub-cn-next">
69
+ <p>{t("cinema.next")}</p>
70
+ <ul>
71
+ {upNext.map((p) => (
72
+ <li key={p.id}>
73
+ <Link href={`/products/${p.handle}`} aria-label={p.title ?? ""}>
74
+ <FadeImg src={imageOf(p)} alt="" />
75
+ <span className="ub-cn-next__play" aria-hidden>
76
+ ▶
77
+ </span>
78
+ </Link>
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </div>
83
+ )}
84
+ </aside>
85
+ </div>
86
+ </section>
87
+ )
88
+ }
89
+
90
+ /** Rangée d'affiches (format 2:3) à faire défiler, titre et prix sur l'image. */
91
+ export function CinemaRow({ products, title }: { products: P[]; title: string }) {
92
+ const items = products.slice(0, 10)
93
+ if (!items.length) return null
94
+ return (
95
+ <section className="ub-cn-section">
96
+ <div className="ub-wrap ub-cn-head">
97
+ <h2 className="ub-cn-h2">{title}</h2>
98
+ <Link href="/store" className="ub-cn-more">
99
+ {t("section.viewAll")} <Icon name="arrowRight" />
100
+ </Link>
101
+ </div>
102
+ <ul className="ub-cn-row">
103
+ {items.map((p) => {
104
+ const { price, onSale } = priceOf(p)
105
+ const image = imageOf(p)
106
+ return (
107
+ <li key={p.id}>
108
+ <Link href={`/products/${p.handle}`} className="ub-cn-poster">
109
+ {image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}
110
+ {onSale && price?.percentage_diff && <span className="ub-cn-poster__off">-{price.percentage_diff}%</span>}
111
+ <span className="ub-cn-poster__info">
112
+ <b>{p.title}</b>
113
+ {price && (
114
+ <span>
115
+ {price.calculated_price}
116
+ {onSale && <s>{price.original_price}</s>}
117
+ </span>
118
+ )}
119
+ </span>
120
+ </Link>
121
+ </li>
122
+ )
123
+ })}
124
+ </ul>
125
+ </section>
126
+ )
127
+ }
128
+
129
+ /** Top 10 : grands chiffres détourés derrière les affiches (les plus remisés d'abord). */
130
+ export function CinemaTop({ products }: { products: P[] }) {
131
+ const items = products.filter(imageOf).slice(0, 10)
132
+ if (items.length < 3) return null
133
+ return (
134
+ <section className="ub-cn-section">
135
+ <div className="ub-wrap ub-cn-head">
136
+ <h2 className="ub-cn-h2">{t("cinema.top")}</h2>
137
+ </div>
138
+ <ol className="ub-cn-row ub-cn-top">
139
+ {items.map((p, i) => (
140
+ <li key={p.id}>
141
+ <Link href={`/products/${p.handle}`} className="ub-cn-top__item" aria-label={`${i + 1}. ${p.title ?? ""}`}>
142
+ <span className="ub-cn-top__num" aria-hidden>
143
+ {i + 1}
144
+ </span>
145
+ <span className="ub-cn-poster ub-cn-poster--small">
146
+ <FadeImg src={imageOf(p)} alt="" />
147
+ </span>
148
+ </Link>
149
+ </li>
150
+ ))}
151
+ </ol>
152
+ </section>
153
+ )
154
+ }
155
+
156
+ /** Bannière « séance de minuit » : format cinéma (bandes noires), texte du bandeau promo, compte à rebours. */
157
+ export function CinemaScreening({ campaign, image }: { campaign: Campaign; image?: string }) {
158
+ const promo = campaign.promo
159
+ if (!promo) return null
160
+ const bg = (promo.image && /^https?:\/\//.test(promo.image) ? promo.image : undefined) ?? image
161
+ return (
162
+ <section className="ub-wrap ub-cn-section">
163
+ <div className="ub-cn-screen" style={bg ? { ["--cn-screen" as string]: `url("${bg}")` } : undefined}>
164
+ <div className="ub-cn-screen__text">
165
+ {promo.eyebrow && <p className="ub-cn-tag">{promo.eyebrow}</p>}
166
+ <h2 className="ub-cn-h2">{promo.title}</h2>
167
+ {promo.text && <p>{promo.text}</p>}
168
+ {campaign.showCountdown !== false && (
169
+ <div className="ub-cn-screen__cd">
170
+ <CampaignCountdown endsAt={campaign.endsAt} />
171
+ </div>
172
+ )}
173
+ <Link href="/store" className="ub-cn-btn ub-cn-btn--play">
174
+ <span aria-hidden>▶</span> {promo.cta ?? t("nav.cta")}
175
+ </Link>
176
+ </div>
177
+ </div>
178
+ </section>
179
+ )
180
+ }