@salifbiaye/create-boutique 0.6.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 (182) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +143 -10
  3. package/cli/dist/create.js +54 -7
  4. package/cli/dist/generate.js +65 -4
  5. package/cli/dist/guide.js +10 -2
  6. package/cli/dist/index.js +20 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/docs/fiche-client.en.html +2 -2
  9. package/docs/fiche-client.html +2 -2
  10. package/docs/guide-complet.fr.html +681 -256
  11. package/docs/guide-images/categories.jpg +0 -0
  12. package/docs/guide-images/clients.jpg +0 -0
  13. package/docs/guide-images/collections.jpg +0 -0
  14. package/docs/guide-images/commande-detail.jpg +0 -0
  15. package/docs/guide-images/commande-expedier.jpg +0 -0
  16. package/docs/guide-images/commande-menu.jpg +0 -0
  17. package/docs/guide-images/commande-paiement.jpg +0 -0
  18. package/docs/guide-images/commandes-liste.jpg +0 -0
  19. package/docs/guide-images/connexion.jpg +0 -0
  20. package/docs/guide-images/groupes-clients.jpg +0 -0
  21. package/docs/guide-images/inventaire.jpg +0 -0
  22. package/docs/guide-images/listes-prix.jpg +0 -0
  23. package/docs/guide-images/menu.jpg +0 -0
  24. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques.jpg +0 -0
  27. package/docs/guide-images/parametres-boutique.jpg +0 -0
  28. package/docs/guide-images/parametres-canaux.jpg +0 -0
  29. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  30. package/docs/guide-images/parametres-profil.jpg +0 -0
  31. package/docs/guide-images/parametres-regions.jpg +0 -0
  32. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  33. package/docs/guide-images/produit-creer-details.jpg +0 -0
  34. package/docs/guide-images/produit-detail.jpg +0 -0
  35. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  36. package/docs/guide-images/produit-variantes.jpg +0 -0
  37. package/docs/guide-images/produits-liste.jpg +0 -0
  38. package/docs/guide-images/promotion-details.jpg +0 -0
  39. package/docs/guide-images/promotion-type.jpg +0 -0
  40. package/docs/guide-images/promotions-liste.jpg +0 -0
  41. package/docs/guide-images/site-boutique.jpg +0 -0
  42. package/docs/guide-images/site-fermee.png +0 -0
  43. package/docs/guide-images/vue-ensemble.jpg +0 -0
  44. package/package.json +3 -2
  45. package/templates/backend/medusa-config.ts +31 -25
  46. package/templates/backend/package-lock.json +21 -0
  47. package/templates/backend/package.json +2 -0
  48. package/templates/backend/src/admin/i18n/json/en.json +51 -3
  49. package/templates/backend/src/admin/i18n/json/fr.json +51 -3
  50. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
  51. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
  52. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  53. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
  54. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  55. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  56. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  57. package/templates/backend/src/api/middlewares.ts +7 -0
  58. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  59. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  60. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  61. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  62. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  63. package/templates/backend/src/emails/en.json +3 -1
  64. package/templates/backend/src/emails/fr.json +3 -1
  65. package/templates/backend/src/lib/email-config.ts +14 -0
  66. package/templates/backend/src/lib/emails.ts +9 -0
  67. package/templates/backend/src/lib/notify.ts +14 -8
  68. package/templates/backend/src/lib/payment-config.ts +66 -0
  69. package/templates/backend/src/lib/project.ts +12 -0
  70. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  71. package/templates/backend/src/lib/review-token.ts +40 -0
  72. package/templates/backend/src/lib/reviews.ts +25 -0
  73. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  74. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  75. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  76. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  77. package/templates/backend/src/modules/paytech/index.ts +6 -0
  78. package/templates/backend/src/modules/paytech/service.ts +114 -0
  79. package/templates/backend/src/modules/product-review/index.ts +8 -0
  80. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  81. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  82. package/templates/backend/src/modules/product-review/service.ts +6 -0
  83. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  84. package/templates/backend/src/modules/smtp/index.ts +6 -0
  85. package/templates/backend/src/modules/smtp/service.ts +73 -0
  86. package/templates/backend/src/scripts/seed.ts +52 -36
  87. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  88. package/templates/backend/src/workflows/reviews.ts +21 -0
  89. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  90. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  91. package/templates/storefront/PERSONNALISER.md +134 -0
  92. package/templates/storefront/locales/en.json +198 -11
  93. package/templates/storefront/locales/fr.json +198 -11
  94. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  95. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  96. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  97. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  98. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  99. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  100. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  101. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  102. package/templates/storefront/src/app/layout.tsx +13 -1
  103. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  104. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  105. package/templates/storefront/src/lib/boutique/schema.ts +3 -1
  106. package/templates/storefront/src/lib/constants.tsx +17 -0
  107. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  108. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  109. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  110. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  111. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  112. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  113. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  114. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  115. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  116. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  117. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  118. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  119. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  120. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  121. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  122. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  123. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  124. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  125. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  126. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  127. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  128. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  129. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  130. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  131. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  132. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  133. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  134. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  135. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  136. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  137. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  138. package/templates/storefront/src/proxy.ts +2 -0
  139. package/templates/storefront/src/styles/custom.css +26 -0
  140. package/templates/storefront/src/styles/ub/album.css +1 -1
  141. package/templates/storefront/src/styles/ub/arena.css +120 -0
  142. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  143. package/templates/storefront/src/styles/ub/gala.css +1 -1
  144. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  145. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  146. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  147. package/templates/storefront/src/styles/ub/locker.css +115 -0
  148. package/templates/storefront/src/styles/ub/media.css +10 -0
  149. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  150. package/templates/storefront/src/styles/ub/motion.css +0 -3
  151. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  152. package/templates/storefront/src/styles/ub/pages.css +176 -0
  153. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  154. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  155. package/templates/storefront/src/styles/ub/souk.css +144 -0
  156. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  157. package/templates/storefront/src/styles/ub/tech.css +2 -5
  158. package/templates/storefront/src/styles/ub/training.css +120 -0
  159. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  160. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  161. package/templates/storefront/themes/jeux/theme.json +11 -0
  162. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  163. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  164. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  165. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  166. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  167. package/templates/storefront/themes/mubarak/theme.json +11 -0
  168. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  169. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  170. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  171. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  172. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  173. package/templates/storefront/themes/sport/theme.json +11 -0
  174. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  175. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  176. package/templates/storefront/themes/sport-2/theme.json +11 -0
  177. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  178. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  179. package/templates/storefront/themes/summer/theme.json +11 -0
  180. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  181. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  182. 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,6 +5,8 @@ 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
11
  import { boutiqueUrl, currencies, effective, normalizeUrl, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG, storePorts } from "./project.js";
10
12
  import { listThemes } from "./themes.js";
@@ -63,6 +65,7 @@ export async function apply(dir, opts = { up: true }) {
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);
@@ -248,29 +251,90 @@ export function themes() {
248
251
  console.log(`${t.id.padEnd(16)} ${t.name.padEnd(26)} langues : ${t.languages.join(", ")}`);
249
252
  }
250
253
  // ─────────────────────────── update ───────────────────────────
251
- /** 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
+ */
252
259
  export async function update(dir, opts) {
253
260
  const target = path.resolve(dir);
254
261
  const p = readProject(target);
255
262
  const from = p.templateVersion ?? "?";
256
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);
257
301
  for (const part of ["storefront", "backend"]) {
258
302
  fs.rmSync(path.join(target, part), { recursive: true, force: true });
259
303
  copyTemplate(part, target);
260
304
  }
305
+ restoreKept(target, tmp, saved);
306
+ writeFingerprint(target, to);
261
307
  p.templateVersion = to;
262
308
  writeProject(target, p);
263
- 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(", ")})` : ""}`));
264
310
  await apply(target, { up: false });
265
311
  if (opts.up && compose(target, ["up", "-d", "--build", "--remove-orphans"]) !== 0)
266
312
  throw new Error("docker compose up --build a échoué");
267
313
  }
268
- // ─────────────────────────── backup ───────────────────────────
269
- 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 = {}) {
270
331
  const target = path.resolve(dir);
271
- 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");
272
335
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
273
- const out = path.join(target, "backups", stamp);
336
+ const name = opts.label ? `${stamp}-${opts.label}` : stamp;
337
+ const out = path.join(target, "backups", name);
274
338
  fs.mkdirSync(out, { recursive: true });
275
339
  const dump = composeOutput(target, ["exec", "-T", "postgres", "pg_dump", "-U", "medusa", "--clean", "--if-exists", "medusa"]);
276
340
  if (dump.code !== 0)
@@ -278,8 +342,69 @@ export function backup(dir) {
278
342
  fs.writeFileSync(path.join(out, "database.sql"), dump.stdout);
279
343
  if (compose(target, ["cp", "storage:/data", path.join(out, "storage")]) !== 0)
280
344
  throw new Error("copie des images échouée");
281
- console.log(pc.green(`✔ sauvegarde : ${out}`));
282
- 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()}`));
283
408
  }
284
409
  // ─────────────────────────── campaign export / import ───────────────────────────
285
410
  export function campaignExport(dir) {
@@ -392,8 +517,16 @@ export async function check(dir) {
392
517
  console.log(pc.yellow("! Stripe non configuré : paiement manuel uniquement (STRIPE_API_KEY dans .env)"));
393
518
  else if (!env.STRIPE_WEBHOOK_SECRET)
394
519
  console.log(pc.yellow("! STRIPE_WEBHOOK_SECRET vide : webhook Stripe à créer (voir README)"));
395
- if (!env.RESEND_API_KEY)
396
- 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)"));
397
530
  console.log(pc.dim(" Les commandes de test (check@example.com) peuvent être annulées dans l'admin."));
398
531
  if (failed)
399
532
  throw new Error(`${failed} boutique(s) en échec`);
@@ -6,9 +6,11 @@ import { z } from "zod";
6
6
  import { writeCampaignFiles, writeCompose, writeEnv, writeReadme } from "./generate.js";
7
7
  import { writeGuides } from "./guide.js";
8
8
  import { copyDir, copyTemplate, templateVersion } from "./paths.js";
9
+ import { syncCustomCss, writeFingerprint } from "./code.js";
9
10
  import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
10
- import { normalizeUrl, storePorts, validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
11
+ import { boutiqueUrl, normalizeUrl, storePorts, validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
11
12
  import { listThemes, themeIds } from "./themes.js";
13
+ import { askMail, askMobile, mobileSummary } from "./payments.js";
12
14
  /** Réponses du CLI : mêmes clés pour les options, le fichier --config et les questions. */
13
15
  export const answersSchema = z.object({
14
16
  name: z.string().min(1).max(60).optional(),
@@ -30,6 +32,22 @@ export const answersSchema = z.object({
30
32
  stripeWhsec: z.string().optional(),
31
33
  resendKey: z.string().optional(),
32
34
  emailFrom: z.email().optional(),
35
+ /** E-mails par SMTP (ex. Gmail + mot de passe d'application) si pas de Resend */
36
+ smtpHost: z.string().optional(),
37
+ smtpPort: z.number().int().optional(),
38
+ smtpUser: z.string().optional(),
39
+ smtpPass: z.string().optional(),
40
+ /** Paiement mobile (Wave, Orange Money…) : agrégateurs et leurs clés (voir cli/src/payments.ts) */
41
+ mobile: z.array(z.enum(["paytech", "paydunya", "naboopay"])).optional(),
42
+ paytechKey: z.string().optional(),
43
+ paytechSecret: z.string().optional(),
44
+ paytechEnv: z.enum(["test", "prod"]).optional(),
45
+ paydunyaMaster: z.string().optional(),
46
+ paydunyaPrivate: z.string().optional(),
47
+ paydunyaToken: z.string().optional(),
48
+ paydunyaEnv: z.enum(["test", "prod"]).optional(),
49
+ naboopayKey: z.string().optional(),
50
+ naboopaySecret: z.string().optional(),
33
51
  products: z.string().optional(),
34
52
  images: z.string().optional(),
35
53
  storePort: z.number().int().optional(),
@@ -113,10 +131,14 @@ async function ask(a, dirName, yes) {
113
131
  : a.filesUrl;
114
132
  const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
115
133
  const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
116
- // E-mails clients (confirmation, expédition, mot de passe oublié…) via Resend ; vide = aucun e-mail envoyé.
134
+ // Paiement mobile (Wave, Orange Money…) : critères de chaque agrégateur expliqués avant le choix
135
+ const mobile = await askMobile(a, [currency]);
136
+ // E-mails clients : Resend, SMTP (Gmail…) ou aucun. Déjà donnés en option / --config : pas de question.
137
+ const mail = a.resendKey || a.smtpHost ? { kind: a.resendKey ? "resend" : "smtp" } : await askMail();
117
138
  const resendKey = a.resendKey ??
118
- (cancelled(await p.password({ message: "Clé API Resend (vide = aucun e-mail envoyé)", validate: (v) => (!v || v.startsWith("re_") ? undefined : "une clé Resend commence par re_") })) ||
139
+ (mail.kind !== "resend" ? undefined : cancelled(await p.password({ message: "Clé API Resend (vide = aucun e-mail envoyé)", validate: (v) => (!v || v.startsWith("re_") ? undefined : "une clé Resend commence par re_") })) ||
119
140
  undefined);
141
+ const smtp = "smtp" in mail ? mail.smtp : undefined;
120
142
  const emailFrom = resendKey
121
143
  ? (a.emailFrom ??
122
144
  cancelled(await p.text({
@@ -135,7 +157,7 @@ async function ask(a, dirName, yes) {
135
157
  };
136
158
  const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "", validate: exists("file") })).trim() || undefined);
137
159
  const images = a.images ?? (cancelled(await p.text({ message: "Dossier images (optionnel)", defaultValue: "", validate: exists("dir") })).trim() || undefined);
138
- return { ...a, name, email, currency, country, language, mode, apiUrl, filesUrl, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
160
+ return { ...a, ...mobile, ...smtp, name, email, currency, country, language, mode, apiUrl, filesUrl, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
139
161
  }
140
162
  export async function create(dir, input, opts) {
141
163
  const target = path.resolve(dir);
@@ -221,7 +243,8 @@ export async function create(dir, input, opts) {
221
243
  `Images : ${proj.urls ? `${proj.urls.files}/medusa` : `http://localhost:${proj.storagePort}/medusa (local : à changer avant la production avec create-boutique urls)`}`,
222
244
  `Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
223
245
  `Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
224
- `E-mails : ${a.resendKey ? `Resend, depuis ${a.emailFrom ?? "(adresse manquante)"}` : "désactivés (pas de clé Resend)"}`,
246
+ `E-mails : ${a.resendKey ? `Resend, depuis ${a.emailFrom ?? "(adresse manquante)"}` : a.smtpHost ? `SMTP ${a.smtpHost}${a.smtpUser ? `, depuis ${a.smtpUser}` : ""}${a.smtpPass ? "" : " (mot de passe à mettre dans .env)"}` : "désactivés (Resend ou SMTP à renseigner dans .env)"}`,
247
+ mobileSummary(a),
225
248
  `Catalogue : ${a.products ?? "aucun (à ajouter dans l'admin)"}`,
226
249
  ];
227
250
  if (opts.yes)
@@ -236,6 +259,8 @@ export async function create(dir, input, opts) {
236
259
  // Écriture du projet
237
260
  copyTemplate("storefront", target);
238
261
  copyTemplate("backend", target);
262
+ syncCustomCss(target, proj.boutiques.map((b) => ({ slug: b.slug, theme: b.theme, url: boutiqueUrl(proj, b) })));
263
+ writeFingerprint(target, templateVersion()); // empreinte du code généré : update saura dire ce qui a été modifié à la main
239
264
  writeProject(target, proj);
240
265
  writeJsonSchema(target);
241
266
  fs.mkdirSync(path.join(target, "seed", "images"), { recursive: true });
@@ -251,6 +276,19 @@ export async function create(dir, input, opts) {
251
276
  stripeWhsec: a.stripeWhsec,
252
277
  resendKey: a.resendKey,
253
278
  emailFrom: a.emailFrom,
279
+ smtpHost: a.smtpHost,
280
+ smtpPort: a.smtpPort,
281
+ smtpUser: a.smtpUser,
282
+ smtpPass: a.smtpPass,
283
+ paytechKey: a.paytechKey,
284
+ paytechSecret: a.paytechSecret,
285
+ paytechEnv: a.paytechEnv,
286
+ paydunyaMaster: a.paydunyaMaster,
287
+ paydunyaPrivate: a.paydunyaPrivate,
288
+ paydunyaToken: a.paydunyaToken,
289
+ paydunyaEnv: a.paydunyaEnv,
290
+ naboopayKey: a.naboopayKey,
291
+ naboopaySecret: a.naboopaySecret,
254
292
  });
255
293
  writeCompose(target, proj);
256
294
  writeReadme(target, proj);
@@ -261,8 +299,17 @@ export async function create(dir, input, opts) {
261
299
  console.log(pc.yellow(`Chrome/Edge introuvable : ${path.basename(g.file)} à imprimer en PDF.`));
262
300
  }
263
301
  const next = `cd ${path.relative(process.cwd(), target) || "."}\ndocker compose up -d`;
302
+ // Personnaliser : ce qui est conservé par les mises à jour, et ce qui ne l'est pas
303
+ const custom = [
304
+ "Personnaliser l'apparence (conservé par create-boutique update) :",
305
+ ' storefront/src/styles/custom.css : une section par boutique ([data-boutique="<slug>"]) + une commune',
306
+ " storefront/PERSONNALISER.md : mode d'emploi (plan du site page par page, classes, exemples)",
307
+ " storefront/public/custom/ : images et polices utilisées par custom.css",
308
+ " puis : docker compose up -d --build",
309
+ "Modifier d'autres fichiers du code reste possible, mais update les remplace (il les liste et les copie dans backups/ avant).",
310
+ ].join("\n ");
264
311
  if (opts.yes)
265
- console.log(`Projet créé : ${target}\nEnsuite :\n ${next.replace("\n", "\n ")}\n`);
312
+ console.log(`Projet créé : ${target}\nEnsuite :\n ${next.replace("\n", "\n ")}\n\n ${custom}\n`);
266
313
  else
267
- p.outro(`Projet créé. Ensuite :\n\n ${pc.cyan(next.replace("\n", "\n "))}\n\n Mot de passe admin : ADMIN_PASSWORD dans .env (à transmettre à part).`);
314
+ p.outro(`Projet créé. Ensuite :\n\n ${pc.cyan(next.replace("\n", "\n "))}\n\n Mot de passe admin : ADMIN_PASSWORD dans .env (à transmettre à part).\n\n ${custom}`);
268
315
  }
@@ -185,9 +185,27 @@ export function writeEnv(dir, p, s) {
185
185
  `STRIPE_WEBHOOK_SECRET=${s.stripeWhsec ?? ""}`,
186
186
  `STRIPE_PUBLISHABLE_KEY=${s.stripePk ?? ""}`,
187
187
  "",
188
- "# E-mails (Resend ; vide = aucun e-mail envoyé)",
188
+ "# E-mails : Resend (prioritaire) OU un serveur SMTP ; les deux vides = aucun e-mail envoyé",
189
189
  `RESEND_API_KEY=${s.resendKey ?? ""}`,
190
190
  `EMAIL_FROM=${s.emailFrom ?? ""}`,
191
+ "# SMTP, ex. Gmail : SMTP_HOST=smtp.gmail.com, SMTP_PORT=465, SMTP_USER=adresse gmail, SMTP_PASS=mot de passe d'application",
192
+ `SMTP_HOST=${s.smtpHost ?? ""}`,
193
+ `SMTP_PORT=${s.smtpPort ?? 465}`,
194
+ `SMTP_USER=${s.smtpUser ?? ""}`,
195
+ `SMTP_PASS=${s.smtpPass ?? ""}`,
196
+ "",
197
+ "# Paiement Wave / Orange Money / carte (Afrique de l'Ouest, devise XOF). Vide = désactivé. Après modification :",
198
+ "# docker compose restart backend. Webhooks : voir README (section Paiement mobile).",
199
+ `PAYTECH_API_KEY=${s.paytechKey ?? ""}`,
200
+ `PAYTECH_API_SECRET=${s.paytechSecret ?? ""}`,
201
+ `PAYTECH_ENV=${s.paytechEnv ?? "test"}`,
202
+ `PAYDUNYA_MASTER_KEY=${s.paydunyaMaster ?? ""}`,
203
+ `PAYDUNYA_PRIVATE_KEY=${s.paydunyaPrivate ?? ""}`,
204
+ `PAYDUNYA_TOKEN=${s.paydunyaToken ?? ""}`,
205
+ `PAYDUNYA_ENV=${s.paydunyaEnv ?? "test"}`,
206
+ `NABOOPAY_API_KEY=${s.naboopayKey ?? ""}`,
207
+ `NABOOPAY_WEBHOOK_SECRET=${s.naboopaySecret ?? ""}`,
208
+ "NABOOPAY_METHODS=wave,orange_money",
191
209
  "",
192
210
  "# Dérivé de config/project.json (réécrit par `create-boutique apply`)",
193
211
  ...Object.entries(derivedEnv(p)).map(([k, v]) => `${k}=${v}`),
@@ -269,13 +287,28 @@ export function campaignDates(theme, now = new Date()) {
269
287
  return [new Date(Date.UTC(y, 11, 1)), day(new Date(Date.UTC(y, 11, 24)), 0, true)];
270
288
  case "nouvel-an":
271
289
  return [new Date(Date.UTC(y, 11, 26)), day(new Date(Date.UTC(y + 1, 0, 2)), 0, true)];
290
+ // Saison d'été (solstice → fin août) ; Jeux et sport : l'été sportif (mi-juillet → fin août)
291
+ case "summer":
292
+ return [new Date(Date.UTC(y, 5, 21)), day(new Date(Date.UTC(y, 7, 31)), 0, true)];
293
+ case "jeux":
294
+ case "sport":
295
+ return [new Date(Date.UTC(y, 6, 15)), day(new Date(Date.UTC(y, 7, 31)), 0, true)];
296
+ // Ramadan → 2 jours après l'Aïd el-Fitr (Korité). Calendrier lunaire : dates approximatives (± 1 jour selon
297
+ // l'observation de la lune), à ajuster dans l'admin ; pour la Tabaski, le client change simplement les dates.
298
+ case "mubarak": {
299
+ const ramadan = {
300
+ 2027: [1, 8, 2, 11], 2028: [0, 28, 2, 1], 2029: [0, 16, 1, 17], 2030: [0, 6, 1, 7],
301
+ };
302
+ const r = ramadan[y];
303
+ return r ? [new Date(Date.UTC(y, r[0], r[1])), day(new Date(Date.UTC(y, r[2], r[3])), 0, true)] : null;
304
+ }
272
305
  default:
273
306
  return null;
274
307
  }
275
308
  };
276
309
  let range = build(now.getUTCFullYear());
277
- if (range && range[1] < now)
278
- range = build(now.getUTCFullYear() + 1);
310
+ if (!range || range[1] < now)
311
+ range = build(now.getUTCFullYear() + 1) ?? range;
279
312
  if (!range)
280
313
  range = [day(now), day(now, 30, true)];
281
314
  return { startsAt: range[0].toISOString(), endsAt: range[1].toISOString() };
@@ -373,13 +406,41 @@ create-boutique backup . # sauvegarde base + images dans backups/<date>/
373
406
  puis copier le secret \`whsec_…\` dans \`STRIPE_WEBHOOK_SECRET\` et \`docker compose restart backend\`.
374
407
  4. Apple Pay : déclarer les domaines des boutiques dans Stripe → Paramètres → Moyens de paiement.
375
408
 
409
+ ## Paiement mobile : Wave, Orange Money, carte (PayTech, PayDunya, NabooPay)
410
+
411
+ Pour les boutiques en **XOF** (FCFA ; PayTech accepte aussi EUR et USD). Le client choisit « Wave, Orange Money… »,
412
+ paie sur la page sécurisée de l'agrégateur, puis revient sur la boutique. Le montant est toujours calculé par le backend.
413
+
414
+ 1. Créer un compte chez l'agrégateur choisi et récupérer ses clés (de test d'abord) :
415
+ - **PayTech** (paytech.sn) : \`PAYTECH_API_KEY\`, \`PAYTECH_API_SECRET\` ; \`PAYTECH_ENV=test\` (débite 100 à 150 FCFA) puis \`prod\`.
416
+ - **PayDunya** (paydunya.com) : \`PAYDUNYA_MASTER_KEY\`, \`PAYDUNYA_PRIVATE_KEY\`, \`PAYDUNYA_TOKEN\` ; \`PAYDUNYA_ENV=test\` (sandbox) puis \`prod\`.
417
+ - **NabooPay** (naboopay.com) : \`NABOOPAY_API_KEY\` ; \`NABOOPAY_METHODS=wave,orange_money\`.
418
+ 2. Les écrire dans \`.env\` (jamais ailleurs), puis \`docker compose restart backend\` : le moyen apparaît au paiement.
419
+ 3. Notifications (le paiement est confirmé même si le client ferme la page) : le backend doit être en ligne en HTTPS.
420
+ - PayTech et PayDunya : rien à faire, l'adresse est envoyée à chaque paiement
421
+ (\`<adresse du backend>/hooks/payment/paytech_paytech\`, \`…/paydunya_paydunya\`).
422
+ - NabooPay : tableau de bord → Paramètres → Intégration, webhook \`<adresse du backend>/hooks/payment/naboopay_naboopay\`,
423
+ puis copier son secret dans \`NABOOPAY_WEBHOOK_SECRET\` et \`docker compose restart backend\`.
424
+ 4. Remboursement : PayTech et NabooPay depuis l'admin (remboursement total) ; PayDunya dans son tableau de bord, puis
425
+ « Remboursement » dans l'admin pour l'enregistrer (guide client, section 4.3).
426
+ 5. Test PayTech (\`PAYTECH_ENV=test\`) : pas de simulation, chaque paiement débite vraiment 100 à 150 FCFA sur le compte
427
+ du testeur, reçus sur le solde PayTech de la boutique (moins les frais).
428
+
429
+ ## E-mails sans Resend : SMTP (ex. Gmail)
430
+
431
+ Si \`RESEND_API_KEY\` est vide, les e-mails partent par le serveur SMTP du \`.env\`. Avec Gmail :
432
+ 1. Compte Google → Sécurité → validation en deux étapes activée → **Mots de passe des applications** → en créer un.
433
+ 2. \`.env\` : \`SMTP_HOST=smtp.gmail.com\`, \`SMTP_PORT=465\`, \`SMTP_USER=adresse@gmail.com\`, \`SMTP_PASS=<mot de passe d'application>\`,
434
+ \`EMAIL_FROM\` vide (l'adresse Gmail est utilisée), puis \`docker compose restart backend\`.
435
+ 3. Limite Gmail : environ 500 e-mails par jour. Au-delà, ou pour un domaine à la marque : Resend, Brevo, Mailjet (SMTP aussi).
436
+
376
437
  ## Avant de livrer : checklist
377
438
 
378
439
  - [ ] Chaque boutique s'ouvre à son adresse, avec son thème et sa devise.
379
440
  - [ ] Chaque produit a un prix dans la devise de chaque boutique où il est vendu.
380
441
  - [ ] Logo, textes et dates de chaque campagne vérifiés (Admin → Campagnes).
381
442
  - [ ] Stripe configuré (clés + webhook) ou paiement manuel assumé.
382
- - [ ] E-mails : \`RESEND_API_KEY\` renseignée, une commande test reçue par le client et le marchand.
443
+ - [ ] E-mails : Resend ou SMTP renseigné, une commande test reçue par le client et le marchand.
383
444
  - [ ] Une commande test passée dans chaque boutique (\`create-boutique check .\`).
384
445
  - [ ] Sauvegarde programmée (\`create-boutique backup .\`).
385
446
 
package/cli/dist/guide.js CHANGED
@@ -27,6 +27,14 @@ function render(source, out, dir, vars) {
27
27
  let html = fs.readFileSync(source, "utf8");
28
28
  for (const [k, v] of Object.entries(vars))
29
29
  html = html.split(`{{${k}}}`).join(k.endsWith("_HTML") ? v : escapeHtml(v) || "&nbsp;");
30
+ // Captures d'écran (docs/guide-images) intégrées au fichier : le PDF n'a besoin de rien à côté
31
+ html = html.replace(/src="guide-images\/([\w.-]+)"/g, (m, name) => {
32
+ const file = path.join(path.dirname(source), "guide-images", name);
33
+ if (!fs.existsSync(file))
34
+ return m;
35
+ const type = name.endsWith(".png") ? "image/png" : "image/jpeg";
36
+ return `src="data:${type};base64,${fs.readFileSync(file).toString("base64")}"`;
37
+ });
30
38
  const htmlFile = path.join(dir, `${out}.html`);
31
39
  const pdfFile = path.join(dir, `${out}.pdf`);
32
40
  fs.writeFileSync(htmlFile, html);
@@ -48,7 +56,7 @@ function render(source, out, dir, vars) {
48
56
  */
49
57
  export function writeGuides(dir, p, adminEmail, support = "") {
50
58
  const fr = p.defaults.language === "fr";
51
- const adminUrl = `http://localhost:${p.apiPort}/app`;
59
+ const adminUrl = `${p.urls?.api ?? `http://localhost:${p.apiPort}`}/app`;
52
60
  const stores = p.boutiques
53
61
  .map((b) => {
54
62
  const e = effective(p, b);
@@ -58,7 +66,7 @@ export function writeGuides(dir, p, adminEmail, support = "") {
58
66
  const vars = {
59
67
  ADMIN_URL: adminUrl,
60
68
  ADMIN_EMAIL: adminEmail,
61
- SUPPORT: support,
69
+ SUPPORT: support || "l'équipe qui a créé votre boutique",
62
70
  PROJECT: p.project,
63
71
  STORE_NAME: p.defaults.storeName,
64
72
  CURRENCIES: currencies(p).map((c) => c.toUpperCase()).join(", "),