@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
@@ -16,7 +16,7 @@
16
16
  "saved": "Campagne enregistrée",
17
17
  "reset": "Réinitialiser aux textes du thème",
18
18
  "resetDone": "Textes du thème rétablis",
19
- "emptyHint": "Laissez vide pour garder le texte du thème.",
19
+ "emptyHint": "Les champs affichent les textes actuels (ceux du thème par défaut) : modifiez ce que vous voulez. {discount}, {endDate} et {freeShipping} sont remplacés automatiquement par la remise, la date de fin et le seuil de livraison.",
20
20
  "sections": {
21
21
  "dates": "Dates",
22
22
  "hero": "Bannière principale",
@@ -66,6 +66,16 @@
66
66
  "save": "Enregistrer l'image",
67
67
  "saved": "Image enregistrée"
68
68
  },
69
- "shareHint": "Ce qui s'affiche quand on partage le lien de la boutique (WhatsApp, Facebook, LinkedIn…). Vide = nom de la boutique, sous-titre et visuel principal."
69
+ "shareHint": "Ce qui s'affiche quand on partage le lien de la boutique (WhatsApp, Facebook, LinkedIn…). Vide = nom de la boutique, sous-titre et visuel principal.",
70
+ "online": {
71
+ "column": "Site",
72
+ "live": "En ligne",
73
+ "action": "Mettre en ligne",
74
+ "hint": "Un seul site : la boutique « En ligne » est celle que voient les clients. Les commandes et paniers en cours des autres boutiques ne sont pas perdus.",
75
+ "confirmTitle": "Changer la boutique en ligne ?",
76
+ "confirmText": "Le site va passer de {{from}} à {{to}}. Les clients qui paient en ce moment peuvent finir leur commande.",
77
+ "confirm": "Mettre en ligne",
78
+ "done": "Boutique en ligne : le site a changé."
79
+ }
70
80
  }
71
81
  }
@@ -10,6 +10,7 @@ type Texts = {
10
10
  topbar?: string[]
11
11
  hero?: Partial<Record<"eyebrow" | "title" | "highlight" | "sub" | "cta" | "cta2", string>> & { bubble?: string[]; image?: string }
12
12
  promo?: Partial<Record<"eyebrow" | "title" | "text" | "cta" | "seal" | "image", string>>
13
+ seo?: Partial<Record<"title" | "description" | "image", string>>
13
14
  }
14
15
 
15
16
  export type Campaign = Texts & {
@@ -44,14 +45,22 @@ const clean = <T extends Record<string, unknown>>(o: T): Partial<T> | undefined
44
45
  return Object.keys(out).length ? out : undefined
45
46
  }
46
47
 
47
- /** Panneau d'édition de la campagne d'une boutique. */
48
+ // Texte identique à celui du thème = non enregistré : la boutique suit le thème (et ses mises à jour).
49
+ const same = (a: string, b?: string) => a.trim() === (b ?? "").trim()
50
+ const ifChanged = <T extends Record<string, string>>(o: T, def: Partial<Record<keyof T, string>> = {}) =>
51
+ Object.fromEntries(Object.entries(o).map(([k, v]) => [k, same(v, def[k]) ? "" : v])) as T
52
+ const listChanged = (list: string[], def: string[] = []) => (list.every((x, i) => same(x, def[i])) ? [] : list)
53
+
54
+ /** Panneau d'édition de la campagne d'une boutique. Champs pré-remplis avec les textes du thème (defaults). */
48
55
  export function CampaignDrawer({
49
56
  boutique,
57
+ defaults,
50
58
  others,
51
59
  queryKey,
52
60
  onClose,
53
61
  }: {
54
62
  boutique: Boutique
63
+ defaults?: Texts | null
55
64
  others: string[]
56
65
  queryKey: string[]
57
66
  onClose: () => void
@@ -59,27 +68,28 @@ export function CampaignDrawer({
59
68
  const { t } = useTranslation()
60
69
  const queryClient = useQueryClient()
61
70
  const c = boutique.campaign
71
+ const def: Texts = defaults ?? {}
62
72
 
63
73
  const [startsAt, setStartsAt] = useState(toLocal(c?.startsAt))
64
74
  const [endsAt, setEndsAt] = useState(toLocal(c?.endsAt))
65
75
  const [nextCampaign, setNextCampaign] = useState(c?.nextCampaign ?? "")
66
76
  const [vars, setVars] = useState({ discount: c?.vars?.discount ?? "", freeShipping: c?.vars?.freeShipping ?? "" })
67
- const [topbar, setTopbar] = useState<string[]>([0, 1, 2].map((i) => c?.topbar?.[i] ?? ""))
77
+ const [topbar, setTopbar] = useState<string[]>([0, 1, 2].map((i) => (c?.topbar?.length ? c.topbar[i] : def.topbar?.[i]) ?? ""))
68
78
  const [hero, setHero] = useState({
69
- eyebrow: c?.hero?.eyebrow ?? "",
70
- title: c?.hero?.title ?? "",
71
- highlight: c?.hero?.highlight ?? "",
72
- sub: c?.hero?.sub ?? "",
73
- cta: c?.hero?.cta ?? "",
74
- cta2: c?.hero?.cta2 ?? "",
79
+ eyebrow: c?.hero?.eyebrow ?? def.hero?.eyebrow ?? "",
80
+ title: c?.hero?.title ?? def.hero?.title ?? "",
81
+ highlight: c?.hero?.highlight ?? def.hero?.highlight ?? "",
82
+ sub: c?.hero?.sub ?? def.hero?.sub ?? "",
83
+ cta: c?.hero?.cta ?? def.hero?.cta ?? "",
84
+ cta2: c?.hero?.cta2 ?? def.hero?.cta2 ?? "",
75
85
  })
76
- const [bubble, setBubble] = useState<string[]>([0, 1, 2].map((i) => c?.hero?.bubble?.[i] ?? ""))
86
+ const [bubble, setBubble] = useState<string[]>([0, 1, 2].map((i) => (c?.hero?.bubble?.length ? c.hero.bubble[i] : def.hero?.bubble?.[i]) ?? ""))
77
87
  const [promo, setPromo] = useState({
78
- eyebrow: c?.promo?.eyebrow ?? "",
79
- title: c?.promo?.title ?? "",
80
- text: c?.promo?.text ?? "",
81
- cta: c?.promo?.cta ?? "",
82
- seal: c?.promo?.seal ?? "",
88
+ eyebrow: c?.promo?.eyebrow ?? def.promo?.eyebrow ?? "",
89
+ title: c?.promo?.title ?? def.promo?.title ?? "",
90
+ text: c?.promo?.text ?? def.promo?.text ?? "",
91
+ cta: c?.promo?.cta ?? def.promo?.cta ?? "",
92
+ seal: c?.promo?.seal ?? def.promo?.seal ?? "",
83
93
  })
84
94
  const [heroImage, setHeroImage] = useState(c?.hero?.image ?? "")
85
95
  const [promoImage, setPromoImage] = useState(c?.promo?.image ?? "")
@@ -124,9 +134,9 @@ export function CampaignDrawer({
124
134
  nextCampaign: nextCampaign || null,
125
135
  vars: clean(vars) ?? null,
126
136
  ...media(),
127
- topbar: topbar.some((m) => m.trim()) ? topbar.filter((m) => m.trim()) : undefined,
128
- hero: clean({ ...hero, bubble: bubble.some((b) => b.trim()) ? bubble : undefined }),
129
- promo: clean(promo),
137
+ topbar: listChanged(topbar, def.topbar).some((m) => m.trim()) ? topbar.filter((m) => m.trim()) : undefined,
138
+ hero: clean({ ...ifChanged(hero, def.hero), bubble: listChanged(bubble, def.hero?.bubble).some((b) => b.trim()) ? bubble : undefined }),
139
+ promo: clean(ifChanged(promo, def.promo)),
130
140
  seo: clean({ ...seo, image: seoImage || undefined }),
131
141
  })
132
142
  }
@@ -1,7 +1,7 @@
1
1
  import { defineRouteConfig } from "@medusajs/admin-sdk"
2
2
  import { PencilSquare, Sparkles, Spinner } from "@medusajs/icons"
3
- import { Badge, Button, Container, Heading, Table, Text } from "@medusajs/ui"
4
- import { useQuery } from "@tanstack/react-query"
3
+ import { Badge, Button, Container, Heading, Table, Text, toast, usePrompt } from "@medusajs/ui"
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
5
5
  import { useState } from "react"
6
6
  import { useTranslation } from "react-i18next"
7
7
  import { sdk } from "../../lib/client"
@@ -23,11 +23,11 @@ const COLORS = { upcoming: "blue", live: "green", ended: "grey" } as const
23
23
  * Ouvre le panneau avec la campagne relue sur le serveur (jamais la copie du tableau) : sinon, rouvrir juste après
24
24
  * un enregistrement pouvait repartir d'anciennes valeurs et réenregistrer l'ancienne image.
25
25
  */
26
- function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "boutique"> & { boutique: Boutique }) {
26
+ function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "boutique" | "defaults"> & { boutique: Boutique }) {
27
27
  const { boutique, ...rest } = props
28
28
  const { data, isFetching } = useQuery({
29
29
  queryKey: [...QUERY_KEY, "one", boutique.sales_channel_id],
30
- queryFn: () => sdk.client.fetch<{ campaign: Boutique["campaign"] }>(`/admin/boutique-campaigns/${boutique.sales_channel_id}`),
30
+ queryFn: () => sdk.client.fetch<{ campaign: Boutique["campaign"]; defaults?: Parameters<typeof CampaignDrawer>[0]["defaults"] }>(`/admin/boutique-campaigns/${boutique.sales_channel_id}`),
31
31
  staleTime: 0,
32
32
  gcTime: 0,
33
33
  })
@@ -38,18 +38,42 @@ function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "
38
38
  </div>
39
39
  )
40
40
  }
41
- return <CampaignDrawer {...rest} boutique={{ ...boutique, campaign: data.campaign ?? null }} />
41
+ return <CampaignDrawer {...rest} boutique={{ ...boutique, campaign: data.campaign ?? null }} defaults={data.defaults ?? null} />
42
42
  }
43
43
 
44
44
  /** Page « Campagnes » : une ligne par boutique du projet, édition dans un panneau latéral. */
45
45
  const CampaignsPage = () => {
46
46
  const { t, i18n } = useTranslation()
47
47
  const [editing, setEditing] = useState<Boutique | null>(null)
48
+ const queryClient = useQueryClient()
49
+ const prompt = usePrompt()
48
50
 
49
51
  const { data, isLoading } = useQuery({
50
52
  queryKey: QUERY_KEY,
51
- queryFn: () => sdk.client.fetch<{ boutiques: Boutique[] }>("/admin/boutique-campaigns"),
53
+ queryFn: () =>
54
+ sdk.client.fetch<{ mode?: "multi" | "single"; active?: string | null; boutiques: Boutique[] }>("/admin/boutique-campaigns"),
52
55
  })
56
+ const single = data?.mode === "single"
57
+
58
+ // Mode single : met une boutique en ligne (la précédente est retirée ; il y en a toujours exactement une)
59
+ const activate = useMutation({
60
+ mutationFn: (slug: string) => sdk.client.fetch("/admin/active-boutique", { method: "POST", body: { slug } }),
61
+ onSuccess: () => {
62
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY })
63
+ toast.success(t("campaigns.online.done"))
64
+ },
65
+ onError: (e: Error) => toast.error(e.message),
66
+ })
67
+ const goLive = async (slug: string) => {
68
+ const ok = await prompt({
69
+ title: t("campaigns.online.confirmTitle"),
70
+ description: t("campaigns.online.confirmText", { from: data?.active ?? "", to: slug }),
71
+ confirmText: t("campaigns.online.confirm"),
72
+ cancelText: t("campaigns.cancel"),
73
+ variant: "confirmation",
74
+ })
75
+ if (ok) activate.mutate(slug)
76
+ }
53
77
 
54
78
  const date = (iso: string) => new Date(iso).toLocaleDateString(i18n.language, { dateStyle: "medium" })
55
79
 
@@ -60,6 +84,11 @@ const CampaignsPage = () => {
60
84
  <Text size="small" className="text-ui-fg-subtle mt-1">
61
85
  {t("campaigns.description")}
62
86
  </Text>
87
+ {single && (
88
+ <Text size="small" className="text-ui-fg-subtle mt-1">
89
+ {t("campaigns.online.hint")}
90
+ </Text>
91
+ )}
63
92
  </div>
64
93
 
65
94
  {isLoading ? (
@@ -73,6 +102,7 @@ const CampaignsPage = () => {
73
102
  <Table.HeaderCell>{t("campaigns.store")}</Table.HeaderCell>
74
103
  <Table.HeaderCell>{t("campaigns.dates")}</Table.HeaderCell>
75
104
  <Table.HeaderCell>{t("campaigns.status")}</Table.HeaderCell>
105
+ {single && <Table.HeaderCell>{t("campaigns.online.column")}</Table.HeaderCell>}
76
106
  <Table.HeaderCell />
77
107
  </Table.Row>
78
108
  </Table.Header>
@@ -101,6 +131,19 @@ const CampaignsPage = () => {
101
131
  </Badge>
102
132
  )}
103
133
  </Table.Cell>
134
+ {single && (
135
+ <Table.Cell>
136
+ {data?.active === b.slug ? (
137
+ <Badge size="2xsmall" color="green">
138
+ {t("campaigns.online.live")}
139
+ </Badge>
140
+ ) : (
141
+ <Button size="small" variant="secondary" onClick={() => goLive(b.slug)} disabled={activate.isPending}>
142
+ {t("campaigns.online.action")}
143
+ </Button>
144
+ )}
145
+ </Table.Cell>
146
+ )}
104
147
  <Table.Cell className="text-right">
105
148
  <Button size="small" variant="secondary" onClick={() => setEditing(b)}>
106
149
  <PencilSquare />
@@ -0,0 +1,25 @@
1
+ import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { z } from "@medusajs/framework/zod"
3
+ import { MedusaError } from "@medusajs/framework/utils"
4
+ import { getActive, setActive } from "../../../lib/active"
5
+ import { readProject } from "../../../lib/project"
6
+
7
+ export const ActiveBoutique = z.object({ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) })
8
+
9
+ /** GET /admin/active-boutique : mode du projet et boutique en ligne (mode single). */
10
+ export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
11
+ const { mode } = readProject()
12
+ res.json({ mode, active: mode === "single" ? await getActive(req.scope) : null })
13
+ }
14
+
15
+ /** POST /admin/active-boutique { slug } : met cette boutique en ligne (la précédente est retirée). */
16
+ export async function POST(req: AuthenticatedMedusaRequest<z.infer<typeof ActiveBoutique>>, res: MedusaResponse) {
17
+ const project = readProject()
18
+ if (project.mode !== "single") {
19
+ throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Mode multi : toutes les boutiques sont déjà en ligne")
20
+ }
21
+ if (!project.boutiques.some((b) => b.slug === req.validatedBody.slug)) {
22
+ throw new MedusaError(MedusaError.Types.NOT_FOUND, "Unknown store")
23
+ }
24
+ res.json({ mode: project.mode, active: await setActive(req.scope, req.validatedBody.slug) })
25
+ }
@@ -1,6 +1,7 @@
1
1
  import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
2
  import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
3
3
  import { toCampaignJson, type CampaignInput } from "../../../../modules/campaign/validators"
4
+ import { themeTexts } from "../../../../lib/project"
4
5
  import { upsertCampaignWorkflow } from "../../../../workflows/upsert-campaign"
5
6
 
6
7
  async function channelOrThrow(req: AuthenticatedMedusaRequest<unknown>) {
@@ -17,7 +18,12 @@ async function channelOrThrow(req: AuthenticatedMedusaRequest<unknown>) {
17
18
  /** GET /admin/boutique-campaigns/:salesChannelId */
18
19
  export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
19
20
  const channel = await channelOrThrow(req)
20
- res.json({ slug: channel.name, campaign: channel.boutique_campaign ? toCampaignJson(channel.boutique_campaign) : null })
21
+ res.json({
22
+ slug: channel.name,
23
+ campaign: channel.boutique_campaign ? toCampaignJson(channel.boutique_campaign) : null,
24
+ // Textes du thème : pré-remplissent les champs de l'admin
25
+ defaults: themeTexts(channel.name) ?? null,
26
+ })
21
27
  }
22
28
 
23
29
  /** POST /admin/boutique-campaigns/:salesChannelId : enregistre la campagne (corps validé par zod, cf. middlewares). */
@@ -1,5 +1,7 @@
1
1
  import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
2
  import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
3
+ import { getActive } from "../../../lib/active"
4
+ import { readProject } from "../../../lib/project"
3
5
  import { toCampaignJson } from "../../../modules/campaign/validators"
4
6
 
5
7
  /** GET /admin/boutique-campaigns : les boutiques du projet (sales channels) et leur campagne. */
@@ -11,7 +13,11 @@ export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse)
11
13
  fields: ["id", "name", "description", "boutique_campaign.*"],
12
14
  })
13
15
 
16
+ const { mode } = readProject()
14
17
  res.json({
18
+ // Mode single : une seule boutique en ligne, choisie ici (POST /admin/active-boutique)
19
+ mode,
20
+ active: mode === "single" ? await getActive(req.scope) : null,
15
21
  boutiques: channels
16
22
  .filter((c) => c.name !== "Default Sales Channel")
17
23
  .map((c) => ({
@@ -1,6 +1,7 @@
1
1
  import { defineMiddlewares, validateAndTransformBody } from "@medusajs/framework/http"
2
2
  import { CampaignInput } from "../modules/campaign/validators"
3
3
  import { rateLimit } from "./rate-limit"
4
+ import { ActiveBoutique } from "./admin/active-boutique/route"
4
5
  import { OrderLookup } from "./store/order-lookup/route"
5
6
 
6
7
  // Anti force brute (par IP) : connexion admin, mot de passe oublié, suivi de commande.
@@ -16,6 +17,7 @@ export default defineMiddlewares({
16
17
  method: "POST",
17
18
  middlewares: [validateAndTransformBody(CampaignInput)],
18
19
  },
20
+ { matcher: "/admin/active-boutique", method: "POST", middlewares: [validateAndTransformBody(ActiveBoutique)] },
19
21
  { matcher: "/auth/user/emailpass", method: "POST", middlewares: [loginLimit] },
20
22
  { matcher: "/auth/user/emailpass/reset-password", method: "POST", middlewares: [resetLimit] },
21
23
  { matcher: "/auth/customer/emailpass", method: "POST", middlewares: [loginLimit] },
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { MedusaContainer } from "@medusajs/framework/types"
4
+ import { Modules } from "@medusajs/framework/utils"
5
+ import { updateStoresWorkflow } from "@medusajs/medusa/core-flows"
6
+ import { KEYS_DIR, readProject } from "./project"
7
+
8
+ /**
9
+ * Mode single : une seule boutique en ligne à la fois.
10
+ * - Source de vérité : metadata.active_boutique du store Medusa (base de données).
11
+ * - Copie dans KEYS_DIR/_active : le site la lit à chaque requête (volume partagé, pas d'appel réseau).
12
+ * Il y a toujours exactement une boutique active : activer une boutique remplace la précédente,
13
+ * et une valeur absente ou inconnue retombe sur la première boutique du projet.
14
+ */
15
+ const ACTIVE_FILE = path.join(KEYS_DIR, "_active")
16
+
17
+ export async function getActive(container: MedusaContainer): Promise<string> {
18
+ const project = readProject()
19
+ const slugs = project.boutiques.map((b) => b.slug)
20
+ const [store] = await container.resolve(Modules.STORE).listStores({}, { select: ["id", "metadata"] })
21
+ const saved = store?.metadata?.active_boutique
22
+ return typeof saved === "string" && slugs.includes(saved) ? saved : slugs[0]
23
+ }
24
+
25
+ export async function setActive(container: MedusaContainer, slug: string): Promise<string> {
26
+ const project = readProject()
27
+ if (!project.boutiques.some((b) => b.slug === slug)) throw new Error(`boutique inconnue : ${slug}`)
28
+ const [store] = await container.resolve(Modules.STORE).listStores({}, { select: ["id", "metadata"] })
29
+ await updateStoresWorkflow(container).run({
30
+ input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), active_boutique: slug } } },
31
+ })
32
+ writeActiveFile(slug)
33
+ return slug
34
+ }
35
+
36
+ export function writeActiveFile(slug: string) {
37
+ fs.mkdirSync(KEYS_DIR, { recursive: true })
38
+ // Écriture atomique : le site ne lit jamais un fichier à moitié écrit
39
+ fs.writeFileSync(`${ACTIVE_FILE}.tmp`, slug)
40
+ fs.renameSync(`${ACTIVE_FILE}.tmp`, ACTIVE_FILE)
41
+ }
@@ -16,6 +16,7 @@ const shipsTo = z.union([z.literal("all"), z.array(code2).min(1)])
16
16
 
17
17
  const projectSchema = z.object({
18
18
  project: z.string(),
19
+ mode: z.enum(["multi", "single"]).default("multi"),
19
20
  defaults: z.looseObject({
20
21
  storeName: z.string(),
21
22
  currency: code3,
@@ -29,7 +30,7 @@ const projectSchema = z.object({
29
30
  z.looseObject({
30
31
  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
31
32
  theme: z.string(),
32
- port: z.number(),
33
+ port: z.number().optional(),
33
34
  storeName: z.string().optional(),
34
35
  currency: code3.optional(),
35
36
  country: code2.optional(),
@@ -50,6 +51,8 @@ export type Boutique = {
50
51
 
51
52
  export type Project = {
52
53
  project: string
54
+ /** single : un seul site public, qui affiche la boutique active (lib/active.ts) */
55
+ mode: "multi" | "single"
53
56
  defaultCurrency: string
54
57
  language: string
55
58
  merchantEmail?: string
@@ -66,6 +69,7 @@ export function readProject(): Project {
66
69
  const { defaults, boutiques } = res.data
67
70
  return {
68
71
  project: res.data.project,
72
+ mode: res.data.mode,
69
73
  defaultCurrency: defaults.currency,
70
74
  language: defaults.language,
71
75
  merchantEmail: defaults.merchantEmail,
@@ -144,3 +148,24 @@ export const SHIPPING_RATES: Record<string, [number, number]> = {
144
148
  xaf: [2000, 5000],
145
149
  }
146
150
  export const shippingRates = (currency: string): [number, number] => SHIPPING_RATES[currency] ?? [0, 0]
151
+
152
+ export const THEMES_DIR = process.env.THEMES_DIR || "/app/themes"
153
+
154
+ /**
155
+ * Textes par défaut du thème d'une boutique (themes/<thème>/campaign.<langue>.json, anglais à défaut),
156
+ * affichés pré-remplis dans l'admin → Campagnes. Variables {discount}, {endDate}, {freeShipping} laissées telles quelles.
157
+ */
158
+ export function themeTexts(slug: string): Record<string, unknown> | undefined {
159
+ try {
160
+ const project = readProject()
161
+ const theme = project.boutiques.find((b) => b.slug === slug)?.theme
162
+ if (!theme) return undefined
163
+ for (const lang of [project.language, "en"]) {
164
+ const file = path.join(THEMES_DIR, theme, `campaign.${lang}.json`)
165
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"))
166
+ }
167
+ } catch {
168
+ // Thèmes non montés (ancien projet) : l'admin affiche des champs vides, comme avant
169
+ }
170
+ return undefined
171
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `create-boutique urls` : réécrit l'adresse des images déjà en base quand l'adresse publique du stockage change
3
+ * (ex. http://localhost:9100/medusa → https://images.marque.com/medusa). Les fichiers ne bougent pas :
4
+ * seul le début de l'adresse enregistrée change. Variables : FILES_FROM (ancien préfixe), FILES_TO (nouveau).
5
+ * Concerne : photos et vignettes des produits, visuels et logo des campagnes, image de partage (Open Graph).
6
+ * Aucun SQL : services des modules Product et Campaign. Relançable : sans effet si plus rien ne commence par FILES_FROM.
7
+ */
8
+ import { ExecArgs } from "@medusajs/framework/types"
9
+ import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
10
+ import { CAMPAIGN_MODULE } from "../modules/campaign"
11
+ import CampaignModuleService from "../modules/campaign/service"
12
+
13
+ export default async function rewriteFileUrls({ container }: ExecArgs) {
14
+ const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
15
+ const from = (process.env.FILES_FROM ?? "").replace(/\/+$/, "")
16
+ const to = (process.env.FILES_TO ?? "").replace(/\/+$/, "")
17
+ if (!/^https?:\/\//.test(from) || !/^https?:\/\//.test(to)) throw new Error("FILES_FROM et FILES_TO : adresses http(s) obligatoires")
18
+ if (from === to) return logger.info("[urls] rien à faire : même adresse")
19
+
20
+ // Remplace seulement le début exact (préfixe + « / ») : une autre adresse n'est jamais touchée.
21
+ const swap = <T extends string | null | undefined>(u: T): T => (typeof u === "string" && u.startsWith(`${from}/`) ? (`${to}${u.slice(from.length)}` as T) : u)
22
+
23
+ // ---------- Produits : vignette + photos (id conservés : les photos des variantes restent liées) ----------
24
+ const products = container.resolve(Modules.PRODUCT)
25
+ const list = await products.listProducts({}, { select: ["id", "thumbnail"], relations: ["images"], take: 100000 })
26
+ let productCount = 0
27
+ for (const p of list) {
28
+ const images = (p.images ?? []).filter((i) => swap(i.url) !== i.url)
29
+ if (swap(p.thumbnail) === p.thumbnail && !images.length) continue
30
+ await products.updateProducts(p.id, {
31
+ thumbnail: swap(p.thumbnail),
32
+ images: (p.images ?? []).map((i) => ({ id: i.id, url: swap(i.url) })),
33
+ })
34
+ productCount++
35
+ }
36
+
37
+ // ---------- Campagnes : hero, bannière promo, logo, image de partage ----------
38
+ const campaigns: CampaignModuleService = container.resolve(CAMPAIGN_MODULE)
39
+ let campaignCount = 0
40
+ for (const c of await campaigns.listBoutiqueCampaigns({})) {
41
+ const texts = (c.texts ?? {}) as Record<string, unknown> & { seo?: { image?: string } }
42
+ const seoImage = swap(texts.seo?.image)
43
+ const update = {
44
+ hero_image: swap(c.hero_image),
45
+ promo_image: swap(c.promo_image),
46
+ logo: swap(c.logo),
47
+ texts: seoImage !== texts.seo?.image ? { ...texts, seo: { ...texts.seo, image: seoImage } } : c.texts,
48
+ }
49
+ if (update.hero_image === c.hero_image && update.promo_image === c.promo_image && update.logo === c.logo && update.texts === c.texts) continue
50
+ await campaigns.updateBoutiqueCampaigns({ id: c.id, ...update })
51
+ campaignCount++
52
+ }
53
+
54
+ logger.info(`[urls] ${from} → ${to} : ${productCount} produit(s), ${campaignCount} campagne(s) mis à jour`)
55
+ process.stdout.write(`<<<URLS${JSON.stringify({ products: productCount, campaigns: campaignCount })}URLS>>>\n`)
56
+ }
@@ -33,6 +33,7 @@ import {
33
33
  uploadFilesWorkflow,
34
34
  } from "@medusajs/medusa/core-flows"
35
35
  import { CONFIG_DIR, KEYS_DIR, SEED_DIR, countriesByCurrency, readProject, shippingRates } from "../lib/project"
36
+ import { getActive, writeActiveFile } from "../lib/active"
36
37
  import { CAMPAIGN_MODULE } from "../modules/campaign"
37
38
  import CampaignModuleService from "../modules/campaign/service"
38
39
  import { CampaignFile, CampaignInput } from "../modules/campaign/validators"
@@ -245,6 +246,13 @@ export default async function seed({ container }: ExecArgs) {
245
246
  })
246
247
  }
247
248
 
249
+ // ---------- Mode single : boutique en ligne (la première au départ, ensuite celle choisie dans l'admin) ----------
250
+ if (project.mode === "single") {
251
+ const active = await getActive(container)
252
+ writeActiveFile(active)
253
+ logger.info(`[seed] mode single : boutique en ligne ${active}`)
254
+ }
255
+
248
256
  // ---------- Campagnes : valeur initiale depuis config/campaigns/<slug>.json ----------
249
257
  // Importée seulement si la boutique n'a pas encore de campagne : une campagne modifiée
250
258
  // dans l'admin (source de vérité = base) n'est jamais écrasée.
@@ -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">
@@ -1,5 +1,6 @@
1
1
  import { Metadata } from "next"
2
- import { notFound } from "next/navigation"
2
+ import { notFound, redirect } from "next/navigation"
3
+ import { isSingleSite } from "@lib/boutique/config"
3
4
  import { listProducts } from "@lib/data/products"
4
5
  import { getRegion } from "@lib/data/regions"
5
6
  import ProductTemplate from "@modules/products/templates"
@@ -71,6 +72,8 @@ export default async function ProductPage(props: Props) {
71
72
  }).then(({ response }) => response.products[0])
72
73
 
73
74
  if (!pricedProduct) {
75
+ // Mode single : lien vers un produit d'une boutique qui n'est plus en ligne → la boutique actuelle
76
+ if (isSingleSite()) redirect("/store")
74
77
  notFound()
75
78
  }
76
79
 
@@ -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>