@salifbiaye/create-boutique 0.5.0 → 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 (29) 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/src/app/(main)/products/[handle]/page.tsx +4 -1
  21. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  22. package/templates/storefront/src/lib/boutique/schema.ts +4 -1
  23. package/templates/storefront/src/lib/config.ts +37 -8
  24. package/templates/storefront/src/lib/data/cart.ts +4 -1
  25. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  26. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  27. package/templates/storefront/src/proxy.ts +2 -1
  28. package/templates/storefront/src/styles/ub/motion.css +2 -1
  29. package/templates/storefront/src/styles/ub/tech.css +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.
@@ -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
 
@@ -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
  }