@salifbiaye/create-boutique 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
6
  "repository": {
@@ -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,
@@ -323,5 +323,13 @@
323
323
  "tech.deal": "Top deal",
324
324
  "tech.all": "See all",
325
325
  "tech.warranty": "2-year warranty",
326
- "tech.warrantySub": "On all tech"
326
+ "tech.warrantySub": "On all tech",
327
+ "fx.surprise.halloween": "Reveal the surprise",
328
+ "fx.surprise.noel": "Open the surprise",
329
+ "fx.surprise.nouvelAn": "Start the party",
330
+ "grimoire.drawEyebrow": "Today's reading",
331
+ "grimoire.draw": "Draw a card",
332
+ "grimoire.drawSub": "Three cards, three bewitched deals. Hover a card to turn it over.",
333
+ "grimoire.reveal": "Turn over",
334
+ "grimoire.shelf": "The apothecary shelf"
327
335
  }
@@ -323,5 +323,13 @@
323
323
  "tech.deal": "Offre phare",
324
324
  "tech.all": "Tout voir",
325
325
  "tech.warranty": "Garantie 2 ans",
326
- "tech.warrantySub": "Sur toute la tech"
326
+ "tech.warrantySub": "Sur toute la tech",
327
+ "fx.surprise.halloween": "Découvrir la surprise",
328
+ "fx.surprise.noel": "Ouvrir la surprise",
329
+ "fx.surprise.nouvelAn": "Lancer la fête",
330
+ "grimoire.drawEyebrow": "Le tirage du jour",
331
+ "grimoire.draw": "Tirez une carte",
332
+ "grimoire.drawSub": "Trois cartes, trois offres ensorcelées. Survolez une carte pour la retourner.",
333
+ "grimoire.reveal": "Retourner",
334
+ "grimoire.shelf": "L'étagère de l'apothicaire"
327
335
  }
@@ -14,6 +14,7 @@ import { HarvestBanner, HarvestGrid, HarvestHero, HarvestServices, HarvestStrip
14
14
  import { GalaFoil, GalaHero, GalaMidnight, GalaSelection, GalaStrokes } from "@modules/ub/gala"
15
15
  import { CinemaHero, CinemaRow, CinemaScreening, CinemaTop } from "@modules/ub/cinema"
16
16
  import { TechBento, TechFinal, TechFlash, TechGrid, TechHero, TechSpecs, TechTicker } from "@modules/ub/tech"
17
+ import { GrimoireHero, GrimoireShelf, GrimoireSpell, GrimoireTarot } from "@modules/ub/grimoire"
17
18
  import { AlbumGarland, AlbumHero, AlbumPolaroids, AlbumStory, AlbumTicket } from "@modules/ub/album"
18
19
  import { getBoutique } from "@lib/boutique/config"
19
20
  import type { Campaign } from "@lib/boutique/campaign"
@@ -153,6 +154,26 @@ async function TechPage({ campaign }: { campaign: Campaign }) {
153
154
  )
154
155
  }
155
156
 
157
+ /** Accueil « Grimoire » (Halloween 2) : les 3 offres les plus remisées au tirage, le reste sur l'étagère. */
158
+ async function GrimoirePage({ campaign }: { campaign: Campaign }) {
159
+ const {
160
+ response: { products },
161
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
162
+ const off = (p: (typeof products)[number]) =>
163
+ Math.max(0, ...(p.variants ?? []).map((v) => {
164
+ const c = v.calculated_price
165
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
166
+ }))
167
+ const sorted = [...products].sort((a, b) => off(b) - off(a))
168
+ return (
169
+ <>
170
+ <GrimoireTarot products={sorted} />
171
+ <GrimoireShelf products={products} title={campaign.sections?.products ?? t("section.products")} sub={campaign.promo?.text} />
172
+ <GrimoireSpell campaign={campaign} />
173
+ </>
174
+ )
175
+ }
176
+
156
177
  /** Accueil « Gala » (thèmes Nouvel An). */
157
178
  async function GalaProducts({ campaign }: { campaign: Campaign }) {
158
179
  const {
@@ -179,6 +200,17 @@ export default async function Home() {
179
200
  return <CampaignEnded nextUrl={campaign.nextCampaignUrl} />
180
201
  }
181
202
 
203
+ if (getBoutique().theme.home === "grimoire") {
204
+ return (
205
+ <div className="ub-grimoire">
206
+ <GrimoireHero campaign={campaign} />
207
+ <Suspense fallback={<GridFallback />}>
208
+ <GrimoirePage campaign={campaign} />
209
+ </Suspense>
210
+ </div>
211
+ )
212
+ }
213
+
182
214
  if (getBoutique().theme.home === "tech") {
183
215
  return (
184
216
  <div className="ub-tech">
@@ -0,0 +1,4 @@
1
+ /** Remonté à chaque navigation : sert de point d'accroche à la transition entre pages (styles/ub/motion.css, desktop). */
2
+ export default function Template({ children }: { children: React.ReactNode }) {
3
+ return <div className="ub-page">{children}</div>
4
+ }
@@ -3,6 +3,7 @@ import { getBoutique } from "@lib/boutique/config"
3
3
  import { themeFontClasses } from "@lib/boutique/fonts"
4
4
  import { messages } from "@lib/boutique/i18n"
5
5
  import { I18nProvider } from "@lib/boutique/i18n-client"
6
+ import Motion from "@modules/ub/motion"
6
7
  import { Metadata } from "next"
7
8
  import "styles/globals.css"
8
9
  import "styles/ub/tokens.css"
@@ -18,6 +19,8 @@ import "styles/ub/gala.css"
18
19
  import "styles/ub/cinema.css"
19
20
  import "styles/ub/album.css"
20
21
  import "styles/ub/tech.css"
22
+ import "styles/ub/grimoire.css"
23
+ import "styles/ub/motion.css"
21
24
  import "styles/ub/media.css"
22
25
  import "styles/ub/responsive.css"
23
26
  import "styles/ub/fonts.css"
@@ -60,10 +63,11 @@ export default function RootLayout(props: { children: React.ReactNode }) {
60
63
  const b = getBoutique()
61
64
 
62
65
  return (
63
- <html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-mode="light" className={themeFontClasses(b.theme)}>
66
+ <html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-event={b.theme.id.replace(/-2$/, "")} data-mode="light" className={themeFontClasses(b.theme)}>
64
67
  <body className="ub">
65
68
  <I18nProvider messages={messages("")} locale={b.locale}>
66
69
  <main className="relative">{props.children}</main>
70
+ <Motion event={b.theme.id.replace(/-2$/, "")} />
67
71
  </I18nProvider>
68
72
  </body>
69
73
  </html>
@@ -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", "gala", "cinema", "album", "tech"]).optional(),
74
+ home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire"]).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"]),
@@ -5,6 +5,7 @@ import { t } from "@lib/boutique/i18n"
5
5
  import { getProductPrice } from "@lib/util/get-product-price"
6
6
  import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
+ import SurpriseButton from "./surprise-button"
8
9
  import FadeImg from "./fade-img"
9
10
 
10
11
  /**
@@ -27,9 +28,12 @@ export function AlbumHero({ campaign }: { campaign: Campaign }) {
27
28
  {hero.eyebrow && <p className="ub-al-eyebrow">{hero.eyebrow}</p>}
28
29
  <h1 className="ub-al-title">{hero.title}</h1>
29
30
  {hero.sub && <p className="ub-al-sub">{hero.sub}</p>}
30
- <Link href="/store" className="ub-al-btn">
31
- {hero.cta}
32
- </Link>
31
+ <div className="ub-al-actions">
32
+ <Link href="/store" className="ub-al-btn">
33
+ {hero.cta}
34
+ </Link>
35
+ <SurpriseButton event="noel" label={t("fx.surprise.noel")} />
36
+ </div>
33
37
  </div>
34
38
  </section>
35
39
  )
@@ -7,6 +7,7 @@ import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import FadeImg from "./fade-img"
9
9
  import { Icon } from "./icons"
10
+ import SurpriseButton from "./surprise-button"
10
11
 
11
12
  /**
12
13
  * Accueil « Cinéma » (thèmes Halloween, theme.json → "home": "cinema") : la boutique comme une affiche de film.
@@ -63,6 +64,7 @@ export function CinemaHero({ campaign, next }: { campaign: Campaign; next: P[] }
63
64
  <Link href="/store" className="ub-cn-btn ub-cn-btn--ghost">
64
65
  {t("cinema.catalog")}
65
66
  </Link>
67
+ <SurpriseButton event="halloween" label={t("fx.surprise.halloween")} />
66
68
  </div>
67
69
  {upNext.length > 0 && (
68
70
  <div className="ub-cn-next">
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Animations desktop (chargé à la demande par motion.tsx, jamais sur mobile ni si « réduire les animations ») :
3
+ * - apparition au scroll des sections et des cartes (décalage léger entre les cartes) ;
4
+ * - défilement automatique des rangées horizontales (pause au survol, au focus, hors écran, après un geste) ;
5
+ * - ambiance de l'événement : chauves-souris occasionnelles (Halloween), neige (Noël), paillettes (Nouvel An) ;
6
+ * - burst() : confettis / particules du bouton « surprise » du hero.
7
+ * Aucun état React : tout passe par des classes CSS, transform et opacity (pas de re-render de la page).
8
+ */
9
+
10
+ export type FxEvent = "halloween" | "noel" | "nouvel-an" | string
11
+
12
+ const SCOPE = ".ub-page"
13
+ let started = false
14
+
15
+ export function start(event: FxEvent) {
16
+ if (started) return
17
+ started = true
18
+ document.documentElement.classList.add("ub-motion")
19
+ const scan = setupReveal()
20
+ const slide = setupAutoSlide()
21
+ const refresh = () => {
22
+ scan()
23
+ slide()
24
+ ambient(event)
25
+ }
26
+ refresh()
27
+ // Nouvelle page (navigation côté client) : on reprend les nouveaux éléments
28
+ let queued = false
29
+ new MutationObserver(() => {
30
+ if (queued) return
31
+ queued = true
32
+ requestAnimationFrame(() => {
33
+ queued = false
34
+ refresh()
35
+ })
36
+ }).observe(document.body, { childList: true, subtree: true })
37
+ }
38
+
39
+ /* ---------------- Apparition au scroll ---------------- */
40
+ function setupReveal() {
41
+ const io = new IntersectionObserver(
42
+ (entries) => {
43
+ for (const e of entries) {
44
+ if (!e.isIntersecting) continue
45
+ const el = e.target as HTMLElement
46
+ io.unobserve(el)
47
+ el.classList.add("is-in")
48
+ // Fin de l'animation : on retire les classes pour rendre la main aux styles (hover, transitions) du thème
49
+ const i = Number(el.style.getPropertyValue("--rv-i") || 0)
50
+ setTimeout(() => el.classList.remove("ub-rv", "is-in"), 700 + i * 60)
51
+ }
52
+ },
53
+ { rootMargin: "0px 0px -8% 0px" }
54
+ )
55
+ return () => {
56
+ const page = document.querySelector(SCOPE)
57
+ if (!page) return
58
+ const vh = window.innerHeight
59
+ const mark = (el: HTMLElement, i = 0) => {
60
+ if (el.dataset.rv) return
61
+ el.dataset.rv = "1"
62
+ // Déjà à l'écran (hero, haut de page) : rien à animer, pas de clignotement
63
+ if (el.getBoundingClientRect().top < vh * 0.92) return
64
+ if (i) el.style.setProperty("--rv-i", String(i))
65
+ el.classList.add("ub-rv")
66
+ io.observe(el)
67
+ }
68
+ page.querySelectorAll<HTMLElement>(":scope section").forEach((s, n) => n > 0 && mark(s))
69
+ page.querySelectorAll<HTMLElement>(":scope section :is(ul, ol)").forEach((list) => {
70
+ const items = Array.from(list.children) as HTMLElement[]
71
+ if (items.length < 2 || items.length > 24) return
72
+ items.forEach((li, i) => mark(li, i % 8))
73
+ })
74
+ }
75
+ }
76
+
77
+ /* ---------------- Rangées horizontales : défilement automatique ---------------- */
78
+ function setupAutoSlide() {
79
+ const rows = new WeakSet<HTMLElement>()
80
+ return () => {
81
+ const page = document.querySelector(SCOPE)
82
+ if (!page) return
83
+ page.querySelectorAll<HTMLElement>(":scope :is(ul, ol)").forEach((row) => {
84
+ if (rows.has(row)) return
85
+ const ox = getComputedStyle(row).overflowX
86
+ if ((ox !== "auto" && ox !== "scroll") || row.scrollWidth <= row.clientWidth + 10) return
87
+ rows.add(row)
88
+ let visible = false
89
+ let pausedUntil = 0
90
+ let hover = false
91
+ const pause = (ms: number) => (pausedUntil = Date.now() + ms)
92
+ row.addEventListener("mouseenter", () => (hover = true))
93
+ row.addEventListener("mouseleave", () => (hover = false))
94
+ row.addEventListener("focusin", () => pause(10000))
95
+ row.addEventListener("wheel", () => pause(8000), { passive: true })
96
+ row.addEventListener("pointerdown", () => pause(8000), { passive: true })
97
+ new IntersectionObserver(([e]) => (visible = e.isIntersecting), { threshold: 0.4 }).observe(row)
98
+ setInterval(() => {
99
+ if (!visible || hover || document.hidden || Date.now() < pausedUntil || !row.isConnected) return
100
+ const first = row.children[0] as HTMLElement | undefined
101
+ if (!first) return
102
+ const step = first.getBoundingClientRect().width + (parseFloat(getComputedStyle(row).columnGap) || 16)
103
+ const end = row.scrollLeft + row.clientWidth >= row.scrollWidth - 4
104
+ if (end) row.scrollTo({ left: 0, behavior: "smooth" })
105
+ else row.scrollBy({ left: step, behavior: "smooth" })
106
+ }, 4500)
107
+ })
108
+ }
109
+ }
110
+
111
+ /* ---------------- Ambiance de l'événement ---------------- */
112
+ let ambientHero: Element | null = null
113
+ let batTimer: number | undefined
114
+
115
+ function ambient(event: FxEvent) {
116
+ const hero = document.querySelector(`${SCOPE} section`)
117
+ if (event === "halloween") {
118
+ if (batTimer === undefined) scheduleBats(4000)
119
+ return
120
+ }
121
+ if (!hero || hero === ambientHero) return
122
+ ambientHero = hero
123
+ if (event === "noel") particlesIn(hero as HTMLElement, "snow")
124
+ if (event === "nouvel-an") particlesIn(hero as HTMLElement, "sparkle")
125
+ }
126
+
127
+ /* Halloween : 1 à 3 chauves-souris traversent l'écran de temps en temps (toutes les 12 à 28 s). */
128
+ function scheduleBats(delay: number) {
129
+ batTimer = window.setTimeout(() => {
130
+ if (!document.hidden) flock(1 + Math.floor(Math.random() * 3))
131
+ scheduleBats(12000 + Math.random() * 16000)
132
+ }, delay)
133
+ }
134
+
135
+ const BAT =
136
+ '<svg viewBox="0 0 64 32" width="100%" height="100%"><path fill="currentColor" d="M32 10c-2 0-3 2-3 2l-2-4-1 5C20 8 12 6 2 10c6 1 10 5 11 10 3-3 7-3 9 0 2-3 5-3 7-1 1-2 2-3 3-3s2 1 3 3c2-2 5-2 7 1 2-3 6-3 9 0 1-5 5-9 11-10-10-4-18-2-24 3l-1-5-2 4s-1-2-3-2z"/></svg>'
137
+
138
+ function flock(n: number, fromY?: number) {
139
+ for (let i = 0; i < n; i++) {
140
+ const bat = document.createElement("span")
141
+ bat.className = Math.random() < 0.5 ? "ub-bat ub-bat--rtl" : "ub-bat"
142
+ bat.setAttribute("aria-hidden", "true")
143
+ bat.innerHTML = `<span>${BAT}</span>`
144
+ const size = 22 + Math.random() * 22
145
+ bat.style.cssText = `--y:${fromY ?? 8 + Math.random() * 45}vh;--dy:${-10 + Math.random() * 20}vh;--s:${size}px;--d:${6 + Math.random() * 3}s;--delay:${i * 0.35}s`
146
+ bat.addEventListener("animationend", () => bat.remove())
147
+ document.body.appendChild(bat)
148
+ }
149
+ }
150
+
151
+ /* Noël (neige) / Nouvel An (paillettes dorées qui montent) : canvas dans le hero, actif seulement quand il est visible. */
152
+ function particlesIn(host: HTMLElement, kind: "snow" | "sparkle") {
153
+ const canvas = document.createElement("canvas")
154
+ canvas.className = "ub-fx-canvas"
155
+ canvas.setAttribute("aria-hidden", "true")
156
+ if (getComputedStyle(host).position === "static") host.style.position = "relative"
157
+ host.appendChild(canvas)
158
+ const ctx = canvas.getContext("2d")
159
+ if (!ctx) return
160
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
161
+ let w = 0
162
+ let h = 0
163
+ const resize = () => {
164
+ w = host.clientWidth
165
+ h = host.clientHeight
166
+ canvas.width = w * dpr
167
+ canvas.height = h * dpr
168
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
169
+ }
170
+ resize()
171
+ new ResizeObserver(resize).observe(host)
172
+ const count = kind === "snow" ? 46 : 34
173
+ const ps = Array.from({ length: count }, () => ({
174
+ x: Math.random() * w,
175
+ y: Math.random() * h,
176
+ r: kind === "snow" ? 1 + Math.random() * 2.4 : 0.8 + Math.random() * 1.8,
177
+ v: kind === "snow" ? 0.25 + Math.random() * 0.6 : 0.15 + Math.random() * 0.4,
178
+ a: Math.random() * Math.PI * 2,
179
+ }))
180
+ let visible = true
181
+ new IntersectionObserver(([e]) => (visible = e.isIntersecting)).observe(host)
182
+ let last = 0
183
+ const frame = (t: number) => {
184
+ requestAnimationFrame(frame)
185
+ if (!visible || document.hidden || t - last < 33) return // ~30 i/s, rien hors écran
186
+ last = t
187
+ ctx.clearRect(0, 0, w, h)
188
+ for (const p of ps) {
189
+ p.a += 0.02
190
+ if (kind === "snow") {
191
+ p.y += p.v
192
+ p.x += Math.sin(p.a) * 0.3
193
+ if (p.y > h + 4) (p.y = -4), (p.x = Math.random() * w)
194
+ ctx.fillStyle = "rgba(255,255,255,0.75)"
195
+ } else {
196
+ p.y -= p.v
197
+ p.x += Math.sin(p.a) * 0.2
198
+ if (p.y < -4) (p.y = h + 4), (p.x = Math.random() * w)
199
+ ctx.fillStyle = `rgba(240,210,130,${0.35 + 0.45 * Math.abs(Math.sin(p.a * 1.5))})`
200
+ }
201
+ ctx.beginPath()
202
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
203
+ ctx.fill()
204
+ }
205
+ }
206
+ requestAnimationFrame(frame)
207
+ }
208
+
209
+ /* ---------------- Surprise : confettis / particules pendant ~3 s ---------------- */
210
+ const PALETTES: Record<string, string[]> = {
211
+ halloween: ["#ff7a1a", "#f2e14c", "#8b5cf6", "#1a1a1a", "#ffb15c"],
212
+ noel: ["#ffffff", "#d4af37", "#c0392b", "#1f5c3a", "#f7e7b4"],
213
+ "nouvel-an": ["#e8c766", "#d9dbe1", "#ffffff", "#f5b5c8", "#b08a2e"],
214
+ }
215
+
216
+ export function burst(event: FxEvent, origin: { x: number; y: number }) {
217
+ if (event === "halloween") flock(6, (origin.y / window.innerHeight) * 100 - 10)
218
+ const colors = PALETTES[event] ?? PALETTES["nouvel-an"]
219
+ const canvas = document.createElement("canvas")
220
+ canvas.className = "ub-fx-burst"
221
+ canvas.setAttribute("aria-hidden", "true")
222
+ document.body.appendChild(canvas)
223
+ const ctx = canvas.getContext("2d")
224
+ if (!ctx) return canvas.remove()
225
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
226
+ const w = window.innerWidth
227
+ const h = window.innerHeight
228
+ canvas.width = w * dpr
229
+ canvas.height = h * dpr
230
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
231
+ const ps = Array.from({ length: 140 }, () => {
232
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.1
233
+ const speed = 6 + Math.random() * 9
234
+ return {
235
+ x: origin.x,
236
+ y: origin.y,
237
+ vx: Math.cos(angle) * speed,
238
+ vy: Math.sin(angle) * speed,
239
+ rot: Math.random() * 6,
240
+ vr: (Math.random() - 0.5) * 0.3,
241
+ size: 5 + Math.random() * 6,
242
+ color: colors[Math.floor(Math.random() * colors.length)],
243
+ round: event === "noel" ? Math.random() < 0.6 : Math.random() < 0.25,
244
+ }
245
+ })
246
+ const t0 = performance.now()
247
+ const frame = (t: number) => {
248
+ const k = (t - t0) / 3000
249
+ if (k >= 1) return canvas.remove()
250
+ ctx.clearRect(0, 0, w, h)
251
+ ctx.globalAlpha = k > 0.7 ? (1 - k) / 0.3 : 1
252
+ for (const p of ps) {
253
+ p.vy += 0.22
254
+ p.vx *= 0.99
255
+ p.x += p.vx
256
+ p.y += p.vy
257
+ p.rot += p.vr
258
+ ctx.fillStyle = p.color
259
+ ctx.save()
260
+ ctx.translate(p.x, p.y)
261
+ ctx.rotate(p.rot)
262
+ if (p.round) {
263
+ ctx.beginPath()
264
+ ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2)
265
+ ctx.fill()
266
+ } else ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2)
267
+ ctx.restore()
268
+ }
269
+ requestAnimationFrame(frame)
270
+ }
271
+ requestAnimationFrame(frame)
272
+ }
@@ -7,6 +7,7 @@ import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import FadeImg from "./fade-img"
9
9
  import { Icon } from "./icons"
10
+ import SurpriseButton from "./surprise-button"
10
11
 
11
12
  /**
12
13
  * Accueil « Gala » (thèmes Nouvel An, theme.json → "home": "gala") : noir, or et argent.
@@ -54,6 +55,7 @@ export function GalaHero({ campaign }: { campaign: Campaign }) {
54
55
  {hero.cta2}
55
56
  </Link>
56
57
  )}
58
+ <SurpriseButton event="nouvel-an" label={t("fx.surprise.nouvelAn")} />
57
59
  </div>
58
60
  </div>
59
61
  </div>
@@ -0,0 +1,194 @@
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
+ import SurpriseButton from "./surprise-button"
11
+
12
+ /**
13
+ * Accueil « Grimoire » (Halloween 2, theme.json → "home": "grimoire") : jaune acide sur nuit.
14
+ * Hero plein cadre (le titre « sort de terre »), tirage de tarot (3 cartes qui se retournent),
15
+ * étagères d'apothicaire (cadres en arche, étiquettes N°), cercle de sort avec compte à rebours.
16
+ * CSS : styles/ub/grimoire.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
+ const ARCANA = ["XIII", "XVIII", "XV"]
26
+
27
+ export function GrimoireHero({ campaign }: { campaign: Campaign }) {
28
+ const { hero } = campaign
29
+ const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
30
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
31
+ const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
32
+ const before = hl ? hero.title.slice(0, hero.title.indexOf(hl)).trim() : hero.title
33
+ const after = hl ? hero.title.slice(hero.title.indexOf(hl) + hl.length).trim() : ""
34
+
35
+ return (
36
+ <section className="ub-gr-hero" style={image ? { ["--gr-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
37
+ <div className="ub-gr-hero__bg" aria-hidden />
38
+ <div className="ub-wrap ub-gr-hero__in">
39
+ {hero.eyebrow && <p className="ub-gr-eyebrow">{hero.eyebrow}</p>}
40
+ <h1 className="ub-gr-title">
41
+ {before && <span className="ub-gr-title__line">{before}</span>}
42
+ {hl && <span className="ub-gr-title__line ub-gr-title__hl">{hl}</span>}
43
+ {after && <span className="ub-gr-title__line">{after}</span>}
44
+ </h1>
45
+ <div className="ub-gr-hero__foot">
46
+ {hero.sub && <p className="ub-gr-sub">{hero.sub}</p>}
47
+ <div className="ub-gr-actions">
48
+ <Link href="/store" className="ub-gr-btn ub-gr-btn--solid">
49
+ {hero.cta} <Icon name="arrowRight" />
50
+ </Link>
51
+ {hero.cta2 && (
52
+ <Link href="/store" className="ub-gr-btn ub-gr-btn--ghost">
53
+ {hero.cta2}
54
+ </Link>
55
+ )}
56
+ <SurpriseButton event="halloween" label={t("fx.surprise.halloween")} />
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </section>
61
+ )
62
+ }
63
+
64
+ /** Tirage : 3 cartes de tarot face cachée ; survol / focus / toucher = la carte se retourne sur une offre. */
65
+ export function GrimoireTarot({ products }: { products: P[] }) {
66
+ const cards = products.filter(imageOf).slice(0, 3)
67
+ if (cards.length < 3) return null
68
+ return (
69
+ <section className="ub-gr-section ub-gr-tarot">
70
+ <div className="ub-wrap">
71
+ <div className="ub-gr-head ub-gr-head--center">
72
+ <p className="ub-gr-eyebrow">{t("grimoire.drawEyebrow")}</p>
73
+ <h2 className="ub-gr-h2">{t("grimoire.draw")}</h2>
74
+ <p className="ub-gr-lead">{t("grimoire.drawSub")}</p>
75
+ </div>
76
+ <ul className="ub-gr-deck">
77
+ {cards.map((p, i) => {
78
+ const { price, onSale } = priceOf(p)
79
+ return (
80
+ <li key={p.id}>
81
+ <Link href={`/products/${p.handle}`} className="ub-gr-card" aria-label={p.title}>
82
+ <span className="ub-gr-card__inner">
83
+ <span className="ub-gr-card__back" aria-hidden>
84
+ <span className="ub-gr-card__num">{ARCANA[i]}</span>
85
+ <svg viewBox="0 0 100 100" className="ub-gr-sigil">
86
+ <circle cx="50" cy="50" r="40" />
87
+ <circle cx="50" cy="50" r="28" />
88
+ <path d="M50 10 L61 38 L90 38 L66 56 L75 86 L50 68 L25 86 L34 56 L10 38 L39 38 Z" />
89
+ </svg>
90
+ <span className="ub-gr-card__hint">{t("grimoire.reveal")}</span>
91
+ </span>
92
+ <span className="ub-gr-card__front">
93
+ <span className="ub-gr-card__img">
94
+ <FadeImg src={imageOf(p)} alt="" />
95
+ {onSale && price?.percentage_diff && <b className="ub-gr-off">-{price.percentage_diff}%</b>}
96
+ </span>
97
+ <span className="ub-gr-card__name">{p.title}</span>
98
+ {price && (
99
+ <span className="ub-gr-price">
100
+ <b>{price.calculated_price}</b>
101
+ {onSale && <s>{price.original_price}</s>}
102
+ </span>
103
+ )}
104
+ </span>
105
+ </span>
106
+ </Link>
107
+ </li>
108
+ )
109
+ })}
110
+ </ul>
111
+ </div>
112
+ </section>
113
+ )
114
+ }
115
+
116
+ /** Étagères d'apothicaire : cadres en arche posés sur des planches, étiquette « N° ». */
117
+ export function GrimoireShelf({ products, title, sub }: { products: P[]; title: string; sub?: string }) {
118
+ if (!products.length) return null
119
+ return (
120
+ <section className="ub-gr-section">
121
+ <div className="ub-wrap">
122
+ <div className="ub-gr-head">
123
+ <div>
124
+ <p className="ub-gr-eyebrow">{t("grimoire.shelf")}</p>
125
+ <h2 className="ub-gr-h2">{title}</h2>
126
+ </div>
127
+ {sub && <p className="ub-gr-lead">{sub}</p>}
128
+ </div>
129
+ <ul className="ub-gr-shelf">
130
+ {products.map((p, i) => {
131
+ const { price, onSale } = priceOf(p)
132
+ const image = imageOf(p)
133
+ return (
134
+ <li key={p.id}>
135
+ <Link href={`/products/${p.handle}`} className="ub-gr-jar">
136
+ <span className="ub-gr-jar__arch">
137
+ {image ? <FadeImg src={image} alt="" /> : <span className="ub-ph" />}
138
+ {onSale && price?.percentage_diff && <b className="ub-gr-off">-{price.percentage_diff}%</b>}
139
+ </span>
140
+ <span className="ub-gr-jar__label">
141
+ <small>N°{String(i + 1).padStart(2, "0")}</small>
142
+ <span className="ub-gr-jar__name">{p.title}</span>
143
+ {price && (
144
+ <span className="ub-gr-price">
145
+ <b>{price.calculated_price}</b>
146
+ {onSale && <s>{price.original_price}</s>}
147
+ </span>
148
+ )}
149
+ </span>
150
+ </Link>
151
+ </li>
152
+ )
153
+ })}
154
+ </ul>
155
+ </div>
156
+ </section>
157
+ )
158
+ }
159
+
160
+ /** Cercle de sort : runes qui tournent lentement, texte promo et compte à rebours au centre. */
161
+ export function GrimoireSpell({ campaign }: { campaign: Campaign }) {
162
+ const promo = campaign.promo
163
+ return (
164
+ <section className="ub-gr-section">
165
+ <div className="ub-wrap">
166
+ <div className="ub-gr-spell">
167
+ <svg className="ub-gr-spell__ring" viewBox="0 0 400 400" aria-hidden>
168
+ <defs>
169
+ <path id="ub-gr-ring" d="M200 200 m-170 0 a170 170 0 1 1 340 0 a170 170 0 1 1 -340 0" />
170
+ </defs>
171
+ <circle cx="200" cy="200" r="190" />
172
+ <circle cx="200" cy="200" r="150" />
173
+ <text>
174
+ <textPath href="#ub-gr-ring">ᚠ ᚢ ᚦ ᚨ ᚱ ᚲ ᚷ ᚹ ᚺ ᚾ ᛁ ᛃ ᛇ ᛈ ᛉ ᛊ ᛏ ᛒ ᛖ ᛗ ᛚ ᛜ ᛞ ᛟ ᚠ ᚢ ᚦ ᚨ ᚱ ᚲ ᚷ ᚹ ᚺ ᚾ ᛁ ᛃ</textPath>
175
+ </text>
176
+ </svg>
177
+ <div className="ub-gr-spell__text">
178
+ {promo?.eyebrow && <p className="ub-gr-eyebrow">{promo.eyebrow}</p>}
179
+ <h2 className="ub-gr-h2">{promo?.title ?? campaign.hero.title}</h2>
180
+ {promo?.text && <p className="ub-gr-lead">{promo.text}</p>}
181
+ {campaign.showCountdown !== false && (
182
+ <div className="ub-gr-spell__cd">
183
+ <CampaignCountdown endsAt={campaign.endsAt} />
184
+ </div>
185
+ )}
186
+ <Link href="/store" className="ub-gr-btn ub-gr-btn--solid">
187
+ {promo?.cta ?? campaign.hero.cta} <Icon name="arrowRight" />
188
+ </Link>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </section>
193
+ )
194
+ }
@@ -0,0 +1,20 @@
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+
5
+ /** Écran large + souris + animations autorisées : seul cas où les animations desktop sont chargées. */
6
+ export const DESKTOP_FX = "(min-width: 1024px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"
7
+
8
+ /**
9
+ * Active les animations desktop (styles/ub/motion.css + fx-runtime) après le chargement de la page.
10
+ * Rien n'est téléchargé sur mobile : le code est importé à la demande, seulement si DESKTOP_FX est vrai.
11
+ */
12
+ export default function Motion({ event }: { event: string }) {
13
+ useEffect(() => {
14
+ if (!window.matchMedia(DESKTOP_FX).matches) return
15
+ const go = () => import("./fx-runtime").then((m) => m.start(event))
16
+ if ("requestIdleCallback" in window) window.requestIdleCallback(go, { timeout: 1500 })
17
+ else setTimeout(go, 300)
18
+ }, [event])
19
+ return null
20
+ }
@@ -0,0 +1,20 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Bouton « surprise » du hero (desktop uniquement, masqué par le CSS sur mobile) :
5
+ * lance quelques secondes de confettis / particules aux couleurs de l'événement, sans bloquer la page.
6
+ */
7
+ export default function SurpriseButton({ event, label }: { event: string; label: string }) {
8
+ return (
9
+ <button
10
+ type="button"
11
+ className="ub-surprise"
12
+ onClick={(e) => {
13
+ const r = e.currentTarget.getBoundingClientRect()
14
+ import("./fx-runtime").then((m) => m.burst(event, { x: r.left + r.width / 2, y: r.top + r.height / 2 }))
15
+ }}
16
+ >
17
+ <span aria-hidden>✦</span> {label}
18
+ </button>
19
+ )
20
+ }
@@ -0,0 +1,178 @@
1
+ /* Accueil « Grimoire » (Halloween 2) — modules/ub/grimoire.tsx. Jaune acide sur nuit, sorcellerie d'apothicaire.
2
+ Jetons de couleur : styles/ub/cinema.css ([data-variant="halloween-2"]). */
3
+
4
+ .ub-al-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; }
5
+
6
+ [data-variant="halloween-2"] { --gr-acid: #f2e14c; --gr-night: #0b0a0c; --gr-wood: #2a1d14; }
7
+
8
+ /* ---------- Hero : image plein cadre, titre qui « sort de terre » ---------- */
9
+ .ub-gr-hero {
10
+ position: relative;
11
+ isolation: isolate;
12
+ overflow: hidden;
13
+ display: flex;
14
+ align-items: flex-end;
15
+ min-height: clamp(600px, 52vw, 780px);
16
+ background: var(--gr-night);
17
+ color: #fff;
18
+ }
19
+ .ub-gr-hero__bg { position: absolute; inset: 0; z-index: -2; background: var(--gr-img, radial-gradient(60% 60% at 40% 30%, #3a2a14, var(--gr-night))) center 35% / cover no-repeat; }
20
+ .ub-gr-hero::after {
21
+ content: "";
22
+ position: absolute;
23
+ inset: 0;
24
+ z-index: -1;
25
+ background:
26
+ linear-gradient(0deg, var(--gr-night) 2%, rgb(11 10 12 / 0.75) 26%, transparent 60%),
27
+ linear-gradient(90deg, rgb(11 10 12 / 0.55), transparent 55%);
28
+ }
29
+ .ub-gr-hero__in { width: 100%; padding-top: var(--space-16); padding-bottom: var(--space-12); }
30
+ .ub-gr-eyebrow { margin: 0; color: var(--gr-acid); font: 700 13px/18px var(--ub-font-body); letter-spacing: 0.3em; text-transform: uppercase; }
31
+ .ub-gr-title { display: grid; margin: 14px 0 0 !important; font-family: var(--font-oswald); font-weight: 700; text-transform: uppercase; line-height: 0.92; padding-bottom: 0.06em; letter-spacing: -0.01em; }
32
+ .ub-gr-title__line { font-size: clamp(52px, 6.4vw, 104px); }
33
+ /* Mot mis en avant : lettres creuses jaunes */
34
+ .ub-gr-title__hl { color: transparent; -webkit-text-stroke: 2px var(--gr-acid); }
35
+ .ub-gr-hero__foot { display: grid; grid-template-columns: minmax(0, 520px) auto; align-items: end; justify-content: space-between; gap: var(--space-8); margin-top: var(--space-8); }
36
+ .ub-gr-sub { margin: 0; color: #d8d2c6; font-size: 17px; line-height: 27px; }
37
+ .ub-gr-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; }
38
+ .ub-gr-btn { display: inline-flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 26px; border-radius: 4px; font: 700 14px/18px var(--ub-font-body); letter-spacing: 0.08em; text-transform: uppercase; transition: transform 0.2s, background 0.2s; }
39
+ .ub-gr-btn svg { width: 18px; height: 18px; }
40
+ .ub-gr-btn--solid { background: var(--gr-acid); color: var(--gr-night); }
41
+ .ub-gr-btn--solid:hover { transform: translateY(-2px); }
42
+ .ub-gr-btn--ghost { border: 1px solid rgb(255 255 255 / 0.4); color: #fff; }
43
+ .ub-gr-btn--ghost:hover { background: rgb(255 255 255 / 0.08); }
44
+ .ub-gr-hero .ub-surprise { color: var(--gr-acid); }
45
+
46
+ /* Desktop : le titre monte depuis le sol, ligne par ligne */
47
+ @media (min-width: 1024px) and (prefers-reduced-motion: no-preference) {
48
+ .ub-gr-title { overflow: hidden; }
49
+ .ub-gr-title__line { animation: ub-gr-rise 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
50
+ .ub-gr-title__line:nth-child(2) { animation-delay: 0.12s; }
51
+ .ub-gr-title__line:nth-child(3) { animation-delay: 0.24s; }
52
+ @keyframes ub-gr-rise { from { transform: translate3d(0, 105%, 0); } }
53
+ }
54
+
55
+ /* ---------- Sections ---------- */
56
+ .ub-gr-section { padding-top: var(--space-16); }
57
+ .ub-grimoire > :last-child { padding-bottom: var(--space-16); }
58
+ .ub-gr-head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: var(--space-6); margin-bottom: var(--space-10); }
59
+ .ub-gr-head--center { display: grid; justify-items: center; text-align: center; }
60
+ .ub-gr-h2 { margin: 8px 0 0; font: 700 clamp(34px, 4.2vw, 60px) / 0.95 var(--font-oswald); text-transform: uppercase; }
61
+ .ub-gr-lead { max-width: 36em; margin: 0; color: var(--ink-muted); font-size: 16px; line-height: 25px; }
62
+ .ub-gr-off { position: absolute; top: 10px; left: 10px; padding: 4px 9px; border-radius: 3px; background: var(--gr-acid); color: var(--gr-night); font: 800 13px/16px var(--ub-font-body); }
63
+ .ub-gr-price { display: flex; align-items: baseline; gap: 8px; }
64
+ .ub-gr-price b { color: var(--gr-acid); font: 700 18px/22px var(--font-oswald); }
65
+ .ub-gr-price s { color: var(--ink-muted); font-size: 13px; }
66
+
67
+ /* ---------- Tirage de tarot : cartes qui se retournent ---------- */
68
+ .ub-gr-deck { display: grid; grid-template-columns: repeat(3, minmax(0, 300px)); justify-content: center; gap: clamp(16px, 3vw, 40px); margin: 0; padding: 0; list-style: none; }
69
+ .ub-gr-deck > li:nth-child(1) { transform: rotate(-4deg) translateY(14px); }
70
+ .ub-gr-deck > li:nth-child(3) { transform: rotate(4deg) translateY(14px); }
71
+ .ub-gr-card { display: block; aspect-ratio: 2 / 3.3; perspective: 1200px; color: var(--ink); }
72
+ .ub-gr-card__inner { position: relative; display: block; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); }
73
+ .ub-gr-card:is(:hover, :focus-visible, :active) .ub-gr-card__inner { transform: rotateY(180deg); }
74
+ .ub-gr-card__back,
75
+ .ub-gr-card__front { position: absolute; inset: 0; display: grid; border-radius: 14px; backface-visibility: hidden; -webkit-backface-visibility: hidden; overflow: hidden; }
76
+ .ub-gr-card__back {
77
+ place-items: center;
78
+ grid-template-rows: auto 1fr auto;
79
+ padding: 22px;
80
+ border: 1px solid rgb(242 225 76 / 0.5);
81
+ background:
82
+ repeating-linear-gradient(45deg, rgb(242 225 76 / 0.05) 0 2px, transparent 2px 14px),
83
+ radial-gradient(circle at 50% 45%, #2a2410, var(--gr-night) 75%);
84
+ box-shadow: inset 0 0 0 8px var(--gr-night), inset 0 0 0 9px rgb(242 225 76 / 0.35), 0 24px 50px -24px rgb(0 0 0 / 0.9);
85
+ }
86
+ .ub-gr-card__num { color: var(--gr-acid); font: 700 22px/1 var(--font-oswald); letter-spacing: 0.2em; }
87
+ .ub-gr-sigil { width: 70%; fill: none; stroke: var(--gr-acid); stroke-width: 1.2; opacity: 0.85; }
88
+ .ub-gr-card__hint { color: #cfc6a8; font-size: 12px; letter-spacing: 0.3em; text-transform: uppercase; }
89
+ .ub-gr-card__front { grid-template-rows: 1fr auto auto; gap: 8px; padding: 12px 12px 18px; border: 1px solid var(--gr-acid); background: var(--surface); transform: rotateY(180deg); }
90
+ .ub-gr-card__img { position: relative; overflow: hidden; border-radius: 10px; }
91
+ .ub-gr-card__img img { width: 100%; height: 100%; object-fit: cover; }
92
+ .ub-gr-card__name { padding: 0 6px; font: 700 18px/22px var(--font-oswald); text-transform: uppercase; }
93
+ .ub-gr-card__front .ub-gr-price { padding: 0 6px; }
94
+
95
+ /* ---------- Étagères d'apothicaire ---------- */
96
+ .ub-gr-shelf {
97
+ display: grid;
98
+ grid-template-columns: repeat(4, 1fr);
99
+ column-gap: 24px;
100
+ row-gap: 56px;
101
+ margin: 0;
102
+ padding: 0;
103
+ list-style: none;
104
+ }
105
+ .ub-gr-shelf > li { position: relative; }
106
+ /* Planche sous chaque rangée */
107
+ .ub-gr-shelf > li::after {
108
+ content: "";
109
+ position: absolute;
110
+ left: -12px;
111
+ right: -12px;
112
+ bottom: -22px;
113
+ height: 10px;
114
+ border-radius: 2px;
115
+ background: linear-gradient(180deg, #4a3422, var(--gr-wood));
116
+ box-shadow: 0 12px 20px -8px rgb(0 0 0 / 0.8);
117
+ }
118
+ .ub-gr-jar { display: grid; gap: 12px; color: var(--ink); }
119
+ .ub-gr-jar__arch {
120
+ position: relative;
121
+ display: block;
122
+ aspect-ratio: 3 / 4;
123
+ overflow: hidden;
124
+ border: 1px solid rgb(242 225 76 / 0.35);
125
+ border-radius: 999px 999px 10px 10px;
126
+ background: var(--surface);
127
+ }
128
+ .ub-gr-jar__arch img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1); }
129
+ .ub-gr-jar:hover .ub-gr-jar__arch img { transform: scale(1.05); }
130
+ .ub-gr-jar__arch .ub-gr-off { top: auto; bottom: 12px; left: 50%; transform: translateX(-50%); }
131
+ .ub-gr-jar__label { display: grid; justify-items: center; gap: 4px; text-align: center; }
132
+ .ub-gr-jar__label small { color: var(--gr-acid); font: 700 11px/14px var(--ub-font-body); letter-spacing: 0.25em; }
133
+ .ub-gr-jar__name { font: 600 16px/21px var(--ub-font-body); }
134
+
135
+ /* ---------- Cercle de sort ---------- */
136
+ .ub-gr-spell {
137
+ position: relative;
138
+ isolation: isolate;
139
+ display: grid;
140
+ place-items: center;
141
+ min-height: 560px;
142
+ overflow: hidden;
143
+ padding: var(--space-12) var(--space-6);
144
+ border: 1px solid var(--border);
145
+ border-radius: 20px;
146
+ background: radial-gradient(50% 60% at 50% 50%, #221d0c, var(--gr-night) 80%);
147
+ text-align: center;
148
+ }
149
+ .ub-gr-spell__ring { position: absolute; z-index: -1; width: min(640px, 110%); aspect-ratio: 1; fill: none; stroke: rgb(242 225 76 / 0.2); stroke-width: 1; }
150
+ .ub-gr-spell__ring text { fill: rgb(242 225 76 / 0.45); stroke: none; font-size: 18px; letter-spacing: 6px; }
151
+ @media (min-width: 1024px) and (prefers-reduced-motion: no-preference) {
152
+ .ub-gr-spell__ring { animation: ub-gr-turn 90s linear infinite; }
153
+ @keyframes ub-gr-turn { to { transform: rotate(360deg); } }
154
+ }
155
+ .ub-gr-spell__text { display: grid; justify-items: center; gap: 14px; max-width: 560px; }
156
+ .ub-gr-spell__cd .ub-countdown__c { border: 1px solid rgb(242 225 76 / 0.4); background: rgb(11 10 12 / 0.7); color: var(--gr-acid); box-shadow: none; }
157
+
158
+ /* ---------- Responsive ---------- */
159
+ @media (max-width: 1024px) {
160
+ .ub-gr-shelf { grid-template-columns: repeat(3, 1fr); }
161
+ }
162
+ @media (max-width: 900px) {
163
+ .ub-gr-hero { min-height: 560px; }
164
+ .ub-gr-hero__foot { grid-template-columns: 1fr; }
165
+ .ub-gr-actions { justify-content: flex-start; }
166
+ .ub-gr-deck { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
167
+ .ub-gr-deck > li:nth-child(1), .ub-gr-deck > li:nth-child(3) { transform: none; }
168
+ .ub-gr-card__name { font-size: 14px; line-height: 18px; }
169
+ .ub-gr-card__hint { display: none; }
170
+ }
171
+ @media (max-width: 600px) {
172
+ .ub-gr-title__line { font-size: 48px; }
173
+ .ub-gr-shelf { grid-template-columns: repeat(2, 1fr); column-gap: 14px; row-gap: 44px; }
174
+ .ub-gr-spell { min-height: 460px; }
175
+ /* Tirage sur mobile : les cartes défilent, touchées = retournées */
176
+ .ub-gr-deck { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; justify-content: flex-start; }
177
+ .ub-gr-deck > li { flex: 0 0 70%; scroll-snap-align: center; }
178
+ }
@@ -0,0 +1,104 @@
1
+ /* Animations desktop uniquement (écran ≥ 1024 px, souris, animations autorisées). Le mobile n'est pas concerné :
2
+ toutes les règles sont dans ce media query, ou dépendent de html.ub-motion (ajouté par fx-runtime, jamais sur mobile).
3
+ Uniquement transform / opacity : pas de recalcul de mise en page. */
4
+
5
+ /* Bouton « surprise » du hero : desktop seulement */
6
+ .ub-surprise { display: none; }
7
+
8
+ @media (min-width: 1024px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
9
+ /* ---------- Transition entre les pages (le contenu de chaque page est remonté par app/(main)/template.tsx) ---------- */
10
+ .ub-page { animation: ub-page-in 0.38s cubic-bezier(0.2, 0.7, 0.2, 1) backwards; }
11
+ @keyframes ub-page-in { from { opacity: 0; transform: translate3d(0, 10px, 0); } }
12
+ /* Voile court aux couleurs de l'événement, au-dessus du contenu qui arrive */
13
+ .ub-page::before {
14
+ content: "";
15
+ position: fixed;
16
+ inset: 0;
17
+ z-index: 70;
18
+ pointer-events: none;
19
+ opacity: 0;
20
+ animation: ub-veil 0.45s ease-out;
21
+ }
22
+ @keyframes ub-veil { from { opacity: 1; } }
23
+ html:not([data-event]) .ub-page::before,
24
+ html[data-event="black-friday"] .ub-page::before,
25
+ html[data-event="thanksgiving"] .ub-page::before,
26
+ html[data-event="cyber-monday"] .ub-page::before { display: none; }
27
+ html[data-event="halloween"] .ub-page::before { background: radial-gradient(circle at 50% 40%, transparent 0%, rgb(0 0 0 / 0.85) 70%); }
28
+ html[data-event="noel"] .ub-page::before { background: rgb(255 250 240 / 0.55); animation-duration: 0.5s; }
29
+ html[data-event="nouvel-an"] .ub-page::before { background: linear-gradient(100deg, transparent 30%, rgb(232 199 102 / 0.35) 50%, transparent 70%); background-size: 250% 100%; animation: ub-veil-shine 0.55s ease-out; }
30
+ @keyframes ub-veil-shine { from { opacity: 1; background-position: 100% 0; } to { opacity: 0; background-position: 0 0; } }
31
+
32
+ /* ---------- Apparition au scroll (classes posées par fx-runtime, retirées à la fin) ---------- */
33
+ html.ub-motion .ub-rv {
34
+ opacity: 0;
35
+ transform: translate3d(0, 26px, 0);
36
+ transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
37
+ transition-delay: calc(var(--rv-i, 0) * 60ms);
38
+ }
39
+ html.ub-motion .ub-rv.is-in { opacity: 1; transform: none; }
40
+
41
+ /* ---------- Hero : entrée du titre, du texte puis des boutons ---------- */
42
+ :is(.ub-hero, .ub-hv-hero, .ub-gl-hero, .ub-cn-hero, .ub-al-hero, .ub-tc-hero, .ub-gr-hero) :is(h1, .ub-hero__sub, .ub-hv-sub, .ub-gl-sub, .ub-al-sub, .ub-tc-sub, .ub-gr-sub, .ub-cn-panel__text) {
43
+ animation: ub-hero-in 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) both;
44
+ }
45
+ :is(.ub-hero, .ub-hv-hero, .ub-gl-hero, .ub-cn-hero, .ub-al-hero, .ub-tc-hero, .ub-gr-hero) :is(p, .ub-countdown) { animation: ub-hero-in 0.8s 0.15s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
46
+ :is(.ub-hero, .ub-hv-hero, .ub-gl-hero, .ub-cn-hero, .ub-al-hero, .ub-tc-hero, .ub-gr-hero) :is(a, button) { animation: ub-hero-in 0.8s 0.3s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
47
+ @keyframes ub-hero-in { from { opacity: 0; transform: translate3d(0, 18px, 0); } }
48
+ /* Fond du hero : très lente respiration */
49
+ :is(.ub-hv-hero__bg, .ub-tc-hero__cover img, .ub-gr-hero__bg) { animation: ub-drift 22s ease-in-out infinite alternate; }
50
+ @keyframes ub-drift { to { transform: scale(1.05) translate3d(-1%, -1%, 0); } }
51
+
52
+ /* ---------- Cartes produits : survol ---------- */
53
+ .ub-card { transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.2, 1), box-shadow 0.3s, border-color 0.3s; }
54
+ .ub-card:hover { transform: translateY(-4px); }
55
+ .ub-card__media { overflow: hidden; }
56
+ .ub-card__media img { transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1); }
57
+ .ub-card:hover .ub-card__media img { transform: scale(1.05); }
58
+
59
+ /* Rangées horizontales : défilement doux (auto-slide) */
60
+ .ub-page :is(ul, ol) { scroll-behavior: smooth; }
61
+
62
+ /* ---------- Bouton surprise ---------- */
63
+ .ub-surprise {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ min-height: 48px;
68
+ padding: 0 20px;
69
+ border: 1px dashed currentColor;
70
+ border-radius: 999px;
71
+ background: transparent;
72
+ color: inherit;
73
+ font: 600 14px/18px var(--ub-font-body);
74
+ letter-spacing: 0.04em;
75
+ cursor: pointer;
76
+ transition: transform 0.2s, background 0.2s;
77
+ }
78
+ .ub-surprise:hover { transform: translateY(-2px) rotate(-1deg); background: rgb(255 255 255 / 0.08); }
79
+ .ub-surprise span { display: inline-block; animation: ub-twinkle 1.6s ease-in-out infinite; }
80
+ @keyframes ub-twinkle { 50% { transform: scale(1.35) rotate(20deg); opacity: 0.7; } }
81
+
82
+ /* ---------- Particules ---------- */
83
+ .ub-fx-canvas { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; pointer-events: none; }
84
+ .ub-fx-burst { position: fixed; inset: 0; z-index: 80; width: 100vw; height: 100vh; pointer-events: none; }
85
+
86
+ /* Chauve-souris (Halloween) : traverse l'écran en ondulant, ailes qui battent */
87
+ .ub-bat {
88
+ position: fixed;
89
+ top: var(--y);
90
+ left: 0;
91
+ z-index: 75;
92
+ width: var(--s);
93
+ height: calc(var(--s) / 2);
94
+ color: #0b0a0c;
95
+ filter: drop-shadow(0 0 6px rgb(255 122 26 / 0.35));
96
+ pointer-events: none;
97
+ animation: ub-bat-fly var(--d) linear var(--delay) both;
98
+ }
99
+ .ub-bat > span { display: block; width: 100%; height: 100%; animation: ub-bat-flap 0.22s ease-in-out infinite alternate; }
100
+ .ub-bat--rtl { animation-name: ub-bat-fly-rtl; }
101
+ @keyframes ub-bat-fly { from { transform: translate3d(-10vw, 0, 0); } 50% { transform: translate3d(50vw, var(--dy), 0); } to { transform: translate3d(110vw, calc(var(--dy) * -0.5), 0); } }
102
+ @keyframes ub-bat-fly-rtl { from { transform: translate3d(110vw, 0, 0); } 50% { transform: translate3d(50vw, var(--dy), 0); } to { transform: translate3d(-10vw, calc(var(--dy) * -0.5), 0); } }
103
+ @keyframes ub-bat-flap { to { transform: scaleY(0.45); } }
104
+ }
@@ -24,7 +24,7 @@
24
24
  --ink: #0b0b0f; --ink-muted: #5f616c; --border: #e4e4ea;
25
25
  --primary: #0b0b0f; --on-primary: #ffffff; --accent: #5b3df5; --on-accent: #ffffff;
26
26
  --sale: #e11d48; --star: #f59e0b; --success: #15803d;
27
- --hero-bg: #ececec; --hero-ink: #0b0b0f; --hero-muted: #4d4f59;
27
+ --hero-bg: #ffffff; --hero-ink: #0b0b0f; --hero-muted: #4d4f59;
28
28
  --band-bg: #0b0b0f; --band-ink: #ffffff; --bubble-bg: #0b0b0f; --bubble-ink: #ffffff;
29
29
  --topbar-bg: #0b0b0f; --topbar-ink: #ffffff; --focus: #5b3df5;
30
30
  --shadow-card: 0 14px 34px -22px rgb(11 11 15 / 0.35);
@@ -44,7 +44,7 @@
44
44
  [data-variant="cyber-monday-2"] .ub-nav__cta { background: var(--ink); color: #fff; }
45
45
  [data-theme="cyber-monday"]:not([data-variant]) .ub-footwrap { --foot-bg: #120b24; --foot-ink: var(--ink); --cta-bg: var(--primary); --cta-ink: #ffffff; }
46
46
  [data-theme="cyber-monday"]:not([data-variant]) .ub-footwrap .ub-footer { border: 1px solid var(--border); }
47
- [data-variant="cyber-monday-2"] .ub-footwrap { --foot-bg: #0b0b0f; --foot-ink: #f4f4f6; --cta-bg: #ececec; --cta-ink: #0b0b0f; }
47
+ [data-variant="cyber-monday-2"] .ub-footwrap { --foot-bg: #0b0b0f; --foot-ink: #f4f4f6; --cta-bg: #f1f1f3; --cta-ink: #0b0b0f; }
48
48
 
49
49
  /* Cartes produits du reste du site (boutique, recherche, produits liés) */
50
50
  [data-theme="cyber-monday"]:not([data-variant]) .ub-card { background: var(--tc-card); border: 1px solid var(--border); }
@@ -90,7 +90,23 @@
90
90
  transform: rotate(-24deg);
91
91
  pointer-events: none;
92
92
  }
93
- .ub-tc-hero__cover { display: none; }
93
+ /* Cyber Monday 1 : le visuel fait partie du fond (comme Black Friday) : posé à droite sur toute la hauteur,
94
+ fondu dans le noir à gauche, en haut et en bas. Pas de cadre, pas d'ombre. */
95
+ .ub-tc-hero[data-has-image] .ub-tc-hero__beam { display: none; }
96
+ .ub-tc-hero__cover { position: absolute; inset: 0 0 0 34%; z-index: -1; }
97
+ .ub-tc-hero__cover img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 62%; animation: ub-tc-fadein 1.2s ease both; }
98
+ .ub-tc-hero__cover::after {
99
+ content: "";
100
+ position: absolute;
101
+ inset: 0;
102
+ background:
103
+ linear-gradient(90deg, var(--hero-bg) 0%, rgb(5 3 11 / 0.55) 20%, transparent 45%),
104
+ linear-gradient(0deg, var(--hero-bg) 0%, transparent 18%),
105
+ linear-gradient(180deg, var(--hero-bg) 0%, transparent 12%);
106
+ }
107
+ .ub-tc-hero[data-has-image] .ub-tc-hero__side > img { display: none; }
108
+ .ub-tc-hero[data-has-image] .ub-tc-hero__side { aspect-ratio: auto; align-self: end; }
109
+ .ub-tc-hero[data-has-image] .ub-tc-badge { position: relative; left: auto; bottom: auto; justify-self: start; width: max-content; }
94
110
  .ub-tc-hero__in {
95
111
  display: grid;
96
112
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
@@ -335,12 +351,12 @@
335
351
  /* Le visuel couvre tout le hero ; un voile blanc à gauche garde le texte lisible */
336
352
  /* Fond du hero = gris béton clair du visuel : l'image posée à droite se fond dans toute la largeur */
337
353
  [data-variant="cyber-monday-2"] .ub-tc-hero__cover { display: block; position: absolute; inset: 0 0 0 30%; z-index: -1; }
338
- [data-variant="cyber-monday-2"] .ub-tc-hero__cover img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 78%; animation: ub-tc-zoom 1.4s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
354
+ [data-variant="cyber-monday-2"] .ub-tc-hero__cover img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; animation: ub-tc-zoom 1.4s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
339
355
  [data-variant="cyber-monday-2"] .ub-tc-hero__cover::after {
340
356
  content: "";
341
357
  position: absolute;
342
358
  inset: 0;
343
- background: linear-gradient(90deg, var(--hero-bg) 0%, rgb(236 236 236 / 0.7) 18%, transparent 42%);
359
+ background: linear-gradient(90deg, var(--hero-bg) 0%, rgb(255 255 255 / 0.7) 16%, transparent 38%);
344
360
  }
345
361
  @keyframes ub-tc-zoom { from { transform: scale(1.06); opacity: 0; } }
346
362
  [data-variant="cyber-monday-2"] .ub-tc-hero__in { grid-template-columns: minmax(0, 620px); min-height: clamp(580px, 48vw, 760px); }
@@ -397,6 +413,10 @@
397
413
  @media (max-width: 900px) {
398
414
  .ub-tc-hero__in { grid-template-columns: 1fr; gap: var(--space-8); min-height: 0; padding-top: var(--space-8); }
399
415
  .ub-tc-hero__side { justify-self: center; width: min(100%, 440px); margin-right: 0; }
416
+ .ub-tc-hero__cover { inset: 38% 0 0 0; }
417
+ .ub-tc-hero__cover img { object-position: 60% 60%; }
418
+ .ub-tc-hero__cover::after { background: linear-gradient(180deg, var(--hero-bg) 0%, rgb(5 3 11 / 0.4) 35%, transparent 60%), linear-gradient(0deg, var(--hero-bg) 0%, transparent 20%); }
419
+ .ub-tc-hero[data-has-image] .ub-tc-hero__side { min-height: 300px; justify-self: stretch; width: 100%; }
400
420
  .ub-tc-badge { left: 12px; bottom: 16px; padding: 10px 16px; }
401
421
  .ub-tc-badge b { font-size: 32px; }
402
422
  /* Cyber Monday 2 mobile : visuel en haut, texte dessous sur fond blanc */
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "promo": {
21
21
  "eyebrow": "Spell of the day",
22
- "title": "Buy 2, get the 3rd half off",
23
- "text": "Last-minute costume? Fast delivery before the 31st.",
22
+ "title": "Up to {discount} off before the cauldron runs dry",
23
+ "text": "Marked-down prices storewide until {endDate}, while stocks last.",
24
24
  "cta": "Cast the spell",
25
25
  "seal": "Until {endDate}"
26
26
  },
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "promo": {
21
21
  "eyebrow": "Sort du jour",
22
- "title": "Achetez-en 2, le 3e à -50 %",
23
- "text": "Déguisement de dernière minute ? Livraison rapide avant le 31.",
22
+ "title": "Jusqu'à -{discount} avant que le chaudron soit vide",
23
+ "text": "Prix barrés sur toute la boutique jusqu'au {endDate}, dans la limite des stocks.",
24
24
  "cta": "Lancer le sort",
25
25
  "seal": "Avant le {endDate}"
26
26
  },
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "id": "halloween-2",
3
- "name": "Halloween · Potion",
4
- "reference": "variante, mise en page black-friday",
3
+ "name": "Halloween · Grimoire",
4
+ "reference": "variante, accueil grimoire",
5
5
  "layout": "black-friday",
6
6
  "fonts": {
7
7
  "head": "oswald",
8
8
  "body": "inter"
9
9
  },
10
- "home": "cinema"
10
+ "home": "grimoire"
11
11
  }