@salifbiaye/create-boutique 0.4.1 → 0.6.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 (45) hide show
  1. package/cli/dist/commands.js +98 -7
  2. package/cli/dist/create.js +56 -10
  3. package/cli/dist/generate.js +43 -35
  4. package/cli/dist/guide.js +2 -2
  5. package/cli/dist/index.js +20 -2
  6. package/cli/dist/project.js +42 -3
  7. package/package.json +1 -1
  8. package/templates/backend/src/admin/i18n/json/en.json +12 -2
  9. package/templates/backend/src/admin/i18n/json/fr.json +12 -2
  10. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +27 -17
  11. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +49 -6
  12. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  13. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  14. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +6 -0
  15. package/templates/backend/src/api/middlewares.ts +2 -0
  16. package/templates/backend/src/lib/active.ts +41 -0
  17. package/templates/backend/src/lib/project.ts +26 -1
  18. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  19. package/templates/backend/src/scripts/seed.ts +8 -0
  20. package/templates/storefront/locales/en.json +9 -1
  21. package/templates/storefront/locales/fr.json +9 -1
  22. package/templates/storefront/src/app/(main)/page.tsx +32 -0
  23. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  24. package/templates/storefront/src/app/(main)/template.tsx +4 -0
  25. package/templates/storefront/src/app/layout.tsx +5 -1
  26. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  27. package/templates/storefront/src/lib/boutique/schema.ts +5 -2
  28. package/templates/storefront/src/lib/config.ts +37 -8
  29. package/templates/storefront/src/lib/data/cart.ts +4 -1
  30. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  31. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  32. package/templates/storefront/src/modules/ub/album.tsx +7 -3
  33. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  34. package/templates/storefront/src/modules/ub/fx-runtime.ts +272 -0
  35. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  36. package/templates/storefront/src/modules/ub/grimoire.tsx +194 -0
  37. package/templates/storefront/src/modules/ub/motion.tsx +20 -0
  38. package/templates/storefront/src/modules/ub/surprise-button.tsx +20 -0
  39. package/templates/storefront/src/proxy.ts +2 -1
  40. package/templates/storefront/src/styles/ub/grimoire.css +178 -0
  41. package/templates/storefront/src/styles/ub/motion.css +105 -0
  42. package/templates/storefront/src/styles/ub/tech.css +7 -7
  43. package/templates/storefront/themes/halloween-2/campaign.en.json +2 -2
  44. package/templates/storefront/themes/halloween-2/campaign.fr.json +2 -2
  45. package/templates/storefront/themes/halloween-2/theme.json +3 -3
@@ -3,12 +3,14 @@ import "server-only"
3
3
  import fs from "node:fs"
4
4
  import path from "node:path"
5
5
  import { z } from "zod"
6
- import { projectSchema, themeSchema, type Theme } from "./schema"
6
+ import { projectSchema, themeSchema, type Project, type Theme } from "./schema"
7
7
 
8
8
  /**
9
- * Configuration de l'instance. La boutique est fixée par la variable
10
- * d'environnement BOUTIQUE et par rien d'autre : aucun paramètre d'URL,
11
- * cookie ou en-tête ne peut en changer.
9
+ * Configuration de l'instance. La boutique est fixée par la variable d'environnement BOUTIQUE :
10
+ * - mode multi : BOUTIQUE=<slug>, une instance par boutique ;
11
+ * - mode single : BOUTIQUE=auto, la boutique affichée est celle mise en ligne dans l'admin
12
+ * (fichier KEYS_DIR/_active écrit par le backend).
13
+ * Aucun paramètre d'URL, cookie ou en-tête ne peut changer la boutique affichée.
12
14
  */
13
15
  export type Boutique = {
14
16
  project: string
@@ -22,7 +24,7 @@ export type Boutique = {
22
24
  language: string
23
25
  /** ex. "en-US" : pour Intl et <html lang> */
24
26
  locale: string
25
- port: number
27
+ port?: number
26
28
  publishableKey: string
27
29
  merchantEmail?: string
28
30
  /** URL publique des autres boutiques du projet (lien « campagne suivante ») */
@@ -78,19 +80,14 @@ function loadTheme(id: string): Theme {
78
80
  return parseOrThrow(themeSchema, readJson(file), file)
79
81
  }
80
82
 
81
- function load(): Boutique {
82
- const slug = process.env.BOUTIQUE
83
- if (!slug) {
84
- throw new BoutiqueConfigError("variable d'environnement BOUTIQUE absente (ex. BOUTIQUE=yout-black-friday)")
85
- }
83
+ const projectFile = () => path.join(/*turbopackIgnore: true*/ CONFIG_DIR, "project.json")
86
84
 
87
- const projectFile = path.join(/*turbopackIgnore: true*/ CONFIG_DIR, "project.json")
88
- const project = parseOrThrow(projectSchema, readJson(projectFile), projectFile)
85
+ function load(slug: string, project: Project): Boutique {
89
86
 
90
87
  const entry = project.boutiques.find((b) => b.slug === slug)
91
88
  if (!entry) {
92
89
  throw new BoutiqueConfigError(
93
- `boutique « ${slug} » absente de ${projectFile}. Boutiques déclarées : ${project.boutiques.map((b) => b.slug).join(", ")}`
90
+ `boutique « ${slug} » absente de ${projectFile()}. Boutiques déclarées : ${project.boutiques.map((b) => b.slug).join(", ")}`
94
91
  )
95
92
  }
96
93
 
@@ -131,23 +128,74 @@ function load(): Boutique {
131
128
  port: entry.port,
132
129
  publishableKey,
133
130
  merchantEmail: defaults.merchantEmail,
131
+ // Mode single : toutes les boutiques partagent l'adresse du site unique
134
132
  siblings: Object.fromEntries(
135
- project.boutiques.map((b) => [b.slug, b.url ?? `http://localhost:${b.port}`])
133
+ project.boutiques.map((b) => [
134
+ b.slug,
135
+ project.mode === "single"
136
+ ? (project.site?.url ?? `http://localhost:${project.site?.port ?? 8000}`)
137
+ : (b.url ?? `http://localhost:${b.port}`),
138
+ ])
136
139
  ),
137
140
  }
138
141
  }
139
142
 
140
- let cached: Boutique | undefined
143
+ // Cache relu si project.json change (mode single : une boutique ajoutée par `apply` sans redémarrage)
144
+ let project: { mtime: number; data: Project } | undefined
145
+ const boutiques = new Map<string, Boutique>()
146
+
147
+ function readProject(): Project {
148
+ const file = projectFile()
149
+ const mtime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
150
+ if (!project || project.mtime !== mtime) {
151
+ project = { mtime, data: parseOrThrow(projectSchema, readJson(file), file) }
152
+ boutiques.clear()
153
+ }
154
+ return project.data
155
+ }
156
+
157
+ // Mode single : boutique active, relue au plus toutes les 2 s (le changement dans l'admin est quasi immédiat)
158
+ let active: { at: number; slug: string } | undefined
159
+ function activeSlug(p: Project): string {
160
+ if (active && Date.now() - active.at < 2000) return active.slug
161
+ let slug = ""
162
+ try {
163
+ slug = fs.readFileSync(path.join(/*turbopackIgnore: true*/ KEYS_DIR, "_active"), "utf8").trim()
164
+ } catch {
165
+ // pas encore écrit par le backend : première boutique
166
+ }
167
+ if (!p.boutiques.some((b) => b.slug === slug)) slug = p.boutiques[0].slug
168
+ active = { at: Date.now(), slug }
169
+ return slug
170
+ }
171
+
172
+ /** Mode du projet : multi (une instance par boutique) ou single (un site, la boutique active). */
173
+ export function isSingleSite(): boolean {
174
+ return process.env.BOUTIQUE === "auto"
175
+ }
141
176
 
142
177
  /**
143
- * Boutique de cette instance. Lue une fois (au démarrage via instrumentation.ts),
144
- * puis gardée en mémoire : modifier project.json demande un redémarrage (`apply`).
178
+ * Boutique affichée par cette instance (mode single : la boutique en ligne en ce moment),
179
+ * ou une boutique précise du projet (slug) : utilisé pour finir un panier commencé dans une autre boutique.
145
180
  */
146
- export function getBoutique(): Boutique {
147
- if (!cached) {
148
- cached = load()
149
- // Lu par lib/util/money.ts, partagé avec les composants client (qui lisent <html lang>).
150
- process.env.BOUTIQUE_LOCALE = cached.locale
181
+ export function getBoutique(slug?: string): Boutique {
182
+ const env = process.env.BOUTIQUE
183
+ if (!env) {
184
+ throw new BoutiqueConfigError("variable d'environnement BOUTIQUE absente (ex. BOUTIQUE=yout-black-friday, ou auto en mode single)")
185
+ }
186
+ const p = readProject()
187
+ const wanted = slug ?? (env === "auto" ? activeSlug(p) : env)
188
+ if (!p.boutiques.some((b) => b.slug === wanted)) {
189
+ throw new BoutiqueConfigError(
190
+ `boutique « ${wanted} » absente de ${projectFile()}. Boutiques déclarées : ${p.boutiques.map((b) => b.slug).join(", ")}`
191
+ )
192
+ }
193
+ let b = boutiques.get(wanted)
194
+ if (!b) {
195
+ b = load(wanted, p)
196
+ boutiques.set(wanted, b)
197
+ // Lu par lib/util/money.ts, partagé avec les composants client (qui lisent <html lang>) ; même langue pour tout le projet.
198
+ process.env.BOUTIQUE_LOCALE = b.locale
151
199
  }
152
- return cached
200
+ return b
153
201
  }
@@ -33,6 +33,9 @@ const overridable = {
33
33
  export const projectSchema = z.object({
34
34
  project: z.string().regex(SLUG),
35
35
  templateVersion: z.string().optional(),
36
+ /** single : un seul site (site.port, site.url) qui affiche la boutique active (choisie dans l'admin) */
37
+ mode: z.enum(["multi", "single"]).default("multi"),
38
+ site: z.object({ port: z.number().int().min(1).max(65535), url: z.url().optional() }).optional(),
36
39
  defaults: z
37
40
  .object({
38
41
  ...overridable,
@@ -48,7 +51,7 @@ export const projectSchema = z.object({
48
51
  .object({
49
52
  slug: z.string().regex(SLUG, "slug : minuscules, chiffres et tirets"),
50
53
  theme: z.string().regex(SLUG),
51
- port: z.number().int().min(1).max(65535),
54
+ port: z.number().int().min(1).max(65535).optional(),
52
55
  url: z.url().optional(),
53
56
  storeName: overridable.storeName.optional(),
54
57
  currency: overridable.currency.optional(),
@@ -71,7 +74,7 @@ export const themeSchema = z.object({
71
74
  /** Variante : reprend la mise en page (règles CSS) de ce thème, avec la palette de styles/ub/variants.css. */
72
75
  layout: z.string().regex(SLUG).optional(),
73
76
  /** 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(),
77
+ home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire"]).optional(),
75
78
  fonts: z.object({
76
79
  head: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
77
80
  body: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
@@ -2,21 +2,45 @@ import "server-only"
2
2
 
3
3
  import { getBoutique } from "@lib/boutique/config"
4
4
  import Medusa, { FetchArgs, FetchInput } from "@medusajs/js-sdk"
5
+ import { cookies } from "next/headers"
5
6
 
6
7
  // URL interne du backend (réseau Docker) : jamais exposée au navigateur.
7
8
  const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
8
9
 
9
- let client: Medusa | undefined
10
+ /** Cookie : boutique où le panier a été créé (mode single, cf. setCartId). */
11
+ export const CART_BOUTIQUE_COOKIE = "_ub_cart_boutique"
12
+
13
+ // Requêtes liées au panier : elles utilisent la clé de la boutique du panier, pas celle affichée.
14
+ const CART_PATHS = /^\/?store\/(carts|payment-collections|shipping-options)\b/
10
15
 
11
16
  /**
12
- * Client Medusa de la boutique : la publishable key vient de KEYS_DIR/<slug>,
17
+ * Mode single : un client qui a commencé son panier avant un changement de boutique le termine
18
+ * dans sa boutique d'origine (même canal de vente, mêmes prix et même livraison). Mode multi : sans effet.
19
+ */
20
+ async function cartKey(input: FetchInput): Promise<string | undefined> {
21
+ if (process.env.BOUTIQUE !== "auto" || !CART_PATHS.test(String(input))) return undefined
22
+ try {
23
+ const slug = (await cookies()).get(CART_BOUTIQUE_COOKIE)?.value
24
+ if (!slug || slug === getBoutique().slug) return undefined
25
+ return getBoutique(slug).publishableKey
26
+ } catch {
27
+ // Boutique retirée du projet, ou appel hors requête : clé de la boutique affichée
28
+ return undefined
29
+ }
30
+ }
31
+
32
+ const clients = new Map<string, Medusa>()
33
+
34
+ /**
35
+ * Client Medusa de la boutique affichée : la publishable key vient de KEYS_DIR/<slug>,
13
36
  * lue à l'exécution (pas de variable NEXT_PUBLIC_* figée au build).
14
- * Créé au premier usage pour que `next build` n'ait besoin d'aucune boutique.
37
+ * Un client par boutique (mode single : la boutique active change sans redémarrage).
15
38
  */
16
39
  function getClient(): Medusa {
17
- if (client) return client
18
-
19
40
  const boutique = getBoutique()
41
+ const existing = clients.get(boutique.slug)
42
+ if (existing) return existing
43
+
20
44
  const medusa = new Medusa({
21
45
  baseUrl: MEDUSA_BACKEND_URL,
22
46
  debug: process.env.NODE_ENV === "development",
@@ -26,12 +50,17 @@ function getClient(): Medusa {
26
50
  // Langue fixée par l'instance : les traductions Medusa suivent la langue du projet.
27
51
  const originalFetch = medusa.client.fetch.bind(medusa.client)
28
52
  medusa.client.fetch = async <T>(input: FetchInput, init?: FetchArgs): Promise<T> => {
29
- const headers = { "x-medusa-locale": boutique.locale, ...(init?.headers ?? {}) }
53
+ const key = await cartKey(input)
54
+ const headers = {
55
+ "x-medusa-locale": boutique.locale,
56
+ ...(key ? { "x-publishable-api-key": key } : {}),
57
+ ...(init?.headers ?? {}),
58
+ }
30
59
  return originalFetch(input, { ...init, headers })
31
60
  }
32
61
 
33
- client = medusa
34
- return client
62
+ clients.set(boutique.slug, medusa)
63
+ return medusa
35
64
  }
36
65
 
37
66
  export const sdk = new Proxy({} as Medusa, {
@@ -9,6 +9,7 @@ import {
9
9
  getAuthHeaders,
10
10
  getCacheOptions,
11
11
  getCacheTag,
12
+ cartFromOtherBoutique,
12
13
  getCartId,
13
14
  removeCartId,
14
15
  setCartId,
@@ -54,7 +55,9 @@ export async function retrieveCart(cartId?: string, fields?: string) {
54
55
  export async function getOrSetCart() {
55
56
  const region = await getRegion()
56
57
 
57
- let cart = await retrieveCart(undefined, "id,region_id")
58
+ // Mode single : un nouvel article ajouté après un changement de boutique démarre un panier dans la boutique en ligne
59
+ // (l'ancien panier appartient à l'autre canal de vente et ne peut pas recevoir ses produits).
60
+ let cart = (await cartFromOtherBoutique()) ? null : await retrieveCart(undefined, "id,region_id")
58
61
 
59
62
  const headers = {
60
63
  ...(await getAuthHeaders()),
@@ -1,5 +1,6 @@
1
1
  import "server-only"
2
- import { getBoutique } from "@lib/boutique/config"
2
+ import { getBoutique, isSingleSite } from "@lib/boutique/config"
3
+ import { CART_BOUTIQUE_COOKIE } from "@lib/config"
3
4
  import { cookies as nextCookies } from "next/headers"
4
5
 
5
6
  /**
@@ -7,9 +8,10 @@ import { cookies as nextCookies } from "next/headers"
7
8
  * toutes les boutiques du projet les partagent. Chaque nom est donc suffixé
8
9
  * par le slug, pour qu'un panier ou une session d'une boutique n'apparaisse
9
10
  * jamais dans une autre.
11
+ * Mode single : un seul site, noms fixes : le client reste connecté et garde son panier quand la boutique change.
10
12
  */
11
13
  export const cookieName = (base: "jwt" | "cart_id" | "cache_id" | "onboarding") =>
12
- `_medusa_${base}_${getBoutique().slug}`
14
+ `_medusa_${base}_${isSingleSite() ? "site" : getBoutique().slug}`
13
15
 
14
16
  export const getAuthHeaders = async (): Promise<
15
17
  { authorization: string } | {}
@@ -93,6 +95,15 @@ export const setCartId = async (cartId: string) => {
93
95
  ...secureCookie,
94
96
  maxAge: 60 * 60 * 24 * 7,
95
97
  })
98
+ // Boutique du panier (mode single) : le client finit son panier dans cette boutique même si le site change
99
+ cookies.set(CART_BOUTIQUE_COOKIE, getBoutique().slug, { ...secureCookie, maxAge: 60 * 60 * 24 * 7 })
100
+ }
101
+
102
+ /** Mode single : le panier en cours a été commencé dans une autre boutique que celle en ligne maintenant. */
103
+ export const cartFromOtherBoutique = async () => {
104
+ if (!isSingleSite()) return false
105
+ const slug = (await nextCookies()).get(CART_BOUTIQUE_COOKIE)?.value
106
+ return !!slug && slug !== getBoutique().slug
96
107
  }
97
108
 
98
109
  export const removeCartId = async () => {
@@ -100,4 +111,5 @@ export const removeCartId = async () => {
100
111
  cookies.set(cookieName("cart_id"), "", {
101
112
  maxAge: -1,
102
113
  })
114
+ cookies.set(CART_BOUTIQUE_COOKIE, "", { maxAge: -1 })
103
115
  }
@@ -1,3 +1,4 @@
1
+ import { useLocale } from "@lib/boutique/i18n-client"
1
2
  import { HttpTypes } from "@medusajs/types"
2
3
  import { Container } from "@medusajs/ui"
3
4
  import Checkbox from "@modules/common/components/checkbox"
@@ -19,6 +20,10 @@ const ShippingAddress = ({
19
20
  checked: boolean
20
21
  onChange: () => void
21
22
  }) => {
23
+ // Pays de la boutique (fin de la locale, ex. fr-FR → fr) proposé par défaut s'il est livré
24
+ const locale = useLocale()
25
+ const homeCountry = locale.split("-")[1]?.toLowerCase() ?? ""
26
+ const defaultCountry = cart?.region?.countries?.some((c) => c.iso_2 === homeCountry) ? homeCountry : ""
22
27
  const [formData, setFormData] = useState<Record<string, any>>({
23
28
  "shipping_address.first_name": cart?.shipping_address?.first_name || "",
24
29
  "shipping_address.last_name": cart?.shipping_address?.last_name || "",
@@ -26,7 +31,7 @@ const ShippingAddress = ({
26
31
  "shipping_address.company": cart?.shipping_address?.company || "",
27
32
  "shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
28
33
  "shipping_address.city": cart?.shipping_address?.city || "",
29
- "shipping_address.country_code": cart?.shipping_address?.country_code || "",
34
+ "shipping_address.country_code": cart?.shipping_address?.country_code || defaultCountry,
30
35
  "shipping_address.province": cart?.shipping_address?.province || "",
31
36
  "shipping_address.phone": cart?.shipping_address?.phone || "",
32
37
  email: cart?.email || "",
@@ -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>