@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.
Files changed (32) hide show
  1. package/cli/dist/commands.js +83 -3
  2. package/cli/dist/generate.js +2 -0
  3. package/cli/dist/index.js +11 -5
  4. package/docs/guide-complet.fr.html +29 -1
  5. package/docs/guide-images/personnaliser-style.jpg +0 -0
  6. package/docs/guide-images/personnaliser-textes.jpg +0 -0
  7. package/package.json +1 -1
  8. package/templates/backend/medusa-config.ts +4 -0
  9. package/templates/backend/src/admin/i18n/json/en.json +64 -0
  10. package/templates/backend/src/admin/i18n/json/fr.json +64 -0
  11. package/templates/backend/src/admin/routes/personnaliser/page.tsx +407 -0
  12. package/templates/backend/src/api/admin/customization/css/restore/route.ts +21 -0
  13. package/templates/backend/src/api/admin/customization/css/route.ts +37 -0
  14. package/templates/backend/src/api/admin/customization/texts/route.ts +38 -0
  15. package/templates/backend/src/api/middlewares.ts +4 -0
  16. package/templates/backend/src/api/store/customization/route.ts +21 -0
  17. package/templates/backend/src/lib/customization.ts +43 -0
  18. package/templates/backend/src/modules/customization/index.ts +9 -0
  19. package/templates/backend/src/modules/customization/migrations/Migration20261001080000.ts +21 -0
  20. package/templates/backend/src/modules/customization/models/css-version.ts +16 -0
  21. package/templates/backend/src/modules/customization/models/site-text.ts +17 -0
  22. package/templates/backend/src/modules/customization/service.ts +7 -0
  23. package/templates/backend/src/modules/customization/validators.ts +49 -0
  24. package/templates/backend/src/workflows/customization.ts +15 -0
  25. package/templates/backend/src/workflows/steps/save-css-version.ts +20 -0
  26. package/templates/backend/src/workflows/steps/save-site-texts.ts +56 -0
  27. package/templates/storefront/Dockerfile +2 -0
  28. package/templates/storefront/PERSONNALISER.md +15 -4
  29. package/templates/storefront/src/app/layout.tsx +17 -2
  30. package/templates/storefront/src/lib/boutique/customization.ts +76 -0
  31. package/templates/storefront/src/lib/boutique/i18n.ts +4 -2
  32. package/templates/storefront/src/styles/custom.css +2 -0
@@ -0,0 +1,17 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ /**
4
+ * Texte d'interface remplacé (admin → Personnaliser → Textes du site).
5
+ * scope = « all » (toutes les boutiques du projet) ou l'id du sales channel d'une boutique (prioritaire).
6
+ * key = clé de storefront/locales/<langue>.json (ex. « cart.title ») ; texte simple uniquement.
7
+ */
8
+ const SiteText = model
9
+ .define("site_text", {
10
+ id: model.id({ prefix: "stxt" }).primaryKey(),
11
+ scope: model.text(),
12
+ key: model.text(),
13
+ value: model.text(),
14
+ })
15
+ .indexes([{ on: ["scope", "key"], unique: true }])
16
+
17
+ export default SiteText
@@ -0,0 +1,7 @@
1
+ import { MedusaService } from "@medusajs/framework/utils"
2
+ import CssVersion from "./models/css-version"
3
+ import SiteText from "./models/site-text"
4
+
5
+ class CustomizationModuleService extends MedusaService({ SiteText, CssVersion }) {}
6
+
7
+ export default CustomizationModuleService
@@ -0,0 +1,49 @@
1
+ import { z } from "@medusajs/framework/zod"
2
+
3
+ /**
4
+ * Personnalisation : textes d'interface (texte simple, pas de HTML) et style CSS (taille bornée, rien qui charge
5
+ * du contenu externe ou sorte de la balise <style>).
6
+ */
7
+ export const KEY_RE = /^[a-zA-Z0-9_.-]{1,120}$/
8
+ export const SCOPE_RE = /^(all|sc_[A-Za-z0-9]+)$/
9
+
10
+ export const TextsInput = z.object({
11
+ scope: z.string().regex(SCOPE_RE, "boutique invalide"),
12
+ /** clé → nouveau texte ; null = revenir au texte d'origine */
13
+ values: z.record(
14
+ z.string().regex(KEY_RE, "clé invalide"),
15
+ z
16
+ .string()
17
+ .max(1000, "1000 caractères maximum")
18
+ .refine((v) => !/[<>]/.test(v), "Texte simple uniquement (pas de HTML)")
19
+ .nullable()
20
+ ),
21
+ })
22
+ export type TextsInput = z.infer<typeof TextsInput>
23
+
24
+ export const MAX_CSS = 200_000
25
+
26
+ /** Interdits : sortie de la balise <style>, @import et url() externes (chargement depuis un autre site), anciens vecteurs de script */
27
+ export function cssProblem(css: string): string | null {
28
+ if (css.length > MAX_CSS) return `CSS trop long (${Math.round(MAX_CSS / 1000)} Ko maximum)`
29
+ if (/<\/?\s*style|<!--|<script/i.test(css)) return "Balises HTML interdites dans le CSS"
30
+ if (/@import/i.test(css)) return "@import interdit : mettez les polices dans public/custom/ ou utilisez celles du thème"
31
+ if (/expression\s*\(|javascript:|behavior\s*:|-moz-binding/i.test(css)) return "Instruction interdite dans le CSS"
32
+ const urls = [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)].map((m) => m[2].trim())
33
+ const bad = urls.find((u) => !(u.startsWith("/") || u.startsWith("data:image/") || u.startsWith("#")))
34
+ if (bad) return `url(${bad}) interdite : seulement des fichiers du site (/custom/…) ou des images data:`
35
+ return null
36
+ }
37
+
38
+ export const CssInput = z.object({
39
+ css: z.string().superRefine((v, ctx) => {
40
+ const p = cssProblem(v)
41
+ if (p) ctx.addIssue({ code: "custom", message: p })
42
+ }),
43
+ use_file: z.boolean(),
44
+ note: z.string().trim().max(120).optional(),
45
+ })
46
+ export type CssInput = z.infer<typeof CssInput>
47
+
48
+ export const CssRestoreInput = z.object({ id: z.string().regex(/^scss_[A-Za-z0-9]+$/) })
49
+ export type CssRestoreInput = z.infer<typeof CssRestoreInput>
@@ -0,0 +1,15 @@
1
+ import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
2
+ import { type SaveCssData, saveCssVersionStep } from "./steps/save-css-version"
3
+ import { type SaveSiteTextsData, saveSiteTextsStep } from "./steps/save-site-texts"
4
+
5
+ /** Admin → Personnaliser → Textes du site */
6
+ export const saveSiteTextsWorkflow = createWorkflow("save-site-texts", function (input: SaveSiteTextsData) {
7
+ const result = saveSiteTextsStep(input)
8
+ return new WorkflowResponse(result)
9
+ })
10
+
11
+ /** Admin → Personnaliser → Style : enregistrer, restaurer une version, revenir au thème */
12
+ export const saveCssWorkflow = createWorkflow("save-css-version", function (input: SaveCssData) {
13
+ const version = saveCssVersionStep(input)
14
+ return new WorkflowResponse(version)
15
+ })
@@ -0,0 +1,20 @@
1
+ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
2
+ import { CUSTOMIZATION_MODULE } from "../../modules/customization"
3
+ import type CustomizationModuleService from "../../modules/customization/service"
4
+
5
+ export type SaveCssData = { css: string; use_file: boolean; note: string | null; author: string | null }
6
+
7
+ /** Nouvelle version du style (la plus récente est appliquée) ; rollback : supprime la version créée. */
8
+ export const saveCssVersionStep = createStep(
9
+ "save-css-version",
10
+ async (input: SaveCssData, { container }) => {
11
+ const service: CustomizationModuleService = container.resolve(CUSTOMIZATION_MODULE)
12
+ const version = await service.createCssVersions(input)
13
+ return new StepResponse(version, version.id)
14
+ },
15
+ async (id, { container }) => {
16
+ if (!id) return
17
+ const service: CustomizationModuleService = container.resolve(CUSTOMIZATION_MODULE)
18
+ await service.deleteCssVersions(id)
19
+ }
20
+ )
@@ -0,0 +1,56 @@
1
+ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
2
+ import { CUSTOMIZATION_MODULE } from "../../modules/customization"
3
+ import type CustomizationModuleService from "../../modules/customization/service"
4
+
5
+ export type SaveSiteTextsData = { scope: string; values: Record<string, string | null> }
6
+ type Previous = { scope: string; key: string; value: string | null }[]
7
+
8
+ /**
9
+ * Enregistre des textes remplacés pour une portée (projet entier ou une boutique) : valeur = remplacer,
10
+ * null ou vide = revenir au texte d'origine. Rollback : remet les valeurs d'avant.
11
+ */
12
+ export const saveSiteTextsStep = createStep(
13
+ "save-site-texts",
14
+ async (input: SaveSiteTextsData, { container }) => {
15
+ const service: CustomizationModuleService = container.resolve(CUSTOMIZATION_MODULE)
16
+ const keys = Object.keys(input.values)
17
+ if (!keys.length) return new StepResponse({ changed: 0 }, [] as Previous)
18
+ const existing = await service.listSiteTexts({ scope: input.scope, key: keys })
19
+ const byKey = new Map(existing.map((e) => [e.key, e]))
20
+ const previous: Previous = keys.map((key) => ({ scope: input.scope, key, value: byKey.get(key)?.value ?? null }))
21
+
22
+ let changed = 0
23
+ for (const key of keys) {
24
+ const value = input.values[key]?.trim() ? input.values[key] : null
25
+ const row = byKey.get(key)
26
+ if (value === null) {
27
+ if (row) {
28
+ await service.deleteSiteTexts(row.id)
29
+ changed++
30
+ }
31
+ } else if (row) {
32
+ if (row.value !== value) {
33
+ await service.updateSiteTexts({ id: row.id, value })
34
+ changed++
35
+ }
36
+ } else {
37
+ await service.createSiteTexts({ scope: input.scope, key, value })
38
+ changed++
39
+ }
40
+ }
41
+ return new StepResponse({ changed }, previous)
42
+ },
43
+ async (previous, { container }) => {
44
+ if (!previous?.length) return
45
+ const service: CustomizationModuleService = container.resolve(CUSTOMIZATION_MODULE)
46
+ const scope = previous[0].scope
47
+ const current = await service.listSiteTexts({ scope, key: previous.map((p) => p.key) })
48
+ const byKey = new Map(current.map((c) => [c.key, c]))
49
+ for (const p of previous) {
50
+ const row = byKey.get(p.key)
51
+ if (p.value === null && row) await service.deleteSiteTexts(row.id)
52
+ else if (p.value !== null && row) await service.updateSiteTexts({ id: row.id, value: p.value })
53
+ else if (p.value !== null) await service.createSiteTexts({ scope, key: p.key, value: p.value })
54
+ }
55
+ }
56
+ )
@@ -29,6 +29,8 @@ COPY --from=build --chown=app:app /app/.next/static ./.next/static
29
29
  COPY --from=build --chown=app:app /app/public ./public
30
30
  COPY --from=build --chown=app:app /app/themes ./themes
31
31
  COPY --from=build --chown=app:app /app/locales ./locales
32
+ # Retouches du projet : lues à l'exécution (désactivables depuis l'admin → Personnaliser)
33
+ COPY --from=build --chown=app:app /app/src/styles/custom.css ./custom/custom.css
32
34
 
33
35
  USER app
34
36
  EXPOSE 8000
@@ -1,8 +1,19 @@
1
1
  # Personnaliser l'apparence d'un projet
2
2
 
3
- Tout se fait dans **`storefront/src/styles/custom.css`** (+ images et polices dans `storefront/public/custom/`).
4
- Ces deux emplacements sont **conservés par `create-boutique update`** ; le reste du code est remplacé à chaque mise à jour.
5
- Après une modification : `docker compose up -d --build`.
3
+ Deux endroits, qui se cumulent (le second passe après le premier) :
4
+
5
+ 1. **Admin → Personnaliser → Style (CSS)** : éditeur dans le navigateur, **sans reconstruire** (visible en ~1 minute),
6
+ avec historique des versions, « Annuler les modifications », « Restaurer » une version, « Revenir au CSS du thème ».
7
+ Idéal pour les retouches du client ou les essais.
8
+ 2. **`storefront/src/styles/custom.css`** (+ images et polices dans `storefront/public/custom/`) : retouches dans le code,
9
+ conservées par `create-boutique update` ; après une modification : `docker compose up -d --build`.
10
+ Affiché en lecture seule dans l'admin ; un interrupteur l'applique ou non (« Revenir au CSS du thème » le désactive aussi).
11
+
12
+ Les deux sont injectés à l'exécution après les feuilles du thème : pas besoin de `!important`. Refusé dans l'éditeur de
13
+ l'admin : `@import`, `url()` vers un autre site, balises HTML (sécurité) ; images : `/custom/…` ou `data:image/…`.
14
+
15
+ **Textes de l'interface** (boutons, panier, paiement, compte…) : **admin → Personnaliser → Textes du site**, pour toutes les
16
+ boutiques ou une seule, sans reconstruire. Les textes de campagne (titre du hero, bandeau, dates, visuels) : **admin → Mes boutiques**.
6
17
 
7
18
  Ce fichier-ci (`PERSONNALISER.md`) est remis à jour par `update` : il décrit toujours la version installée.
8
19
 
@@ -16,7 +27,7 @@ Ce fichier-ci (`PERSONNALISER.md`) est remis à jour par `update` : il décrit t
16
27
  | Image de fond, motif, décoration (`::before`, `::after`, `url("/custom/…")`) | Modifier un e-mail, une règle de prix, de livraison |
17
28
  | Cibler une seule boutique, un thème, une page, l'ordinateur ou le mobile | |
18
29
 
19
- Les **textes** (titres, sous-titres, bandeau, bouton, dates, visuels) se changent dans l'**admin → Mes boutiques**, pas ici.
30
+ Les **textes** se changent dans l'admin, pas ici : textes de campagne dans **Mes boutiques**, textes de l'interface dans **Personnaliser**.
20
31
  Un besoin de la colonne de droite : on le fait dans le modèle (dépôt create-boutique), puis `create-boutique update` sur les projets.
21
32
  Modifier directement un autre fichier du projet reste possible, mais `update` le remplacera (il le liste et le sauvegarde
22
33
  dans `backups/` avant).
@@ -36,7 +36,9 @@ import "styles/ub/responsive.css"
36
36
  import "styles/ub/fonts.css"
37
37
  import "styles/ub/medusa-ui.css"
38
38
  import "styles/ub/overrides.css"
39
- import "styles/custom.css" // retouches du projet, conservées par update (toujours en dernier)
39
+ // Retouches du projet : styles/custom.css (code) et le style de l'admin (Personnaliser), injectés à l'exécution
40
+ // en dernier dans <head> (voir lib/boutique/customization.ts) : modifiables ou désactivables sans reconstruire.
41
+ import { customCssFile, getCustomization, safeCss, shortHash } from "@lib/boutique/customization"
40
42
 
41
43
  // Tout dépend de la boutique de l'instance (lue à l'exécution) : rien n'est pré-rendu au build.
42
44
  export const dynamic = "force-dynamic"
@@ -70,12 +72,25 @@ export async function generateMetadata(): Promise<Metadata> {
70
72
  }
71
73
  }
72
74
 
73
- export default function RootLayout(props: { children: React.ReactNode }) {
75
+ export default async function RootLayout(props: { children: React.ReactNode }) {
74
76
  const b = getBoutique()
77
+ // Avant le rendu : textes remplacés disponibles pour t() et messages()
78
+ const custom = await getCustomization()
75
79
 
76
80
  return (
77
81
  <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-boutique={b.slug} data-mode="light" className={themeFontClasses(b.theme)}>
78
82
  <body className="ub">
83
+ {/* React place ces styles dans <head>, après les feuilles du thème (precedence découverte en dernier) */}
84
+ {custom.useFile && customCssFile() && (
85
+ <style href="ub-custom-file" precedence="ub-custom">
86
+ {safeCss(customCssFile())}
87
+ </style>
88
+ )}
89
+ {custom.css && (
90
+ <style href={`ub-custom-admin-${shortHash(custom.css)}`} precedence="ub-custom">
91
+ {safeCss(custom.css)}
92
+ </style>
93
+ )}
79
94
  <I18nProvider messages={messages("")} locale={b.locale}>
80
95
  <main className="relative">{props.children}</main>
81
96
  <Motion event={b.theme.id.replace(/-2$/, "")} />
@@ -0,0 +1,76 @@
1
+ import "server-only"
2
+
3
+ import fs from "node:fs"
4
+ import path from "node:path"
5
+ import { sdk } from "@lib/config"
6
+ import { getBoutique } from "./config"
7
+
8
+ /**
9
+ * Personnalisation faite dans l'admin (Personnaliser) : textes d'interface remplacés et style CSS, lus sur
10
+ * GET /store/customization (la boutique est déterminée par sa publishable key). Gardés 60 s en mémoire par boutique :
11
+ * une modification apparaît en une minute, sans reconstruire ni redémarrer.
12
+ * Le backend injoignable ne casse jamais le site : textes d'origine et fichier custom.css seulement.
13
+ */
14
+ export type Customization = { texts: Record<string, string>; css: string; useFile: boolean }
15
+
16
+ const TTL = 60_000
17
+ const EMPTY: Customization = { texts: {}, css: "", useFile: true }
18
+ const g = globalThis as unknown as { __ubCustom?: Map<string, { at: number; value: Customization; loading?: Promise<Customization> }> }
19
+ const store = (g.__ubCustom ??= new Map())
20
+
21
+ async function load(): Promise<Customization> {
22
+ try {
23
+ const r = await sdk.client.fetch<{ texts?: Record<string, string>; css?: string; use_file?: boolean }>("/store/customization", { method: "GET", cache: "no-store" })
24
+ return { texts: r.texts ?? {}, css: r.css ?? "", useFile: r.use_file !== false }
25
+ } catch {
26
+ return EMPTY
27
+ }
28
+ }
29
+
30
+ /** Personnalisation de la boutique affichée (rechargée au plus toutes les 60 s) */
31
+ export async function getCustomization(): Promise<Customization> {
32
+ const slug = getBoutique().slug
33
+ const entry = store.get(slug)
34
+ if (entry && Date.now() - entry.at < TTL) return entry.value
35
+ if (entry?.loading) return entry.loading
36
+ const loading = load().then((value) => {
37
+ store.set(slug, { at: Date.now(), value })
38
+ return value
39
+ })
40
+ store.set(slug, { at: entry?.at ?? 0, value: entry?.value ?? EMPTY, loading })
41
+ return loading
42
+ }
43
+
44
+ /** Textes remplacés connus pour la boutique affichée (synchrone : utilisé par t()) */
45
+ export function textOverrides(): Record<string, string> {
46
+ return store.get(getBoutique().slug)?.value.texts ?? {}
47
+ }
48
+
49
+ /**
50
+ * Fichier custom.css du projet (storefront/src/styles/custom.css) : copié dans l'image (custom/custom.css),
51
+ * lu à l'exécution pour pouvoir être désactivé depuis l'admin (« Revenir au CSS du thème »).
52
+ */
53
+ let fileCss: string | null = null
54
+ export function customCssFile(): string {
55
+ if (fileCss !== null) return fileCss
56
+ for (const file of [path.join(/*turbopackIgnore: true*/ process.cwd(), "custom", "custom.css"), path.join(/*turbopackIgnore: true*/ process.cwd(), "src", "styles", "custom.css")]) {
57
+ try {
58
+ fileCss = fs.readFileSync(file, "utf8")
59
+ return fileCss
60
+ } catch {
61
+ // fichier suivant
62
+ }
63
+ }
64
+ fileCss = ""
65
+ return fileCss
66
+ }
67
+
68
+ /** Texte CSS sûr dans une balise <style> : impossible d'en sortir (le backend refuse déjà ces séquences) */
69
+ export const safeCss = (css: string) => css.replace(/<\/?(style|script)/gi, "").replace(/<!--|-->/g, "")
70
+
71
+ /** Empreinte courte d'un texte (identifiant du <style> : un style modifié remplace l'ancien même en navigation) */
72
+ export function shortHash(text: string): string {
73
+ let h = 5381
74
+ for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
75
+ return (h >>> 0).toString(36)
76
+ }
@@ -3,6 +3,7 @@ import "server-only"
3
3
  import fs from "node:fs"
4
4
  import path from "node:path"
5
5
  import { getBoutique } from "./config"
6
+ import { textOverrides } from "./customization"
6
7
 
7
8
  type Dict = Record<string, string>
8
9
 
@@ -24,7 +25,8 @@ function dict(lang: string): Dict {
24
25
  */
25
26
  export function t(key: string, vars?: Record<string, string | number>): string {
26
27
  const { language } = getBoutique()
27
- const text = dict(language)[key] ?? dict("en")[key] ?? key
28
+ // Texte remplacé dans l'admin (Personnaliser → Textes du site), sinon celui de la langue, sinon l'anglais
29
+ const text = textOverrides()[key] ?? dict(language)[key] ?? dict("en")[key] ?? key
28
30
  return vars ? text.replace(/\{(\w+)\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m)) : text
29
31
  }
30
32
 
@@ -32,7 +34,7 @@ export function t(key: string, vars?: Record<string, string | number>): string {
32
34
  export function messages(prefix: string): Dict {
33
35
  const { language } = getBoutique()
34
36
  const out: Dict = {}
35
- for (const d of [dict("en"), dict(language)]) {
37
+ for (const d of [dict("en"), dict(language), textOverrides()]) {
36
38
  for (const [k, v] of Object.entries(d)) if (k.startsWith(prefix)) out[k] = v
37
39
  }
38
40
  return out
@@ -8,6 +8,8 @@
8
8
  * - Jamais remplacé par `create-boutique update` (le reste du code, si).
9
9
  * - Images ou polices utilisées ici : dans public/custom/ (conservé aussi), puis url("/custom/mon-image.png").
10
10
  * - Après modification : docker compose up -d --build
11
+ * - Sans reconstruire : admin → Personnaliser → Style (CSS), appliqué après ce fichier (historique, retour au thème).
12
+ * Ce fichier y est affiché en lecture seule ; « Revenir au CSS du thème » le désactive.
11
13
  *
12
14
  * CIBLER :
13
15
  * tout le projet ...................... directement (ex. .ub-tc-ticker { display: none; })