@salifbiaye/create-boutique 0.3.6 → 0.4.1

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 (30) hide show
  1. package/package.json +5 -2
  2. package/templates/backend/src/scripts/seed.ts +47 -12
  3. package/templates/storefront/locales/en.json +16 -1
  4. package/templates/storefront/locales/fr.json +16 -1
  5. package/templates/storefront/src/app/(main)/page.tsx +123 -0
  6. package/templates/storefront/src/app/layout.tsx +4 -0
  7. package/templates/storefront/src/lib/boutique/schema.ts +1 -1
  8. package/templates/storefront/src/modules/ub/album.tsx +137 -0
  9. package/templates/storefront/src/modules/ub/cinema.tsx +180 -0
  10. package/templates/storefront/src/modules/ub/gala.tsx +178 -0
  11. package/templates/storefront/src/modules/ub/tech.tsx +279 -0
  12. package/templates/storefront/src/styles/ub/album.css +205 -0
  13. package/templates/storefront/src/styles/ub/cinema.css +169 -0
  14. package/templates/storefront/src/styles/ub/gala.css +242 -0
  15. package/templates/storefront/src/styles/ub/tech.css +441 -0
  16. package/templates/storefront/src/styles/ub/variants.css +1 -5
  17. package/templates/storefront/themes/cyber-monday/theme.json +4 -3
  18. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +1 -1
  19. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +1 -1
  20. package/templates/storefront/themes/cyber-monday-2/theme.json +5 -4
  21. package/templates/storefront/themes/halloween/theme.json +3 -2
  22. package/templates/storefront/themes/halloween-2/theme.json +2 -1
  23. package/templates/storefront/themes/noel/theme.json +3 -2
  24. package/templates/storefront/themes/noel-2/theme.json +3 -2
  25. package/templates/storefront/themes/nouvel-an/campaign.en.json +3 -3
  26. package/templates/storefront/themes/nouvel-an/campaign.fr.json +2 -2
  27. package/templates/storefront/themes/nouvel-an/theme.json +2 -1
  28. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +5 -5
  29. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +5 -5
  30. 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.1",
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"
@@ -27,6 +27,7 @@ import {
27
27
  linkSalesChannelsToApiKeyWorkflow,
28
28
  linkSalesChannelsToStockLocationWorkflow,
29
29
  updateRegionsWorkflow,
30
+ updateProductsWorkflow,
30
31
  updateShippingOptionsWorkflow,
31
32
  updateStoresWorkflow,
32
33
  uploadFilesWorkflow,
@@ -210,9 +211,14 @@ export default async function seed({ container }: ExecArgs) {
210
211
  })
211
212
  channel = result[0]
212
213
  await linkSalesChannelsToStockLocationWorkflow(container).run({ input: { id: location.id, add: [channel.id] } })
213
- // Boutique ajoutée à un projet existant : elle reçoit tout le catalogue déjà en base
214
- // (le marchand retire ensuite dans l'admin ce qu'il ne veut pas y vendre).
215
- const existingProducts = await productService.listProducts({}, { select: ["id"], take: 10000 })
214
+ // Boutique ajoutée à un projet existant : elle reçoit le catalogue déjà en base, sauf les produits
215
+ // du CSV réservés à d'autres boutiques (colonnes boutiques/themes). Le marchand ajuste ensuite dans l'admin.
216
+ const rows = readCsv()
217
+ const csvRow = new Map(rows.map((r) => [r.handle, r]))
218
+ const existingProducts = (await productService.listProducts({}, { select: ["id", "handle"], take: 10000 })).filter((pr) => {
219
+ const r = csvRow.get(pr.handle)
220
+ return !r || rowFor(r, b.slug, b.theme)
221
+ })
216
222
  if (existingProducts.length) {
217
223
  await linkProductsToSalesChannelWorkflow(container).run({
218
224
  input: { id: channel.id, add: existingProducts.map((pr) => pr.id) },
@@ -308,6 +314,20 @@ export async function uploadSeedImage(container: ExecArgs["container"], name: st
308
314
 
309
315
  type Row = Record<string, string>
310
316
 
317
+ function readCsv(): Row[] {
318
+ const csvFile = path.join(SEED_DIR, "products.csv")
319
+ if (!fs.existsSync(csvFile)) return []
320
+ return parse(fs.readFileSync(csvFile, "utf8"), { columns: true, skip_empty_lines: true, trim: true, bom: true })
321
+ }
322
+
323
+ /** Le produit de cette ligne est-il vendu dans la boutique ? Colonne « boutiques » (slugs), sinon « themes », sinon partout. */
324
+ function rowFor(r: Row, slug: string, theme: string) {
325
+ const list = (v: string) => v.split("|").map((x) => x.trim()).filter(Boolean)
326
+ if (r.boutiques) return list(r.boutiques).includes(slug)
327
+ if (r.themes) return list(r.themes).includes(theme)
328
+ return true
329
+ }
330
+
311
331
  async function importProducts({
312
332
  container,
313
333
  logger,
@@ -329,35 +349,50 @@ async function importProducts({
329
349
  endsOf: Record<string, string | undefined>
330
350
  locationId: string
331
351
  profileId: string
332
- productService: { listProducts: (f: { handle: string[] }, c: { select: string[] }) => Promise<{ handle: string }[]> }
352
+ productService: { listProducts: (f: { handle: string[] }, c: { select: string[] }) => Promise<{ id: string; handle: string; thumbnail?: string | null }[]> }
333
353
  }) {
334
- const csvFile = path.join(SEED_DIR, "products.csv")
335
- if (!fs.existsSync(csvFile)) {
354
+ const rows = readCsv()
355
+ if (!rows.length) {
336
356
  logger.info("[seed] pas de seed/products.csv : aucun produit importé")
337
357
  return
338
358
  }
339
- const rows: Row[] = parse(fs.readFileSync(csvFile, "utf8"), { columns: true, skip_empty_lines: true, trim: true, bom: true })
340
- const existing = new Set((await productService.listProducts({ handle: rows.map((r) => r.handle) }, { select: ["handle"] })).map((p) => p.handle))
359
+ const inDb = await productService.listProducts({ handle: rows.map((r) => r.handle) }, { select: ["id", "handle", "thumbnail"] })
360
+ const existing = new Set(inDb.map((p) => p.handle))
341
361
  const toCreate = rows.filter((r) => r.handle && !existing.has(r.handle))
342
- if (!toCreate.length) return
343
362
 
344
363
  // Images : fichiers de seed/images envoyés au stockage ; URL http(s) gardées telles quelles.
345
364
  const uploaded = new Map<string, string>()
346
- const imageUrl = async (name: string): Promise<string | undefined> => {
365
+ const imageUrl = async (name: string, warn = true): Promise<string | undefined> => {
347
366
  if (uploaded.has(name)) return uploaded.get(name)
348
367
  const url = await uploadSeedImage(container, name)
349
368
  if (url) uploaded.set(name, url)
350
- else logger.warn(`[seed] image introuvable : ${path.join(SEED_DIR, "images", path.basename(name))}`)
369
+ else if (warn) logger.warn(`[seed] image introuvable : ${path.join(SEED_DIR, "images", path.basename(name))}`)
351
370
  return url
352
371
  }
353
372
 
373
+ // Produits déjà en base sans photo : photos ajoutées plus tard dans seed/images → on les complète
374
+ // (un produit qui a déjà une photo, par exemple choisie dans l'admin, n'est jamais modifié).
375
+ const csvRow = new Map(rows.map((r) => [r.handle, r]))
376
+ let filled = 0
377
+ for (const p of inDb.filter((p) => !p.thumbnail)) {
378
+ const names = (csvRow.get(p.handle)?.images || "").split("|").filter(Boolean)
379
+ const images = (await Promise.all(names.map((n) => imageUrl(n, false)))).filter((u): u is string => !!u)
380
+ if (!images.length) continue
381
+ await updateProductsWorkflow(container).run({
382
+ input: { selector: { id: p.id }, update: { thumbnail: images[0], images: images.map((url) => ({ url })) } },
383
+ })
384
+ filled++
385
+ }
386
+ if (filled) logger.info(`[seed] photos ajoutées à ${filled} produit(s) existant(s)`)
387
+ if (!toCreate.length) return
388
+
354
389
  const salePrices: { variantKey: string; currency_code: string; amount: number; endsAt?: string }[] = []
355
390
  const products: CreateProductWorkflowInputDTO[] = []
356
391
  for (const r of toCreate) {
357
392
  const missing = currencies.filter((c) => !r[`price_${c}`])
358
393
  if (missing.length) logger.warn(`[seed] ${r.handle} : pas de prix en ${missing.join(", ")} (colonne price_<devise>) — invisible dans ces boutiques`)
359
394
 
360
- const images = (await Promise.all((r.images || "").split("|").filter(Boolean).map(imageUrl))).filter((u): u is string => !!u)
395
+ const images = (await Promise.all((r.images || "").split("|").filter(Boolean).map((n) => imageUrl(n)))).filter((u): u is string => !!u)
361
396
  const optionTitle = r.variant_option || "Default option"
362
397
  const values = r.variant_values ? r.variant_values.split("|").map((v) => v.trim()) : ["Default option value"]
363
398
  // Boutiques du produit : colonne « boutiques » (slugs), ou « themes » (toutes les boutiques de ces thèmes,
@@ -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
+ }