@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.
- package/cli/dist/commands.js +98 -7
- package/cli/dist/create.js +56 -10
- package/cli/dist/generate.js +43 -35
- package/cli/dist/guide.js +2 -2
- package/cli/dist/index.js +20 -2
- package/cli/dist/project.js +42 -3
- package/package.json +1 -1
- package/templates/backend/src/admin/i18n/json/en.json +12 -2
- package/templates/backend/src/admin/i18n/json/fr.json +12 -2
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +27 -17
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +49 -6
- package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +6 -0
- package/templates/backend/src/api/middlewares.ts +2 -0
- package/templates/backend/src/lib/active.ts +41 -0
- package/templates/backend/src/lib/project.ts +26 -1
- package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
- package/templates/backend/src/scripts/seed.ts +8 -0
- package/templates/storefront/locales/en.json +9 -1
- package/templates/storefront/locales/fr.json +9 -1
- package/templates/storefront/src/app/(main)/page.tsx +32 -0
- package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
- package/templates/storefront/src/app/(main)/template.tsx +4 -0
- package/templates/storefront/src/app/layout.tsx +5 -1
- package/templates/storefront/src/lib/boutique/config.ts +71 -23
- package/templates/storefront/src/lib/boutique/schema.ts +5 -2
- package/templates/storefront/src/lib/config.ts +37 -8
- package/templates/storefront/src/lib/data/cart.ts +4 -1
- package/templates/storefront/src/lib/data/cookies.ts +14 -2
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
- package/templates/storefront/src/modules/ub/album.tsx +7 -3
- package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
- package/templates/storefront/src/modules/ub/fx-runtime.ts +272 -0
- package/templates/storefront/src/modules/ub/gala.tsx +2 -0
- package/templates/storefront/src/modules/ub/grimoire.tsx +194 -0
- package/templates/storefront/src/modules/ub/motion.tsx +20 -0
- package/templates/storefront/src/modules/ub/surprise-button.tsx +20 -0
- package/templates/storefront/src/proxy.ts +2 -1
- package/templates/storefront/src/styles/ub/grimoire.css +178 -0
- package/templates/storefront/src/styles/ub/motion.css +105 -0
- package/templates/storefront/src/styles/ub/tech.css +7 -7
- package/templates/storefront/themes/halloween-2/campaign.en.json +2 -2
- package/templates/storefront/themes/halloween-2/campaign.fr.json +2 -2
- 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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => [
|
|
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
|
-
|
|
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
|
|
144
|
-
*
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
34
|
-
return
|
|
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
|
-
|
|
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
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
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>
|