@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
|
@@ -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": "
|
|
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
|
-
|
|
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: () =>
|
|
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({
|
|
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
|
|
|
@@ -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>
|