@salifbiaye/create-boutique 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +241 -17
  3. package/cli/dist/create.js +108 -15
  4. package/cli/dist/generate.js +108 -39
  5. package/cli/dist/guide.js +12 -4
  6. package/cli/dist/index.js +38 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/cli/dist/project.js +42 -3
  9. package/docs/fiche-client.en.html +2 -2
  10. package/docs/fiche-client.html +2 -2
  11. package/docs/guide-complet.fr.html +681 -256
  12. package/docs/guide-images/categories.jpg +0 -0
  13. package/docs/guide-images/clients.jpg +0 -0
  14. package/docs/guide-images/collections.jpg +0 -0
  15. package/docs/guide-images/commande-detail.jpg +0 -0
  16. package/docs/guide-images/commande-expedier.jpg +0 -0
  17. package/docs/guide-images/commande-menu.jpg +0 -0
  18. package/docs/guide-images/commande-paiement.jpg +0 -0
  19. package/docs/guide-images/commandes-liste.jpg +0 -0
  20. package/docs/guide-images/connexion.jpg +0 -0
  21. package/docs/guide-images/groupes-clients.jpg +0 -0
  22. package/docs/guide-images/inventaire.jpg +0 -0
  23. package/docs/guide-images/listes-prix.jpg +0 -0
  24. package/docs/guide-images/menu.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  27. package/docs/guide-images/mes-boutiques.jpg +0 -0
  28. package/docs/guide-images/parametres-boutique.jpg +0 -0
  29. package/docs/guide-images/parametres-canaux.jpg +0 -0
  30. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  31. package/docs/guide-images/parametres-profil.jpg +0 -0
  32. package/docs/guide-images/parametres-regions.jpg +0 -0
  33. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  34. package/docs/guide-images/produit-creer-details.jpg +0 -0
  35. package/docs/guide-images/produit-detail.jpg +0 -0
  36. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  37. package/docs/guide-images/produit-variantes.jpg +0 -0
  38. package/docs/guide-images/produits-liste.jpg +0 -0
  39. package/docs/guide-images/promotion-details.jpg +0 -0
  40. package/docs/guide-images/promotion-type.jpg +0 -0
  41. package/docs/guide-images/promotions-liste.jpg +0 -0
  42. package/docs/guide-images/site-boutique.jpg +0 -0
  43. package/docs/guide-images/site-fermee.png +0 -0
  44. package/docs/guide-images/vue-ensemble.jpg +0 -0
  45. package/package.json +3 -2
  46. package/templates/backend/medusa-config.ts +31 -25
  47. package/templates/backend/package-lock.json +21 -0
  48. package/templates/backend/package.json +2 -0
  49. package/templates/backend/src/admin/i18n/json/en.json +63 -5
  50. package/templates/backend/src/admin/i18n/json/fr.json +63 -5
  51. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
  52. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
  53. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  54. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  55. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  56. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
  57. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  58. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  59. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  60. package/templates/backend/src/api/middlewares.ts +9 -0
  61. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  62. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  63. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  64. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  65. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  66. package/templates/backend/src/emails/en.json +3 -1
  67. package/templates/backend/src/emails/fr.json +3 -1
  68. package/templates/backend/src/lib/active.ts +41 -0
  69. package/templates/backend/src/lib/email-config.ts +14 -0
  70. package/templates/backend/src/lib/emails.ts +9 -0
  71. package/templates/backend/src/lib/notify.ts +14 -8
  72. package/templates/backend/src/lib/payment-config.ts +66 -0
  73. package/templates/backend/src/lib/project.ts +38 -1
  74. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  75. package/templates/backend/src/lib/review-token.ts +40 -0
  76. package/templates/backend/src/lib/reviews.ts +25 -0
  77. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  78. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  79. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  80. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  81. package/templates/backend/src/modules/paytech/index.ts +6 -0
  82. package/templates/backend/src/modules/paytech/service.ts +114 -0
  83. package/templates/backend/src/modules/product-review/index.ts +8 -0
  84. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  85. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  86. package/templates/backend/src/modules/product-review/service.ts +6 -0
  87. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  88. package/templates/backend/src/modules/smtp/index.ts +6 -0
  89. package/templates/backend/src/modules/smtp/service.ts +73 -0
  90. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  91. package/templates/backend/src/scripts/seed.ts +60 -36
  92. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  93. package/templates/backend/src/workflows/reviews.ts +21 -0
  94. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  95. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  96. package/templates/storefront/PERSONNALISER.md +134 -0
  97. package/templates/storefront/locales/en.json +198 -11
  98. package/templates/storefront/locales/fr.json +198 -11
  99. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  100. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  101. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  102. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  103. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  104. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  105. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  106. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  107. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  108. package/templates/storefront/src/app/layout.tsx +13 -1
  109. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  110. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  111. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  112. package/templates/storefront/src/lib/boutique/schema.ts +7 -2
  113. package/templates/storefront/src/lib/config.ts +37 -8
  114. package/templates/storefront/src/lib/constants.tsx +17 -0
  115. package/templates/storefront/src/lib/data/cart.ts +4 -1
  116. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  117. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  118. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  119. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  120. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  121. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  122. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  123. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  124. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  125. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  126. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  127. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  128. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  129. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  130. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  131. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  132. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  133. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  134. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  135. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  136. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  137. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  138. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  139. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  140. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  141. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  142. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  143. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  144. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  145. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  146. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  147. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  148. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  149. package/templates/storefront/src/proxy.ts +4 -1
  150. package/templates/storefront/src/styles/custom.css +26 -0
  151. package/templates/storefront/src/styles/ub/album.css +1 -1
  152. package/templates/storefront/src/styles/ub/arena.css +120 -0
  153. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  154. package/templates/storefront/src/styles/ub/gala.css +1 -1
  155. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  156. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  157. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  158. package/templates/storefront/src/styles/ub/locker.css +115 -0
  159. package/templates/storefront/src/styles/ub/media.css +10 -0
  160. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  161. package/templates/storefront/src/styles/ub/motion.css +0 -2
  162. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  163. package/templates/storefront/src/styles/ub/pages.css +176 -0
  164. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  165. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  166. package/templates/storefront/src/styles/ub/souk.css +144 -0
  167. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  168. package/templates/storefront/src/styles/ub/tech.css +5 -8
  169. package/templates/storefront/src/styles/ub/training.css +120 -0
  170. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  171. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  172. package/templates/storefront/themes/jeux/theme.json +11 -0
  173. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  174. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  175. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  176. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  177. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  178. package/templates/storefront/themes/mubarak/theme.json +11 -0
  179. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  180. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  181. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  182. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  183. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  184. package/templates/storefront/themes/sport/theme.json +11 -0
  185. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  186. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  187. package/templates/storefront/themes/sport-2/theme.json +11 -0
  188. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  189. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  190. package/templates/storefront/themes/summer/theme.json +11 -0
  191. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  192. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  193. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -0,0 +1,110 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { shouldCopy } from "./paths.js";
5
+ /**
6
+ * Code généré d'un projet (storefront/, backend/) :
7
+ * - fichiers conservés par `update` (retouches du projet) ;
8
+ * - empreinte de chaque fichier à la génération, pour détecter une modification faite à la main
9
+ * avant qu'`update` ne remplace le code (rien n'est plus jamais perdu sans prévenir).
10
+ */
11
+ /** Chemins (relatifs au projet) que `update` ne remplace jamais. */
12
+ export const KEEP = ["storefront/src/styles/custom.css", "storefront/public/custom"];
13
+ const PARTS = ["storefront", "backend"];
14
+ const MANIFEST = (dir) => path.join(dir, ".create-boutique", "code.json");
15
+ const kept = (rel) => KEEP.some((k) => rel === k || rel.startsWith(`${k}/`));
16
+ function walk(dir, root, out) {
17
+ if (!fs.existsSync(dir))
18
+ return;
19
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
20
+ if (!shouldCopy(e.name))
21
+ continue;
22
+ const abs = path.join(dir, e.name);
23
+ const rel = path.relative(root, abs).split(path.sep).join("/");
24
+ if (kept(rel))
25
+ continue;
26
+ if (e.isDirectory())
27
+ walk(abs, root, out);
28
+ else
29
+ out.set(rel, crypto.createHash("sha256").update(fs.readFileSync(abs)).digest("hex"));
30
+ }
31
+ }
32
+ function hashes(target) {
33
+ const out = new Map();
34
+ for (const part of PARTS)
35
+ walk(path.join(target, part), target, out);
36
+ return out;
37
+ }
38
+ /** Enregistre l'empreinte du code tel que généré (après create et update). */
39
+ export function writeFingerprint(target, version) {
40
+ fs.mkdirSync(path.dirname(MANIFEST(target)), { recursive: true });
41
+ fs.writeFileSync(MANIFEST(target), JSON.stringify({ version, files: Object.fromEntries(hashes(target)) }, null, 2) + "\n");
42
+ }
43
+ /** Fichiers modifiés, ajoutés ou supprimés à la main depuis la génération (known=false : projet sans empreinte). */
44
+ export function localChanges(target) {
45
+ if (!fs.existsSync(MANIFEST(target)))
46
+ return { known: false, modified: [], added: [], deleted: [] };
47
+ const before = new Map(Object.entries(JSON.parse(fs.readFileSync(MANIFEST(target), "utf8")).files));
48
+ const now = hashes(target);
49
+ const modified = [...now].filter(([f, h]) => before.has(f) && before.get(f) !== h).map(([f]) => f);
50
+ const added = [...now.keys()].filter((f) => !before.has(f));
51
+ const deleted = [...before.keys()].filter((f) => !now.has(f));
52
+ return { known: true, modified, added, deleted };
53
+ }
54
+ /** Copie des fichiers (chemins relatifs au projet) dans un dossier de sauvegarde, arborescence conservée. */
55
+ export function copyFiles(target, files, to) {
56
+ for (const rel of files) {
57
+ const src = path.join(target, rel);
58
+ if (!fs.existsSync(src))
59
+ continue;
60
+ const dst = path.join(to, rel);
61
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
62
+ fs.copyFileSync(src, dst);
63
+ }
64
+ }
65
+ /** Tous les fichiers du code (projet sans empreinte : on sauvegarde tout, par précaution). */
66
+ export const allCodeFiles = (target) => [...hashes(target).keys()];
67
+ /** Met de côté les chemins conservés, pour les remettre après le remplacement du code. */
68
+ export function stashKept(target, tmp) {
69
+ const saved = [];
70
+ for (const rel of KEEP) {
71
+ const src = path.join(target, rel);
72
+ if (!fs.existsSync(src))
73
+ continue;
74
+ fs.cpSync(src, path.join(tmp, rel), { recursive: true });
75
+ saved.push(rel);
76
+ }
77
+ return saved;
78
+ }
79
+ export function restoreKept(target, tmp, saved) {
80
+ for (const rel of saved) {
81
+ const dst = path.join(target, rel);
82
+ fs.rmSync(dst, { recursive: true, force: true });
83
+ fs.cpSync(path.join(tmp, rel), dst, { recursive: true });
84
+ }
85
+ fs.rmSync(tmp, { recursive: true, force: true });
86
+ }
87
+ /**
88
+ * custom.css : une section prête à remplir par boutique du projet (ajoutée seulement si elle manque :
89
+ * ce que le développeur a écrit n'est jamais modifié). Appelé à la création et à chaque apply (ex. add).
90
+ */
91
+ export function syncCustomCss(target, boutiques) {
92
+ const file = path.join(target, KEEP[0]);
93
+ if (!fs.existsSync(file))
94
+ return;
95
+ let css = fs.readFileSync(file, "utf8");
96
+ // Projets créés avant le mode d'emploi : une ligne de renvoi en tête (le reste du fichier n'est pas touché)
97
+ const pointer = "/* Mode d'emploi complet (plan du site, classes, [data-page], exemples) : storefront/PERSONNALISER.md */\n";
98
+ const withPointer = css.includes("PERSONNALISER.md") ? css : pointer + css;
99
+ const missing = boutiques.filter((b) => !css.includes(`[data-boutique="${b.slug}"]`));
100
+ if (!missing.length) {
101
+ if (withPointer !== css)
102
+ fs.writeFileSync(file, withPointer);
103
+ return;
104
+ }
105
+ css = withPointer;
106
+ for (const b of missing) {
107
+ css += `\n/* ─────────── Boutique ${b.slug} · thème ${b.theme} · ${b.url} ─────────── */\n[data-boutique="${b.slug}"] {\n /* ex. --primary: #4a2bd6; */\n}\n`;
108
+ }
109
+ fs.writeFileSync(file, css);
110
+ }
@@ -5,8 +5,10 @@ import pc from "picocolors";
5
5
  import { compose, composeOutput, isRunning } from "./docker.js";
6
6
  import { readEnv, refreshEnv, writeCampaignFiles, writeCompose, writeReadme } from "./generate.js";
7
7
  import { copyTemplate, templateVersion } from "./paths.js";
8
+ import { KEEP, allCodeFiles, copyFiles, syncCustomCss, localChanges, restoreKept, stashKept, writeFingerprint } from "./code.js";
9
+ import * as prompts from "@clack/prompts";
8
10
  import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
9
- import { currencies, effective, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG } from "./project.js";
11
+ import { boutiqueUrl, currencies, effective, normalizeUrl, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG, storePorts } from "./project.js";
10
12
  import { listThemes } from "./themes.js";
11
13
  import { writeGuides } from "./guide.js";
12
14
  const STATE = (dir) => path.join(dir, ".create-boutique-state.json");
@@ -59,15 +61,16 @@ export async function apply(dir, opts = { up: true }) {
59
61
  const p = readProject(target); // lève une ProjectError détaillée si invalide
60
62
  checkCsvCurrencies(target, p); // avant toute écriture
61
63
  const others = reservedByOthers(target);
62
- const clash = [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)].filter((port) => others.has(port));
64
+ const clash = [p.apiPort, p.storagePort, ...storePorts(p)].filter((port) => others.has(port));
63
65
  if (clash.length)
64
66
  throw new ProjectError(`port(s) déjà réservé(s) par un autre projet : ${clash.join(", ")}`);
65
67
  writeCampaignFiles(target, p, p.defaults.language);
68
+ syncCustomCss(target, p.boutiques.map((b) => ({ slug: b.slug, theme: b.theme, url: boutiqueUrl(p, b) })));
66
69
  writeJsonSchema(target);
67
70
  writeCompose(target, p);
68
71
  refreshEnv(target, p);
69
72
  writeReadme(target, p);
70
- reservePorts(target, [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
73
+ reservePorts(target, [p.apiPort, p.storagePort, ...storePorts(p)]);
71
74
  const previous = fs.existsSync(STATE(target)) ? JSON.parse(fs.readFileSync(STATE(target), "utf8")).hash : null;
72
75
  const changed = previous !== hash(p);
73
76
  console.log(pc.green("✔ project.json valide, docker-compose.yml et .env régénérés"));
@@ -84,18 +87,77 @@ export async function apply(dir, opts = { up: true }) {
84
87
  fs.writeFileSync(STATE(target), JSON.stringify({ hash: hash(p), appliedAt: new Date().toISOString() }, null, 2) + "\n");
85
88
  priceWarnings(target);
86
89
  }
90
+ // ─────────────────────────── urls ───────────────────────────
91
+ /** Préfixe des images en base : <domaine>/medusa (le bucket). Accepte le domaine seul ou déjà suffixé. */
92
+ const filesPrefix = (v) => {
93
+ const t = v.trim().replace(/\/+$/, "");
94
+ return /\/medusa$/.test(t) ? t : `${t}/medusa`;
95
+ };
96
+ /**
97
+ * Change les domaines publics (admin/API, images, site en mode single), régénère .env / compose / Caddyfile.exemple,
98
+ * puis réécrit l'adresse des images déjà en base : ancienne adresse (S3_FILE_URL avant le changement,
99
+ * ou --from) → nouvelle. Relançable sans risque (ne touche que ce qui commence par l'ancienne adresse).
100
+ */
101
+ export async function urls(dir, o, opts) {
102
+ const target = path.resolve(dir);
103
+ const p = readProject(target);
104
+ const before = readEnv(target).S3_FILE_URL;
105
+ if (o.api || o.files) {
106
+ const api = o.api ? normalizeUrl(o.api, "--api-url") : p.urls?.api;
107
+ const files = o.files ? normalizeUrl(o.files, "--files-url") : p.urls?.files;
108
+ if (!api || !files)
109
+ throw new ProjectError("première configuration : --api-url et --files-url ensemble (domaine de l'admin et domaine des images)");
110
+ p.urls = { api, files };
111
+ }
112
+ if (o.site) {
113
+ if (p.mode !== "single")
114
+ throw new ProjectError("--site-url concerne le mode single ; en mode multi, mettez url sur chaque boutique dans config/project.json");
115
+ p.site = { ...p.site, url: normalizeUrl(o.site, "--site-url") };
116
+ }
117
+ if (!o.api && !o.files && !o.site && !o.from)
118
+ throw new ProjectError("rien à changer : --api-url, --files-url, --site-url ou --from");
119
+ writeProject(target, validateProject(p));
120
+ await apply(target, opts);
121
+ const after = readEnv(target).S3_FILE_URL;
122
+ console.log(`Images : ${after}`);
123
+ // Images déjà importées : réécriture de l'ancienne adresse
124
+ const from = o.from ? filesPrefix(normalizeFrom(o.from)) : before;
125
+ if (!from || from === after)
126
+ return;
127
+ if (!opts.up) {
128
+ console.log(pc.yellow(`! --no-up : images non réécrites. Après le démarrage : create-boutique urls . --from ${from}`));
129
+ return;
130
+ }
131
+ const backend = `http://localhost:${p.apiPort}`;
132
+ for (let i = 0; i < 60 && !(await fetch(`${backend}/health`).then((r) => r.ok).catch(() => false)); i++)
133
+ await new Promise((r) => setTimeout(r, 5000));
134
+ const r = composeOutput(target, ["exec", "-T", "-e", `FILES_FROM=${from}`, "-e", `FILES_TO=${after}`, "backend", "npx", "medusa", "exec", "./src/scripts/rewrite-file-urls.js"]);
135
+ const m = r.stdout.match(/<<<URLS(.*)URLS>>>/);
136
+ if (r.code !== 0 || !m)
137
+ throw new Error(`réécriture des images échouée :\n${r.stderr.slice(-800)}`);
138
+ const n = JSON.parse(m[1]);
139
+ console.log(pc.green(`✔ images réécrites : ${from} → ${after} (${n.products} produit(s), ${n.campaigns} campagne(s))`));
140
+ }
141
+ /** --from accepte aussi « localhost:9100 » : on complète le schéma. */
142
+ const normalizeFrom = (v) => (/^https?:\/\//.test(v.trim()) ? v.trim() : `http://${v.trim()}`);
87
143
  // ─────────────────────────── add / remove / set ───────────────────────────
88
144
  export async function add(dir, specs, opts) {
89
145
  if (!specs.length)
90
146
  throw new ProjectError("--boutique <slug>:<thème> obligatoire");
91
147
  const target = path.resolve(dir);
92
148
  const p = readProject(target);
93
- const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
94
- let from = Math.max(...p.boutiques.map((b) => b.port)) + 1;
149
+ const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...storePorts(p)]);
150
+ let from = Math.max(8000, ...storePorts(p)) + 1;
95
151
  for (const spec of specs) {
96
152
  const [slug, theme] = spec.split(":");
97
153
  if (!slug || !theme || !SLUG.test(slug))
98
154
  throw new ProjectError(`format attendu : --boutique <slug>:<thème> (reçu « ${spec} »)`);
155
+ if (p.mode === "single") {
156
+ // Mode single : pas de port, la boutique s'active dans admin → Campagnes
157
+ p.boutiques.push({ slug, theme });
158
+ console.log(`+ ${slug} (${theme}) : à mettre en ligne dans admin → Campagnes`);
159
+ continue;
160
+ }
99
161
  const port = await nextFreePort(from, taken);
100
162
  taken.add(port);
101
163
  from = port + 1;
@@ -119,6 +181,35 @@ export async function remove(dir, slugs, opts) {
119
181
  console.log(`- ${slugs.join(", ")} retirée(s). Ses commandes et son sales channel restent dans l'admin.`);
120
182
  await apply(target, opts);
121
183
  }
184
+ /**
185
+ * multi → single : un seul site, sur le port de la première boutique ; les boutiques perdent leur port et leur domaine.
186
+ * single → multi : chaque boutique reçoit un port libre à partir de celui du site.
187
+ */
188
+ async function switchMode(target, p, mode) {
189
+ if (mode === "single") {
190
+ const first = p.boutiques.find((b) => b.port);
191
+ p.site = { port: first?.port ?? 8001, ...(first?.url ? { url: first.url } : {}) };
192
+ for (const b of p.boutiques) {
193
+ delete b.port;
194
+ delete b.url;
195
+ }
196
+ console.log(`mode single : un seul site sur le port ${p.site.port}${p.site.url ? ` (${p.site.url})` : ""}, boutique en ligne choisie dans admin → Campagnes`);
197
+ }
198
+ else {
199
+ const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort]);
200
+ let from = p.site?.port ?? 8001;
201
+ for (const b of p.boutiques) {
202
+ const port = await nextFreePort(from, taken);
203
+ taken.add(port);
204
+ from = port + 1;
205
+ b.port = port;
206
+ console.log(`${b.slug} → http://localhost:${port}`);
207
+ }
208
+ delete p.site;
209
+ console.log("mode multi : toutes les boutiques sont en ligne, chacune sur son port");
210
+ }
211
+ p.mode = mode;
212
+ }
122
213
  export async function set(dir, o, opts) {
123
214
  const target = path.resolve(dir);
124
215
  const p = readProject(target);
@@ -140,6 +231,8 @@ export async function set(dir, o, opts) {
140
231
  else {
141
232
  if (o.theme)
142
233
  throw new ProjectError("--theme s'applique à une boutique : ajoutez --boutique <slug>");
234
+ if (o.mode && o.mode !== p.mode)
235
+ await switchMode(target, p, o.mode);
143
236
  if (o.name)
144
237
  p.defaults.storeName = o.name;
145
238
  if (o.currency)
@@ -158,29 +251,90 @@ export function themes() {
158
251
  console.log(`${t.id.padEnd(16)} ${t.name.padEnd(26)} langues : ${t.languages.join(", ")}`);
159
252
  }
160
253
  // ─────────────────────────── update ───────────────────────────
161
- /** Remplace storefront/ et backend/ par la version actuelle du template (config, seed, .env et données conservés). */
254
+ /**
255
+ * Remplace storefront/ et backend/ par la version actuelle du template (config, seed, .env et données conservés).
256
+ * Conservés aussi : les retouches du projet (KEEP : custom.css, public/custom/).
257
+ * Code modifié à la main depuis la génération : listé, copié dans backups/<date>-code/, et confirmation demandée.
258
+ */
162
259
  export async function update(dir, opts) {
163
260
  const target = path.resolve(dir);
164
261
  const p = readProject(target);
165
262
  const from = p.templateVersion ?? "?";
166
263
  const to = templateVersion();
264
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
265
+ const saveDir = path.join(target, "backups", `${stamp}-code`);
266
+ // Sauvegarde des données avant de changer le code (les migrations d'une nouvelle version touchent la base)
267
+ if (isRunning(target, "postgres"))
268
+ backup(target, { label: "avant-update" });
269
+ else
270
+ console.log(pc.yellow("! base arrêtée : pas de sauvegarde automatique avant la mise à jour"));
271
+ const changes = localChanges(target);
272
+ if (!changes.known) {
273
+ // Projet généré avant les empreintes : impossible de savoir ce qui a été modifié, on garde une copie de tout le code
274
+ copyFiles(target, allCodeFiles(target), saveDir);
275
+ console.log(pc.yellow(`! projet sans empreinte du code : copie complète de storefront/ et backend/ dans ${path.relative(target, saveDir)}/ (à comparer si tu avais modifié le code)`));
276
+ }
277
+ else {
278
+ const touched = [...changes.modified, ...changes.added];
279
+ if (touched.length || changes.deleted.length) {
280
+ const list = [
281
+ ...changes.modified.map((f) => ` modifié ${f}`),
282
+ ...changes.added.map((f) => ` ajouté ${f}`),
283
+ ...changes.deleted.map((f) => ` supprimé ${f}`),
284
+ ];
285
+ console.log(pc.yellow(`\n! Code modifié à la main depuis la génération (${list.length} fichier(s)) : update va le remplacer.`));
286
+ console.log(list.slice(0, 40).join("\n") + (list.length > 40 ? `\n … et ${list.length - 40} autre(s)` : ""));
287
+ console.log(` Pour garder une retouche après les mises à jour : la mettre dans ${KEEP.join(" ou ")}.\n`);
288
+ if (!opts.yes) {
289
+ if (!process.stdin.isTTY)
290
+ throw new ProjectError("code modifié à la main : relancez avec --yes pour mettre à jour quand même (une copie sera faite dans backups/)");
291
+ const ok = await prompts.confirm({ message: `Mettre à jour quand même ? (copie de ces fichiers dans backups/${stamp}-code/)`, initialValue: false });
292
+ if (prompts.isCancel(ok) || !ok)
293
+ throw new ProjectError("mise à jour annulée : rien n'a été modifié");
294
+ }
295
+ copyFiles(target, touched, saveDir);
296
+ console.log(pc.green(`✔ fichiers modifiés copiés dans ${path.relative(target, saveDir)}/`));
297
+ }
298
+ }
299
+ const tmp = path.join(target, ".create-boutique", "keep-tmp");
300
+ const saved = stashKept(target, tmp);
167
301
  for (const part of ["storefront", "backend"]) {
168
302
  fs.rmSync(path.join(target, part), { recursive: true, force: true });
169
303
  copyTemplate(part, target);
170
304
  }
305
+ restoreKept(target, tmp, saved);
306
+ writeFingerprint(target, to);
171
307
  p.templateVersion = to;
172
308
  writeProject(target, p);
173
- console.log(pc.green(`✔ code mis à jour : template ${from} → ${to}`));
309
+ console.log(pc.green(`✔ code mis à jour : template ${from} → ${to}${saved.length ? ` (conservés : ${saved.join(", ")})` : ""}`));
174
310
  await apply(target, { up: false });
175
311
  if (opts.up && compose(target, ["up", "-d", "--build", "--remove-orphans"]) !== 0)
176
312
  throw new Error("docker compose up --build a échoué");
177
313
  }
178
- // ─────────────────────────── backup ───────────────────────────
179
- export function backup(dir) {
314
+ // ─────────────────────────── backup / restore ───────────────────────────
315
+ /** Sauvegardes de données (dossiers backups/<date>[-libellé]/ avec database.sql), plus récentes en dernier. */
316
+ function dataBackups(target) {
317
+ const root = path.join(target, "backups");
318
+ if (!fs.existsSync(root))
319
+ return [];
320
+ return fs
321
+ .readdirSync(root)
322
+ .filter((n) => fs.existsSync(path.join(root, n, "database.sql")))
323
+ .sort();
324
+ }
325
+ /**
326
+ * Sauvegarde des données : base (database.sql, pg_dump) + images (storage/) dans backups/<date>[-libellé]/.
327
+ * --keep N : ne garde que les N sauvegardes les plus récentes (les copies de code « -code » ne sont jamais supprimées).
328
+ * Lancée aussi automatiquement avant `update` et avant `restore`.
329
+ */
330
+ export function backup(dir, opts = {}) {
180
331
  const target = path.resolve(dir);
181
- readProject(target);
332
+ const p = readProject(target);
333
+ if (!isRunning(target, "postgres"))
334
+ throw new ProjectError("la base n'est pas démarrée : docker compose up -d, puis relancez la sauvegarde");
182
335
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
183
- const out = path.join(target, "backups", stamp);
336
+ const name = opts.label ? `${stamp}-${opts.label}` : stamp;
337
+ const out = path.join(target, "backups", name);
184
338
  fs.mkdirSync(out, { recursive: true });
185
339
  const dump = composeOutput(target, ["exec", "-T", "postgres", "pg_dump", "-U", "medusa", "--clean", "--if-exists", "medusa"]);
186
340
  if (dump.code !== 0)
@@ -188,8 +342,69 @@ export function backup(dir) {
188
342
  fs.writeFileSync(path.join(out, "database.sql"), dump.stdout);
189
343
  if (compose(target, ["cp", "storage:/data", path.join(out, "storage")]) !== 0)
190
344
  throw new Error("copie des images échouée");
191
- console.log(pc.green(`✔ sauvegarde : ${out}`));
192
- console.log("Restauration base : docker compose exec -T postgres psql -U medusa medusa < backups/<date>/database.sql");
345
+ fs.writeFileSync(path.join(out, "info.json"), JSON.stringify({ date: new Date().toISOString(), project: p.project, templateVersion: p.templateVersion, label: opts.label ?? null }, null, 2) + "\n");
346
+ console.log(pc.green(`✔ sauvegarde : backups/${name}/ (base + images)`));
347
+ if (opts.keep && opts.keep > 0) {
348
+ const all = dataBackups(target);
349
+ for (const old of all.slice(0, Math.max(0, all.length - opts.keep))) {
350
+ fs.rmSync(path.join(target, "backups", old), { recursive: true, force: true });
351
+ console.log(` ancienne sauvegarde supprimée : ${old}`);
352
+ }
353
+ }
354
+ return name;
355
+ }
356
+ /**
357
+ * Remet la base et les images d'une sauvegarde (la plus récente par défaut, ou --from <nom>).
358
+ * Fait d'abord une sauvegarde de l'état actuel (« avant-restauration ») : une restauration se défait toujours.
359
+ */
360
+ export async function restore(dir, opts) {
361
+ const target = path.resolve(dir);
362
+ readProject(target);
363
+ const all = dataBackups(target);
364
+ if (!all.length)
365
+ throw new ProjectError("aucune sauvegarde dans backups/ (create-boutique backup .)");
366
+ const name = opts.from ? all.find((n) => n === opts.from || n.startsWith(opts.from)) : all[all.length - 1];
367
+ if (!name)
368
+ throw new ProjectError(`sauvegarde introuvable : ${opts.from}. Disponibles :\n ${all.join("\n ")}`);
369
+ const src = path.join(target, "backups", name);
370
+ console.log(`Sauvegarde à remettre : backups/${name}/`);
371
+ console.log(pc.yellow("Tout ce qui a été fait depuis (commandes, produits, campagnes, images) sera remplacé."));
372
+ if (!opts.yes) {
373
+ if (!process.stdin.isTTY)
374
+ throw new ProjectError("restauration : relancez avec --yes pour confirmer");
375
+ const ok = await prompts.confirm({ message: "Restaurer cette sauvegarde ?", initialValue: false });
376
+ if (prompts.isCancel(ok) || !ok)
377
+ throw new ProjectError("restauration annulée : rien n'a été modifié");
378
+ }
379
+ // 1. filet de sécurité : l'état actuel
380
+ backup(target, { label: "avant-restauration" });
381
+ // 2. plus aucune écriture pendant la restauration
382
+ compose(target, ["stop", "backend"]);
383
+ // 3. base
384
+ const sql = fs.readFileSync(path.join(src, "database.sql"), "utf8");
385
+ const r = composeOutput(target, ["exec", "-T", "postgres", "psql", "-q", "-v", "ON_ERROR_STOP=1", "-U", "medusa", "medusa"], sql);
386
+ if (r.code !== 0)
387
+ throw new Error(`restauration de la base échouée (le backend reste arrêté ; la sauvegarde « avant-restauration » permet de revenir) :\n${r.stderr.slice(-800)}`);
388
+ console.log(pc.green("✔ base restaurée"));
389
+ // 4. images : copie, puis propriétaire remis à l'utilisateur du stockage (sinon « File access denied »)
390
+ if (fs.existsSync(path.join(src, "storage"))) {
391
+ if (compose(target, ["cp", `backups/${name}/storage/.`, "storage:/data"]) !== 0)
392
+ throw new Error("restauration des images échouée");
393
+ const own = composeOutput(target, ["exec", "-T", "-u", "root", "storage", "sh", "-c", "chown -R \"$(stat -c %u:%g /data)\" /data"]);
394
+ if (own.code !== 0)
395
+ throw new Error(`droits des images non rétablis : ${own.stderr}`);
396
+ compose(target, ["restart", "storage"]);
397
+ console.log(pc.green("✔ images restaurées"));
398
+ }
399
+ // 5. relance
400
+ compose(target, ["up", "-d"]);
401
+ // Attendre que Docker déclare le backend prêt (migrations + démarrage : jusqu'à quelques minutes)
402
+ const healthy = () => composeOutput(target, ["ps", "backend", "--format", "{{.Status}}"]).stdout.includes("(healthy)");
403
+ for (let i = 0; i < 60 && !healthy(); i++)
404
+ await new Promise((res) => setTimeout(res, 5000));
405
+ if (!healthy())
406
+ console.log(pc.yellow("! le backend n'est pas encore prêt : docker compose logs -f backend"));
407
+ console.log(pc.green(`✔ restauration terminée (${name}). Pour revenir en arrière : create-boutique restore . --from ${dataBackups(target).filter((n) => n.endsWith("avant-restauration")).pop()}`));
193
408
  }
194
409
  // ─────────────────────────── campaign export / import ───────────────────────────
195
410
  export function campaignExport(dir) {
@@ -248,9 +463,10 @@ export async function check(dir) {
248
463
  const e = effective(p, b);
249
464
  const steps = [];
250
465
  try {
251
- const url = b.url ?? `http://localhost:${b.port}`;
466
+ const url = boutiqueUrl(p, b);
252
467
  const html = await fetch(url).then((r) => (r.ok ? r.text() : Promise.reject(new Error(`boutique HTTP ${r.status}`))));
253
- if (!html.includes(`data-theme="${b.theme}"`))
468
+ // Mode single : le site n'affiche que la boutique active
469
+ if (p.mode !== "single" && !html.includes(`data-theme="${b.theme}"`))
254
470
  throw new Error(`thème ${b.theme} absent de la page`);
255
471
  steps.push("page");
256
472
  const key = composeOutput(target, ["exec", "-T", "backend", "cat", `/app/keys/${b.slug}`]).stdout.trim();
@@ -301,8 +517,16 @@ export async function check(dir) {
301
517
  console.log(pc.yellow("! Stripe non configuré : paiement manuel uniquement (STRIPE_API_KEY dans .env)"));
302
518
  else if (!env.STRIPE_WEBHOOK_SECRET)
303
519
  console.log(pc.yellow("! STRIPE_WEBHOOK_SECRET vide : webhook Stripe à créer (voir README)"));
304
- if (!env.RESEND_API_KEY)
305
- console.log(pc.yellow("! RESEND_API_KEY vide : aucun e-mail n'est envoyé aux clients"));
520
+ if (!env.RESEND_API_KEY && !(env.SMTP_HOST && env.SMTP_USER && env.SMTP_PASS)) {
521
+ console.log(pc.yellow("! Ni Resend ni SMTP configuré : aucun e-mail n'est envoyé aux clients (voir README)"));
522
+ }
523
+ if (env.NABOOPAY_API_KEY && !env.NABOOPAY_WEBHOOK_SECRET) {
524
+ console.log(pc.yellow("! NABOOPAY_WEBHOOK_SECRET vide : les notifications NabooPay sont ignorées (voir README)"));
525
+ }
526
+ if (env.PAYTECH_API_KEY && env.PAYTECH_ENV !== "prod")
527
+ console.log(pc.yellow("! PayTech en mode test (PAYTECH_ENV=test)"));
528
+ if (env.PAYDUNYA_MASTER_KEY && env.PAYDUNYA_ENV !== "prod")
529
+ console.log(pc.yellow("! PayDunya en mode sandbox (PAYDUNYA_ENV=test)"));
306
530
  console.log(pc.dim(" Les commandes de test (check@example.com) peuvent être annulées dans l'admin."));
307
531
  if (failed)
308
532
  throw new Error(`${failed} boutique(s) en échec`);