@salifbiaye/create-boutique 0.7.1 → 0.7.2
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 +83 -3
- package/cli/dist/generate.js +2 -0
- package/cli/dist/index.js +11 -5
- package/docs/guide-complet.fr.html +29 -1
- package/docs/guide-images/personnaliser-style.jpg +0 -0
- package/docs/guide-images/personnaliser-textes.jpg +0 -0
- package/package.json +1 -1
- package/templates/backend/medusa-config.ts +4 -0
- package/templates/backend/src/admin/i18n/json/en.json +64 -0
- package/templates/backend/src/admin/i18n/json/fr.json +64 -0
- package/templates/backend/src/admin/routes/personnaliser/page.tsx +407 -0
- package/templates/backend/src/api/admin/customization/css/restore/route.ts +21 -0
- package/templates/backend/src/api/admin/customization/css/route.ts +37 -0
- package/templates/backend/src/api/admin/customization/texts/route.ts +38 -0
- package/templates/backend/src/api/middlewares.ts +4 -0
- package/templates/backend/src/api/store/customization/route.ts +21 -0
- package/templates/backend/src/lib/customization.ts +43 -0
- package/templates/backend/src/modules/customization/index.ts +9 -0
- package/templates/backend/src/modules/customization/migrations/Migration20261001080000.ts +21 -0
- package/templates/backend/src/modules/customization/models/css-version.ts +16 -0
- package/templates/backend/src/modules/customization/models/site-text.ts +17 -0
- package/templates/backend/src/modules/customization/service.ts +7 -0
- package/templates/backend/src/modules/customization/validators.ts +49 -0
- package/templates/backend/src/workflows/customization.ts +15 -0
- package/templates/backend/src/workflows/steps/save-css-version.ts +20 -0
- package/templates/backend/src/workflows/steps/save-site-texts.ts +56 -0
- package/templates/storefront/Dockerfile +2 -0
- package/templates/storefront/PERSONNALISER.md +15 -4
- package/templates/storefront/src/app/layout.tsx +17 -2
- package/templates/storefront/src/lib/boutique/customization.ts +76 -0
- package/templates/storefront/src/lib/boutique/i18n.ts +4 -2
- package/templates/storefront/src/styles/custom.css +2 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
|
2
|
+
import { Adjustments, ArrowUturnLeft, Clock, CodeEditor, DocumentText, Language, MagnifyingGlass, XMark } from "@medusajs/icons"
|
|
3
|
+
import { Badge, Button, Container, Heading, Input, Select, Switch, Tabs, Text, Textarea, toast, usePrompt } from "@medusajs/ui"
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
5
|
+
import { useEffect, useMemo, useState } from "react"
|
|
6
|
+
import { useTranslation } from "react-i18next"
|
|
7
|
+
import { sdk } from "../../lib/client"
|
|
8
|
+
|
|
9
|
+
type TextRow = { key: string; default: string | null; all: string | null; own: string | null }
|
|
10
|
+
type TextsResponse = { language: string; defaultsAvailable: boolean; texts: TextRow[] }
|
|
11
|
+
type Version = { id: string; css: string; use_file: boolean; note: string | null; author: string | null; created_at: string }
|
|
12
|
+
type CssResponse = { current: Version | null; versions: Version[]; file: string | null }
|
|
13
|
+
type Boutiques = { boutiques: { slug: string; sales_channel_id: string }[] }
|
|
14
|
+
|
|
15
|
+
const fmt = (iso: string, lang: string) => new Date(iso).toLocaleString(lang === "fr" ? "fr-FR" : "en-US", { dateStyle: "medium", timeStyle: "short" })
|
|
16
|
+
const kb = (s: string) => `${(new Blob([s]).size / 1024).toFixed(1)} Ko`
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Page « Personnaliser » : changer sans reconstruire les textes de l'interface des boutiques et leur style (CSS).
|
|
20
|
+
* Tout apparaît sur les sites en une minute environ.
|
|
21
|
+
*/
|
|
22
|
+
const CustomizePage = () => {
|
|
23
|
+
const { t } = useTranslation()
|
|
24
|
+
return (
|
|
25
|
+
<Container className="divide-y p-0">
|
|
26
|
+
<div className="px-6 py-4">
|
|
27
|
+
<Heading level="h1">{t("customize.title")}</Heading>
|
|
28
|
+
<Text size="small" className="text-ui-fg-subtle mt-1">
|
|
29
|
+
{t("customize.description")}
|
|
30
|
+
</Text>
|
|
31
|
+
</div>
|
|
32
|
+
<Tabs defaultValue="texts">
|
|
33
|
+
<div className="px-6 pt-4">
|
|
34
|
+
<Tabs.List>
|
|
35
|
+
<Tabs.Trigger value="texts">
|
|
36
|
+
<span className="flex items-center gap-2">
|
|
37
|
+
<Language /> {t("customize.tabs.texts")}
|
|
38
|
+
</span>
|
|
39
|
+
</Tabs.Trigger>
|
|
40
|
+
<Tabs.Trigger value="css">
|
|
41
|
+
<span className="flex items-center gap-2">
|
|
42
|
+
<CodeEditor /> {t("customize.tabs.css")}
|
|
43
|
+
</span>
|
|
44
|
+
</Tabs.Trigger>
|
|
45
|
+
</Tabs.List>
|
|
46
|
+
</div>
|
|
47
|
+
<Tabs.Content value="texts">
|
|
48
|
+
<TextsTab />
|
|
49
|
+
</Tabs.Content>
|
|
50
|
+
<Tabs.Content value="css">
|
|
51
|
+
<CssTab />
|
|
52
|
+
</Tabs.Content>
|
|
53
|
+
</Tabs>
|
|
54
|
+
</Container>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─────────────────────────── Textes du site ───────────────────────────
|
|
59
|
+
|
|
60
|
+
function TextsTab() {
|
|
61
|
+
const { t } = useTranslation()
|
|
62
|
+
const queryClient = useQueryClient()
|
|
63
|
+
const [scope, setScope] = useState("all")
|
|
64
|
+
const [search, setSearch] = useState("")
|
|
65
|
+
const [onlyChanged, setOnlyChanged] = useState(false)
|
|
66
|
+
const [edits, setEdits] = useState<Record<string, string | null>>({})
|
|
67
|
+
|
|
68
|
+
const { data: shops } = useQuery({ queryKey: ["boutique-campaigns"], queryFn: () => sdk.client.fetch<Boutiques>("/admin/boutique-campaigns") })
|
|
69
|
+
const { data, isLoading } = useQuery({
|
|
70
|
+
queryKey: ["customization-texts", scope],
|
|
71
|
+
queryFn: () => sdk.client.fetch<TextsResponse>(`/admin/customization/texts?scope=${scope}`),
|
|
72
|
+
})
|
|
73
|
+
useEffect(() => setEdits({}), [scope])
|
|
74
|
+
|
|
75
|
+
const save = useMutation({
|
|
76
|
+
mutationFn: () => sdk.client.fetch<{ changed: number }>("/admin/customization/texts", { method: "POST", body: { scope, values: edits } }),
|
|
77
|
+
onSuccess: (r) => {
|
|
78
|
+
setEdits({})
|
|
79
|
+
queryClient.invalidateQueries({ queryKey: ["customization-texts"] })
|
|
80
|
+
toast.success(t("customize.texts.saved", { count: r.changed }))
|
|
81
|
+
},
|
|
82
|
+
onError: (e: Error) => toast.error(e.message),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const stored = (r: TextRow) => (scope === "all" ? r.all : r.own)
|
|
86
|
+
const value = (r: TextRow) => (r.key in edits ? edits[r.key] : stored(r))
|
|
87
|
+
/** Texte affiché sans remplacement à ce niveau : boutique → projet → origine */
|
|
88
|
+
const inherited = (r: TextRow) => (scope === "all" ? r.default : (r.all ?? r.default)) ?? ""
|
|
89
|
+
|
|
90
|
+
const rows = useMemo(() => {
|
|
91
|
+
const q = search.trim().toLowerCase()
|
|
92
|
+
return (data?.texts ?? []).filter((r) => {
|
|
93
|
+
if (onlyChanged && !stored(r) && !(r.key in edits)) return false
|
|
94
|
+
if (!q) return true
|
|
95
|
+
return [r.key, r.default, r.all, r.own].some((v) => v?.toLowerCase().includes(q))
|
|
96
|
+
})
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [data, search, onlyChanged, edits, scope])
|
|
99
|
+
|
|
100
|
+
const groups = useMemo(() => {
|
|
101
|
+
const m = new Map<string, TextRow[]>()
|
|
102
|
+
for (const r of rows) {
|
|
103
|
+
const g = r.key.split(".")[0]
|
|
104
|
+
m.set(g, [...(m.get(g) ?? []), r])
|
|
105
|
+
}
|
|
106
|
+
return [...m.entries()]
|
|
107
|
+
}, [rows])
|
|
108
|
+
|
|
109
|
+
const dirty = Object.keys(edits).length
|
|
110
|
+
const changedCount = (data?.texts ?? []).filter((r) => stored(r)).length
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
<div className="flex flex-wrap items-end gap-3 px-6 py-4">
|
|
115
|
+
<div className="w-64">
|
|
116
|
+
<Text size="xsmall" className="text-ui-fg-subtle mb-1">
|
|
117
|
+
{t("customize.texts.scope")}
|
|
118
|
+
</Text>
|
|
119
|
+
<Select value={scope} onValueChange={setScope}>
|
|
120
|
+
<Select.Trigger>
|
|
121
|
+
<Select.Value />
|
|
122
|
+
</Select.Trigger>
|
|
123
|
+
<Select.Content>
|
|
124
|
+
<Select.Item value="all">{t("customize.texts.allStores")}</Select.Item>
|
|
125
|
+
{(shops?.boutiques ?? []).map((b) => (
|
|
126
|
+
<Select.Item key={b.sales_channel_id} value={b.sales_channel_id}>
|
|
127
|
+
{b.slug}
|
|
128
|
+
</Select.Item>
|
|
129
|
+
))}
|
|
130
|
+
</Select.Content>
|
|
131
|
+
</Select>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="relative min-w-60 flex-1">
|
|
134
|
+
<MagnifyingGlass className="text-ui-fg-muted pointer-events-none absolute left-2 top-1/2 -translate-y-1/2" />
|
|
135
|
+
<Input className="pl-8" placeholder={t("customize.texts.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
136
|
+
</div>
|
|
137
|
+
<label className="flex items-center gap-2 pb-1.5">
|
|
138
|
+
<Switch checked={onlyChanged} onCheckedChange={setOnlyChanged} />
|
|
139
|
+
<Text size="small">{t("customize.texts.onlyChanged", { count: changedCount })}</Text>
|
|
140
|
+
</label>
|
|
141
|
+
</div>
|
|
142
|
+
<Text size="small" className="text-ui-fg-subtle px-6 pb-3">
|
|
143
|
+
{scope === "all" ? t("customize.texts.helpAll") : t("customize.texts.helpOne")}
|
|
144
|
+
</Text>
|
|
145
|
+
{data && !data.defaultsAvailable && (
|
|
146
|
+
<Text size="small" className="text-ui-fg-error px-6 pb-3">
|
|
147
|
+
{t("customize.texts.noDefaults")}
|
|
148
|
+
</Text>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{isLoading ? (
|
|
152
|
+
<Text className="text-ui-fg-subtle px-6 py-8">{t("customize.loading")}</Text>
|
|
153
|
+
) : !rows.length ? (
|
|
154
|
+
<Text className="text-ui-fg-subtle px-6 py-8">{t("customize.texts.none")}</Text>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="divide-y border-t">
|
|
157
|
+
{groups.map(([group, list]) => (
|
|
158
|
+
<section key={group} className="px-6 py-4">
|
|
159
|
+
<Text size="small" weight="plus" className="text-ui-fg-subtle mb-2 uppercase tracking-wide">
|
|
160
|
+
{group} <span className="text-ui-fg-muted font-normal normal-case">· {list.length}</span>
|
|
161
|
+
</Text>
|
|
162
|
+
<div className="grid gap-3">
|
|
163
|
+
{list.map((r) => {
|
|
164
|
+
const v = value(r)
|
|
165
|
+
const changed = r.key in edits
|
|
166
|
+
return (
|
|
167
|
+
<div key={r.key} className="grid items-start gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_auto]">
|
|
168
|
+
<div className="min-w-0">
|
|
169
|
+
<code className="text-ui-fg-muted block truncate text-xs">{r.key}</code>
|
|
170
|
+
<Text size="small" className="text-ui-fg-subtle line-clamp-2">
|
|
171
|
+
{inherited(r) || "—"}
|
|
172
|
+
</Text>
|
|
173
|
+
</div>
|
|
174
|
+
<Input
|
|
175
|
+
size="small"
|
|
176
|
+
value={v ?? ""}
|
|
177
|
+
placeholder={inherited(r)}
|
|
178
|
+
onChange={(e) => setEdits((old) => ({ ...old, [r.key]: e.target.value === "" ? null : e.target.value }))}
|
|
179
|
+
className={changed ? "ring-ui-border-interactive ring-1" : ""}
|
|
180
|
+
/>
|
|
181
|
+
<div className="flex items-center gap-2 pt-1">
|
|
182
|
+
{stored(r) && !changed && <Badge size="2xsmall" color="blue">{t("customize.texts.custom")}</Badge>}
|
|
183
|
+
{scope !== "all" && r.all && !stored(r) && <Badge size="2xsmall" color="grey">{t("customize.texts.fromProject")}</Badge>}
|
|
184
|
+
{(stored(r) || changed) && (
|
|
185
|
+
<Button
|
|
186
|
+
size="small"
|
|
187
|
+
variant="transparent"
|
|
188
|
+
title={t("customize.texts.reset")}
|
|
189
|
+
onClick={() => setEdits((old) => (stored(r) ? { ...old, [r.key]: null } : Object.fromEntries(Object.entries(old).filter(([k]) => k !== r.key))))}
|
|
190
|
+
>
|
|
191
|
+
<ArrowUturnLeft />
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
</section>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{dirty > 0 && (
|
|
205
|
+
<div className="bg-ui-bg-base sticky bottom-0 flex items-center justify-between gap-3 border-t px-6 py-3 shadow-elevation-flyout">
|
|
206
|
+
<Text size="small">{t("customize.texts.pending", { count: dirty })}</Text>
|
|
207
|
+
<div className="flex gap-2">
|
|
208
|
+
<Button size="small" variant="secondary" onClick={() => setEdits({})}>
|
|
209
|
+
{t("customize.cancel")}
|
|
210
|
+
</Button>
|
|
211
|
+
<Button size="small" isLoading={save.isPending} onClick={() => save.mutate()}>
|
|
212
|
+
{t("customize.save")}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─────────────────────────── Style (CSS) ───────────────────────────
|
|
222
|
+
|
|
223
|
+
function CssTab() {
|
|
224
|
+
const { t, i18n } = useTranslation()
|
|
225
|
+
const queryClient = useQueryClient()
|
|
226
|
+
const prompt = usePrompt()
|
|
227
|
+
const { data, isLoading } = useQuery({ queryKey: ["customization-css"], queryFn: () => sdk.client.fetch<CssResponse>("/admin/customization/css") })
|
|
228
|
+
const [css, setCss] = useState("")
|
|
229
|
+
const [useFile, setUseFile] = useState(true)
|
|
230
|
+
const [note, setNote] = useState("")
|
|
231
|
+
const [showFile, setShowFile] = useState(false)
|
|
232
|
+
|
|
233
|
+
const saved = data?.current
|
|
234
|
+
const reload = () => {
|
|
235
|
+
setCss(saved?.css ?? "")
|
|
236
|
+
setUseFile(saved ? saved.use_file : true)
|
|
237
|
+
setNote("")
|
|
238
|
+
}
|
|
239
|
+
useEffect(reload, [saved?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
240
|
+
|
|
241
|
+
const dirty = css !== (saved?.css ?? "") || useFile !== (saved ? saved.use_file : true)
|
|
242
|
+
const done = (msg: string) => {
|
|
243
|
+
queryClient.invalidateQueries({ queryKey: ["customization-css"] })
|
|
244
|
+
toast.success(msg)
|
|
245
|
+
}
|
|
246
|
+
const save = useMutation({
|
|
247
|
+
mutationFn: (body: { css: string; use_file: boolean; note?: string }) => sdk.client.fetch("/admin/customization/css", { method: "POST", body }),
|
|
248
|
+
onSuccess: () => done(t("customize.css.saved")),
|
|
249
|
+
onError: (e: Error) => toast.error(e.message),
|
|
250
|
+
})
|
|
251
|
+
const restore = useMutation({
|
|
252
|
+
mutationFn: (id: string) => sdk.client.fetch("/admin/customization/css/restore", { method: "POST", body: { id } }),
|
|
253
|
+
onSuccess: () => done(t("customize.css.restored")),
|
|
254
|
+
onError: (e: Error) => toast.error(e.message),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const resetTheme = async () => {
|
|
258
|
+
const ok = await prompt({ title: t("customize.css.themeTitle"), description: t("customize.css.themeText"), confirmText: t("customize.css.themeConfirm"), cancelText: t("customize.cancel") })
|
|
259
|
+
if (ok) save.mutate({ css: "", use_file: false, note: t("customize.css.themeNote") })
|
|
260
|
+
}
|
|
261
|
+
const restoreVersion = async (v: Version) => {
|
|
262
|
+
const ok = await prompt({ title: t("customize.css.restoreTitle"), description: t("customize.css.restoreText", { date: fmt(v.created_at, i18n.language) }), confirmText: t("customize.css.restore"), cancelText: t("customize.cancel") })
|
|
263
|
+
if (ok) restore.mutate(v.id)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Tabulation dans l'éditeur : 2 espaces au lieu de changer de champ */
|
|
267
|
+
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
268
|
+
if (e.key !== "Tab") return
|
|
269
|
+
e.preventDefault()
|
|
270
|
+
const el = e.currentTarget
|
|
271
|
+
const { selectionStart: a, selectionEnd: b } = el
|
|
272
|
+
const next = `${css.slice(0, a)} ${css.slice(b)}`
|
|
273
|
+
setCss(next)
|
|
274
|
+
requestAnimationFrame(() => el.setSelectionRange(a + 2, a + 2))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (isLoading) return <Text className="text-ui-fg-subtle px-6 py-8">{t("customize.loading")}</Text>
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_340px] lg:divide-x">
|
|
281
|
+
<div className="px-6 py-4">
|
|
282
|
+
<Text size="small" className="text-ui-fg-subtle mb-3">
|
|
283
|
+
{t("customize.css.help")}
|
|
284
|
+
</Text>
|
|
285
|
+
<Textarea
|
|
286
|
+
value={css}
|
|
287
|
+
onChange={(e) => setCss(e.target.value)}
|
|
288
|
+
onKeyDown={onKey}
|
|
289
|
+
rows={22}
|
|
290
|
+
spellCheck={false}
|
|
291
|
+
className="font-mono text-xs leading-5"
|
|
292
|
+
placeholder={t("customize.css.placeholder")}
|
|
293
|
+
/>
|
|
294
|
+
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
|
295
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
296
|
+
{kb(css)} · {dirty ? t("customize.css.unsaved") : saved ? t("customize.css.appliedSince", { date: fmt(saved.created_at, i18n.language) }) : t("customize.css.none")}
|
|
297
|
+
</Text>
|
|
298
|
+
<label className="flex items-center gap-2">
|
|
299
|
+
<Switch checked={useFile} onCheckedChange={setUseFile} />
|
|
300
|
+
<Text size="small">{t("customize.css.useFile")}</Text>
|
|
301
|
+
</label>
|
|
302
|
+
</div>
|
|
303
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
304
|
+
<Input size="small" className="max-w-xs" placeholder={t("customize.css.notePlaceholder")} value={note} onChange={(e) => setNote(e.target.value)} />
|
|
305
|
+
<Button size="small" disabled={!dirty} isLoading={save.isPending} onClick={() => save.mutate({ css, use_file: useFile, note: note || undefined })}>
|
|
306
|
+
{t("customize.save")}
|
|
307
|
+
</Button>
|
|
308
|
+
<Button size="small" variant="secondary" disabled={!dirty} onClick={reload}>
|
|
309
|
+
<ArrowUturnLeft /> {t("customize.css.discard")}
|
|
310
|
+
</Button>
|
|
311
|
+
<Button size="small" variant="secondary" onClick={resetTheme}>
|
|
312
|
+
{t("customize.css.theme")}
|
|
313
|
+
</Button>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div className="mt-6 rounded-lg border">
|
|
317
|
+
<button type="button" className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left" onClick={() => setShowFile((s) => !s)}>
|
|
318
|
+
<span className="flex items-center gap-2">
|
|
319
|
+
<DocumentText />
|
|
320
|
+
<Text size="small" weight="plus">
|
|
321
|
+
{t("customize.css.file")}
|
|
322
|
+
</Text>
|
|
323
|
+
<Badge size="2xsmall" color={useFile ? "green" : "grey"}>
|
|
324
|
+
{useFile ? t("customize.css.fileOn") : t("customize.css.fileOff")}
|
|
325
|
+
</Badge>
|
|
326
|
+
</span>
|
|
327
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
328
|
+
{data?.file == null ? t("customize.css.fileMissing") : `${kb(data.file)} · ${showFile ? t("customize.css.hide") : t("customize.css.show")}`}
|
|
329
|
+
</Text>
|
|
330
|
+
</button>
|
|
331
|
+
{showFile && data?.file != null && (
|
|
332
|
+
<div className="border-t">
|
|
333
|
+
<Text size="xsmall" className="text-ui-fg-subtle px-4 pt-3">
|
|
334
|
+
{t("customize.css.fileHelp")}
|
|
335
|
+
</Text>
|
|
336
|
+
<pre className="bg-ui-bg-subtle text-ui-fg-subtle max-h-96 overflow-auto px-4 py-3 font-mono text-xs leading-5">{data.file}</pre>
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<aside className="px-6 py-4">
|
|
343
|
+
<Text size="small" weight="plus" className="mb-3 flex items-center gap-2">
|
|
344
|
+
<Clock /> {t("customize.css.history")}
|
|
345
|
+
</Text>
|
|
346
|
+
{!data?.versions.length ? (
|
|
347
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
348
|
+
{t("customize.css.noHistory")}
|
|
349
|
+
</Text>
|
|
350
|
+
) : (
|
|
351
|
+
<ul className="grid gap-2">
|
|
352
|
+
{data.versions.map((v, i) => (
|
|
353
|
+
<li key={v.id} className="rounded-lg border px-3 py-2">
|
|
354
|
+
<div className="flex items-center justify-between gap-2">
|
|
355
|
+
<Text size="xsmall" weight="plus">
|
|
356
|
+
{fmt(v.created_at, i18n.language)}
|
|
357
|
+
</Text>
|
|
358
|
+
{i === 0 && (
|
|
359
|
+
<Badge size="2xsmall" color="green">
|
|
360
|
+
{t("customize.css.applied")}
|
|
361
|
+
</Badge>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
<Text size="xsmall" className="text-ui-fg-subtle line-clamp-2">
|
|
365
|
+
{v.note || (v.css ? t("customize.css.noNote") : t("customize.css.empty"))}
|
|
366
|
+
</Text>
|
|
367
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
368
|
+
{[v.author, kb(v.css), v.use_file ? t("customize.css.withFile") : t("customize.css.withoutFile")].filter(Boolean).join(" · ")}
|
|
369
|
+
</Text>
|
|
370
|
+
<div className="mt-1.5 flex gap-1">
|
|
371
|
+
<Button
|
|
372
|
+
size="small"
|
|
373
|
+
variant="transparent"
|
|
374
|
+
onClick={() => {
|
|
375
|
+
setCss(v.css)
|
|
376
|
+
setUseFile(v.use_file)
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
{t("customize.css.load")}
|
|
380
|
+
</Button>
|
|
381
|
+
{i > 0 && (
|
|
382
|
+
<Button size="small" variant="transparent" onClick={() => restoreVersion(v)}>
|
|
383
|
+
<ArrowUturnLeft /> {t("customize.css.restore")}
|
|
384
|
+
</Button>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</li>
|
|
388
|
+
))}
|
|
389
|
+
</ul>
|
|
390
|
+
)}
|
|
391
|
+
{dirty && (
|
|
392
|
+
<Button size="small" variant="transparent" className="mt-2" onClick={reload}>
|
|
393
|
+
<XMark /> {t("customize.css.discard")}
|
|
394
|
+
</Button>
|
|
395
|
+
)}
|
|
396
|
+
</aside>
|
|
397
|
+
</div>
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export const config = defineRouteConfig({
|
|
402
|
+
label: "customize.nav",
|
|
403
|
+
translationNs: "translation",
|
|
404
|
+
icon: Adjustments,
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
export default CustomizePage
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { actorEmail } from "../../../../../lib/customization"
|
|
3
|
+
import { CUSTOMIZATION_MODULE } from "../../../../../modules/customization"
|
|
4
|
+
import type CustomizationModuleService from "../../../../../modules/customization/service"
|
|
5
|
+
import type { CssRestoreInput } from "../../../../../modules/customization/validators"
|
|
6
|
+
import { saveCssWorkflow } from "../../../../../workflows/customization"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /admin/customization/css/restore { id } : remet une ancienne version en la recopiant comme nouvelle version
|
|
10
|
+
* (l'historique n'est jamais réécrit : on peut toujours revenir à l'état d'avant).
|
|
11
|
+
*/
|
|
12
|
+
export async function POST(req: AuthenticatedMedusaRequest<CssRestoreInput>, res: MedusaResponse) {
|
|
13
|
+
const service: CustomizationModuleService = req.scope.resolve(CUSTOMIZATION_MODULE)
|
|
14
|
+
const old = await service.retrieveCssVersion(req.validatedBody.id)
|
|
15
|
+
const author = await actorEmail(req.scope, req.auth_context?.actor_id)
|
|
16
|
+
const date = new Date(old.created_at).toISOString().slice(0, 16).replace("T", " ")
|
|
17
|
+
const { result } = await saveCssWorkflow(req.scope).run({
|
|
18
|
+
input: { css: old.css, use_file: old.use_file, note: `Restauration de la version du ${date}`, author },
|
|
19
|
+
})
|
|
20
|
+
res.json({ version: { id: result.id, created_at: result.created_at } })
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { actorEmail, customCssFile } from "../../../../lib/customization"
|
|
3
|
+
import { CUSTOMIZATION_MODULE } from "../../../../modules/customization"
|
|
4
|
+
import type CustomizationModuleService from "../../../../modules/customization/service"
|
|
5
|
+
import type { CssInput } from "../../../../modules/customization/validators"
|
|
6
|
+
import { saveCssWorkflow } from "../../../../workflows/customization"
|
|
7
|
+
|
|
8
|
+
const view = (v: { id: string; css: string; use_file: boolean; note: string | null; author: string | null; created_at: Date | string }) => ({
|
|
9
|
+
id: v.id,
|
|
10
|
+
css: v.css,
|
|
11
|
+
use_file: v.use_file,
|
|
12
|
+
note: v.note,
|
|
13
|
+
author: v.author,
|
|
14
|
+
created_at: v.created_at,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GET /admin/customization/css : version appliquée (la plus récente), historique (30 dernières) et contenu du
|
|
19
|
+
* fichier custom.css du projet (lecture seule : il se modifie dans le code, voir PERSONNALISER.md).
|
|
20
|
+
*/
|
|
21
|
+
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
22
|
+
const service: CustomizationModuleService = req.scope.resolve(CUSTOMIZATION_MODULE)
|
|
23
|
+
const versions = await service.listCssVersions({}, { order: { created_at: "DESC" }, take: 30 })
|
|
24
|
+
res.json({
|
|
25
|
+
current: versions[0] ? view(versions[0]) : null,
|
|
26
|
+
versions: versions.map(view),
|
|
27
|
+
file: customCssFile(),
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** POST /admin/customization/css : enregistre une nouvelle version (appliquée aux boutiques en ~1 minute) */
|
|
32
|
+
export async function POST(req: AuthenticatedMedusaRequest<CssInput>, res: MedusaResponse) {
|
|
33
|
+
const { css, use_file, note } = req.validatedBody
|
|
34
|
+
const author = await actorEmail(req.scope, req.auth_context?.actor_id)
|
|
35
|
+
const { result } = await saveCssWorkflow(req.scope).run({ input: { css, use_file, note: note || null, author } })
|
|
36
|
+
res.json({ version: view(result) })
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { MedusaError } from "@medusajs/framework/utils"
|
|
3
|
+
import { defaultTexts } from "../../../../lib/customization"
|
|
4
|
+
import { readProject } from "../../../../lib/project"
|
|
5
|
+
import { CUSTOMIZATION_MODULE } from "../../../../modules/customization"
|
|
6
|
+
import type CustomizationModuleService from "../../../../modules/customization/service"
|
|
7
|
+
import { SCOPE_RE, type TextsInput } from "../../../../modules/customization/validators"
|
|
8
|
+
import { saveSiteTextsWorkflow } from "../../../../workflows/customization"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /admin/customization/texts?scope=all|<sales channel> : chaque texte d'interface avec sa valeur d'origine,
|
|
12
|
+
* celle remplacée pour tout le projet et celle remplacée pour la boutique choisie.
|
|
13
|
+
*/
|
|
14
|
+
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
15
|
+
const scope = String(req.query.scope ?? "all")
|
|
16
|
+
if (!SCOPE_RE.test(scope)) throw new MedusaError(MedusaError.Types.INVALID_DATA, "boutique invalide")
|
|
17
|
+
const { language } = readProject()
|
|
18
|
+
const origin = defaultTexts(language)
|
|
19
|
+
const service: CustomizationModuleService = req.scope.resolve(CUSTOMIZATION_MODULE)
|
|
20
|
+
const all = await service.listSiteTexts({ scope: "all" }, { take: 5000 })
|
|
21
|
+
const own = scope === "all" ? [] : await service.listSiteTexts({ scope }, { take: 5000 })
|
|
22
|
+
const allMap = new Map(all.map((t) => [t.key, t.value]))
|
|
23
|
+
const ownMap = new Map(own.map((t) => [t.key, t.value]))
|
|
24
|
+
const keys = [...new Set([...Object.keys(origin ?? {}), ...allMap.keys(), ...ownMap.keys()])].sort()
|
|
25
|
+
|
|
26
|
+
res.json({
|
|
27
|
+
language,
|
|
28
|
+
// Fichiers d'origine absents (projet pas encore mis à jour) : on liste seulement les textes déjà remplacés
|
|
29
|
+
defaultsAvailable: origin !== null,
|
|
30
|
+
texts: keys.map((key) => ({ key, default: origin?.[key] ?? null, all: allMap.get(key) ?? null, own: ownMap.get(key) ?? null })),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** POST /admin/customization/texts : { scope, values: { clé: texte | null } } (null = texte d'origine) */
|
|
35
|
+
export async function POST(req: AuthenticatedMedusaRequest<TextsInput>, res: MedusaResponse) {
|
|
36
|
+
const { result } = await saveSiteTextsWorkflow(req.scope).run({ input: req.validatedBody })
|
|
37
|
+
res.json(result)
|
|
38
|
+
}
|
|
@@ -5,6 +5,7 @@ import { ActiveBoutique } from "./admin/active-boutique/route"
|
|
|
5
5
|
import { BoutiqueOnline } from "./admin/boutique-online/[salesChannelId]/route"
|
|
6
6
|
import { OrderLookup } from "./store/order-lookup/route"
|
|
7
7
|
import { ReviewInput, ReviewStatusInput } from "../modules/product-review/validators"
|
|
8
|
+
import { CssInput, CssRestoreInput, TextsInput } from "../modules/customization/validators"
|
|
8
9
|
|
|
9
10
|
// Anti force brute (par IP) : connexion admin, mot de passe oublié, suivi de commande.
|
|
10
11
|
const loginLimit = rateLimit({ name: "connexion", max: 10, windowMs: 15 * 60 * 1000 })
|
|
@@ -22,6 +23,9 @@ export default defineMiddlewares({
|
|
|
22
23
|
},
|
|
23
24
|
{ matcher: "/admin/active-boutique", method: "POST", middlewares: [validateAndTransformBody(ActiveBoutique)] },
|
|
24
25
|
{ matcher: "/admin/boutique-online/:salesChannelId", method: "POST", middlewares: [validateAndTransformBody(BoutiqueOnline)] },
|
|
26
|
+
{ matcher: "/admin/customization/texts", method: "POST", middlewares: [validateAndTransformBody(TextsInput)] },
|
|
27
|
+
{ matcher: "/admin/customization/css", method: "POST", middlewares: [validateAndTransformBody(CssInput)] },
|
|
28
|
+
{ matcher: "/admin/customization/css/restore", method: "POST", middlewares: [validateAndTransformBody(CssRestoreInput)] },
|
|
25
29
|
{ matcher: "/auth/user/emailpass", method: "POST", middlewares: [loginLimit] },
|
|
26
30
|
{ matcher: "/auth/user/emailpass/reset-password", method: "POST", middlewares: [resetLimit] },
|
|
27
31
|
{ matcher: "/auth/customer/emailpass", method: "POST", middlewares: [loginLimit] },
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { MedusaStoreRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { CUSTOMIZATION_MODULE } from "../../../modules/customization"
|
|
3
|
+
import type CustomizationModuleService from "../../../modules/customization/service"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /store/customization : personnalisation de LA boutique qui appelle (sales channel de sa publishable key) :
|
|
7
|
+
* textes remplacés (projet entier, puis ceux de la boutique, prioritaires) et style appliqué.
|
|
8
|
+
* Aucun paramètre ne permet de lire ceux d'une autre boutique.
|
|
9
|
+
*/
|
|
10
|
+
export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
11
|
+
const salesChannelIds = req.publishable_key_context?.sales_channel_ids ?? []
|
|
12
|
+
const service: CustomizationModuleService = req.scope.resolve(CUSTOMIZATION_MODULE)
|
|
13
|
+
const rows = await service.listSiteTexts({ scope: ["all", ...salesChannelIds] }, { take: 5000 })
|
|
14
|
+
const texts: Record<string, string> = {}
|
|
15
|
+
for (const r of rows.filter((r) => r.scope === "all")) texts[r.key] = r.value
|
|
16
|
+
for (const r of rows.filter((r) => r.scope !== "all")) texts[r.key] = r.value
|
|
17
|
+
const [current] = await service.listCssVersions({}, { order: { created_at: "DESC" }, take: 1 })
|
|
18
|
+
|
|
19
|
+
res.setHeader("Cache-Control", "public, max-age=60")
|
|
20
|
+
res.json({ texts, css: current?.css ?? "", use_file: current ? current.use_file : true, version: current?.id ?? null })
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
4
|
+
import type { MedusaContainer } from "@medusajs/framework/types"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fichiers du storefront montés en lecture seule dans le backend (docker-compose généré par le CLI) :
|
|
8
|
+
* textes d'interface d'origine (locales/<langue>.json) et fichier custom.css du projet.
|
|
9
|
+
*/
|
|
10
|
+
export const LOCALES_DIR = process.env.LOCALES_DIR || "/app/locales"
|
|
11
|
+
export const CUSTOM_CSS_FILE = process.env.CUSTOM_CSS_FILE || "/app/custom/custom.css"
|
|
12
|
+
|
|
13
|
+
const readJson = (file: string): Record<string, string> => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
16
|
+
} catch {
|
|
17
|
+
return {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Textes d'origine de l'interface dans la langue du projet (repli en anglais), ou null si les fichiers manquent */
|
|
22
|
+
export function defaultTexts(language: string): Record<string, string> | null {
|
|
23
|
+
const en = readJson(path.join(LOCALES_DIR, "en.json"))
|
|
24
|
+
const own = language === "en" ? en : readJson(path.join(LOCALES_DIR, `${language}.json`))
|
|
25
|
+
if (!Object.keys(en).length && !Object.keys(own).length) return null
|
|
26
|
+
return { ...en, ...own }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function customCssFile(): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(CUSTOM_CSS_FILE, "utf8")
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** E-mail de l'utilisateur admin connecté (auteur d'une version du style) */
|
|
38
|
+
export async function actorEmail(scope: MedusaContainer, actorId?: string): Promise<string | null> {
|
|
39
|
+
if (!actorId) return null
|
|
40
|
+
const query = scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
41
|
+
const { data } = await query.graph({ entity: "user", fields: ["email"], filters: { id: actorId } })
|
|
42
|
+
return (data[0] as { email?: string } | undefined)?.email ?? null
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Module } from "@medusajs/framework/utils"
|
|
2
|
+
import CustomizationModuleService from "./service"
|
|
3
|
+
|
|
4
|
+
/** Personnalisation sans reconstruire : textes d'interface remplacés et style (CSS) versionné */
|
|
5
|
+
export const CUSTOMIZATION_MODULE = "siteCustomization"
|
|
6
|
+
|
|
7
|
+
export default Module(CUSTOMIZATION_MODULE, {
|
|
8
|
+
service: CustomizationModuleService,
|
|
9
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Migration } from "@medusajs/framework/mikro-orm/migrations"
|
|
2
|
+
|
|
3
|
+
// Écrite à la main (même forme que celles de `medusa db:generate`, qui exige une base joignable).
|
|
4
|
+
export class Migration20261001080000 extends Migration {
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(
|
|
7
|
+
`create table if not exists "site_text" ("id" text not null, "scope" text not null, "key" text not null, "value" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "site_text_pkey" primary key ("id"));`
|
|
8
|
+
)
|
|
9
|
+
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_site_text_scope_key_unique" ON "site_text" ("scope", "key") WHERE deleted_at IS NULL;`)
|
|
10
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_site_text_deleted_at" ON "site_text" ("deleted_at") WHERE deleted_at IS NULL;`)
|
|
11
|
+
this.addSql(
|
|
12
|
+
`create table if not exists "site_css_version" ("id" text not null, "css" text not null, "use_file" boolean not null default true, "note" text null, "author" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "site_css_version_pkey" primary key ("id"));`
|
|
13
|
+
)
|
|
14
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_site_css_version_deleted_at" ON "site_css_version" ("deleted_at") WHERE deleted_at IS NULL;`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
override async down(): Promise<void> {
|
|
18
|
+
this.addSql(`drop table if exists "site_text" cascade;`)
|
|
19
|
+
this.addSql(`drop table if exists "site_css_version" cascade;`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Version du style personnalisé (admin → Personnaliser → Style). Chaque enregistrement crée une version ;
|
|
5
|
+
* la plus récente est appliquée aux boutiques (sans reconstruire). Historique gardé pour revenir en arrière.
|
|
6
|
+
* use_file = appliquer aussi le fichier storefront/src/styles/custom.css du projet ; css vide + use_file faux = thème d'origine.
|
|
7
|
+
*/
|
|
8
|
+
const CssVersion = model.define("site_css_version", {
|
|
9
|
+
id: model.id({ prefix: "scss" }).primaryKey(),
|
|
10
|
+
css: model.text(),
|
|
11
|
+
use_file: model.boolean().default(true),
|
|
12
|
+
note: model.text().nullable(),
|
|
13
|
+
author: model.text().nullable(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export default CssVersion
|