@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
@@ -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 { 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(),
@@ -18,11 +20,34 @@ export const answersSchema = z.object({
18
20
  language: z.string().regex(/^[a-z]{2}$/).optional(),
19
21
  themes: z.union([z.literal("all"), z.array(z.string())]).optional(),
20
22
  boutiques: z.array(z.string().regex(/^[a-z0-9-]+:[a-z0-9-]+$/, "format slug:thème")).optional(),
23
+ /** multi : un site par boutique ; single : un seul site, la boutique active se choisit dans l'admin */
24
+ mode: z.enum(["multi", "single"]).optional(),
25
+ /** Production : domaines publics de l'admin/API et des images (le CLI ajoute /medusa pour les images) */
26
+ apiUrl: z.string().optional(),
27
+ filesUrl: z.string().optional(),
28
+ /** Mode single : domaine du site */
29
+ siteUrl: z.string().optional(),
21
30
  stripePk: z.string().optional(),
22
31
  stripeSk: z.string().optional(),
23
32
  stripeWhsec: z.string().optional(),
24
33
  resendKey: z.string().optional(),
25
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(),
26
51
  products: z.string().optional(),
27
52
  images: z.string().optional(),
28
53
  storePort: z.number().int().optional(),
@@ -88,12 +113,32 @@ async function ask(a, dirName, yes) {
88
113
  extra.push(`${slug}:${theme}`);
89
114
  }
90
115
  }
116
+ const mode = a.mode ??
117
+ cancelled(await p.select({
118
+ message: "Sites",
119
+ options: [
120
+ { value: "multi", label: "Un site par boutique", hint: "toutes en ligne, chacune sur son port / domaine" },
121
+ { value: "single", label: "Un seul site", hint: "une boutique en ligne à la fois, choisie dans l'admin → Campagnes" },
122
+ ],
123
+ initialValue: "multi",
124
+ }));
125
+ // Adresses publiques (production) : inscrites dans les images importées, donc à donner avant le premier démarrage
126
+ const apiUrl = a.apiUrl ??
127
+ (cancelled(await p.text({ message: "Domaine de l'admin / API en production (vide = test en local)", placeholder: "https://admin.marque.com", defaultValue: "" })).trim() ||
128
+ undefined);
129
+ const filesUrl = apiUrl
130
+ ? (a.filesUrl ?? cancelled(await p.text({ message: "Domaine des images (juste le domaine, /medusa est ajouté)", placeholder: "https://images.marque.com", validate: (v) => (v?.trim() ? undefined : "obligatoire avec le domaine de l'admin") })).trim())
131
+ : a.filesUrl;
91
132
  const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
92
133
  const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
93
- // 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();
94
138
  const resendKey = a.resendKey ??
95
- (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_") })) ||
96
140
  undefined);
141
+ const smtp = "smtp" in mail ? mail.smtp : undefined;
97
142
  const emailFrom = resendKey
98
143
  ? (a.emailFrom ??
99
144
  cancelled(await p.text({
@@ -112,7 +157,7 @@ async function ask(a, dirName, yes) {
112
157
  };
113
158
  const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "", validate: exists("file") })).trim() || undefined);
114
159
  const images = a.images ?? (cancelled(await p.text({ message: "Dossier images (optionnel)", defaultValue: "", validate: exists("dir") })).trim() || undefined);
115
- return { ...a, name, email, currency, country, language, 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 };
116
161
  }
117
162
  export async function create(dir, input, opts) {
118
163
  const target = path.resolve(dir);
@@ -144,19 +189,39 @@ export async function create(dir, input, opts) {
144
189
  taken.add(apiPort);
145
190
  const storagePort = await nextFreePort(a.storagePort ?? 9100, taken);
146
191
  taken.add(storagePort);
192
+ const mode = a.mode ?? "multi";
147
193
  let nextStore = a.storePort ?? 8001;
148
194
  const boutiques = [];
149
- for (const w of wanted) {
150
- const port = await nextFreePort(nextStore, taken);
151
- taken.add(port);
152
- nextStore = port + 1;
153
- boutiques.push({ slug: w.slug, theme: w.theme, port });
195
+ let site;
196
+ if (mode === "single") {
197
+ // Un seul site : un port pour tout le projet, les boutiques n'en ont pas
198
+ site = { port: await nextFreePort(nextStore, taken) };
199
+ taken.add(site.port);
200
+ for (const w of wanted)
201
+ boutiques.push({ slug: w.slug, theme: w.theme });
202
+ }
203
+ else {
204
+ for (const w of wanted) {
205
+ const port = await nextFreePort(nextStore, taken);
206
+ taken.add(port);
207
+ nextStore = port + 1;
208
+ boutiques.push({ slug: w.slug, theme: w.theme, port });
209
+ }
154
210
  }
211
+ // Adresses publiques : les deux ensemble (les images importées gardent l'adresse du stockage)
212
+ if (!!a.apiUrl !== !!a.filesUrl)
213
+ throw new ProjectError("--api-url et --files-url vont ensemble (domaine de l'admin et domaine des images)");
214
+ const urls = a.apiUrl && a.filesUrl ? { api: normalizeUrl(a.apiUrl, "--api-url"), files: normalizeUrl(a.filesUrl, "--files-url") } : undefined;
215
+ if (site && a.siteUrl)
216
+ Object.assign(site, { url: normalizeUrl(a.siteUrl, "--site-url") });
155
217
  const proj = validateProject({
156
218
  project,
157
219
  templateVersion: templateVersion(),
158
220
  apiPort,
159
221
  storagePort,
222
+ mode,
223
+ ...(site ? { site } : {}),
224
+ ...(urls ? { urls } : {}),
160
225
  defaults: {
161
226
  storeName: a.name,
162
227
  currency: a.currency,
@@ -171,11 +236,15 @@ export async function create(dir, input, opts) {
171
236
  // Récapitulatif
172
237
  const width = Math.max(...proj.boutiques.map((b) => b.slug.length)) + 2;
173
238
  const lines = [
174
- ...proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(16)}→ http://localhost:${b.port}`),
175
- `${"Backend / admin".padEnd(width + 16)}→ http://localhost:${proj.apiPort}/app`,
239
+ ...(proj.mode === "single"
240
+ ? [`Site unique → http://localhost:${proj.site.port} (boutique en ligne choisie dans admin → Campagnes ; au départ : ${proj.boutiques[0].slug})`, ...proj.boutiques.map((b) => ` ${b.slug.padEnd(width)}${b.theme}`)]
241
+ : proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(16)}→ http://localhost:${b.port}`)),
242
+ `${"Backend / admin".padEnd(width + 16)}→ ${proj.urls ? `${proj.urls.api}/app` : `http://localhost:${proj.apiPort}/app`}`,
243
+ `Images : ${proj.urls ? `${proj.urls.files}/medusa` : `http://localhost:${proj.storagePort}/medusa (local : à changer avant la production avec create-boutique urls)`}`,
176
244
  `Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
177
245
  `Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
178
- `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),
179
248
  `Catalogue : ${a.products ?? "aucun (à ajouter dans l'admin)"}`,
180
249
  ];
181
250
  if (opts.yes)
@@ -190,6 +259,8 @@ export async function create(dir, input, opts) {
190
259
  // Écriture du projet
191
260
  copyTemplate("storefront", target);
192
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
193
264
  writeProject(target, proj);
194
265
  writeJsonSchema(target);
195
266
  fs.mkdirSync(path.join(target, "seed", "images"), { recursive: true });
@@ -205,18 +276,40 @@ export async function create(dir, input, opts) {
205
276
  stripeWhsec: a.stripeWhsec,
206
277
  resendKey: a.resendKey,
207
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,
208
292
  });
209
293
  writeCompose(target, proj);
210
294
  writeReadme(target, proj);
211
295
  fs.writeFileSync(path.join(target, ".gitignore"), ".env\nbackups/\nnode_modules/\n");
212
- reservePorts(target, [proj.apiPort, proj.storagePort, ...proj.boutiques.map((b) => b.port)]);
296
+ reservePorts(target, [proj.apiPort, proj.storagePort, ...storePorts(proj)]);
213
297
  for (const g of writeGuides(target, proj, a.email)) {
214
298
  if (!g.pdf)
215
299
  console.log(pc.yellow(`Chrome/Edge introuvable : ${path.basename(g.file)} à imprimer en PDF.`));
216
300
  }
217
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 ");
218
311
  if (opts.yes)
219
- 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`);
220
313
  else
221
- 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}`);
222
315
  }
@@ -1,18 +1,19 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { currencies, effective } from "./project.js";
4
+ import { boutiqueUrl, currencies, effective, fronts } from "./project.js";
5
5
  const secret = () => crypto.randomBytes(32).toString("base64url");
6
6
  // ─────────────────────────── docker-compose.yml ───────────────────────────
7
- /** Production (urls présent) : ports des containers seulement sur 127.0.0.1, Caddy seul exposé (80/443). */
7
+ /** Production (urls présent) : ports seulement sur 127.0.0.1 ; ton reverse proxy (Caddy, Nginx…) sur la machine les expose en https. */
8
8
  const bind = (p, port) => (p.urls ? `127.0.0.1:${port}` : String(port));
9
9
  const host = (url) => new URL(url).host;
10
10
  export function composeYaml(p) {
11
- const services = p.boutiques
12
- .map((b) => ` storefront-${b.slug}:
11
+ // Mode single : un seul service, BOUTIQUE=auto (il affiche la boutique active, choisie dans l'admin)
12
+ const services = fronts(p)
13
+ .map((f) => ` ${f.service}:
13
14
  <<: *storefront
14
- ports: ["${bind(p, b.port)}:8000"]
15
- environment: { <<: *storefront-env, BOUTIQUE: ${b.slug} }
15
+ ports: ["${bind(p, f.port)}:8000"]
16
+ environment: { <<: *storefront-env, BOUTIQUE: ${f.boutique} }
16
17
  `)
17
18
  .join("\n");
18
19
  return `# GÉNÉRÉ par create-boutique à partir de config/project.json — ne pas modifier à la main.
@@ -51,6 +52,7 @@ ${services}
51
52
  volumes:
52
53
  - ./config:/app/config:ro # boutiques, devises, pays → régions, sales channels, clés
53
54
  - ./seed:/app/seed:ro # products.csv + images à importer
55
+ - ./storefront/themes:/app/themes:ro # textes par défaut des thèmes (pré-remplis dans admin → Campagnes)
54
56
  - keys:/app/keys # y écrit la publishable key de chaque boutique
55
57
  depends_on:
56
58
  postgres: { condition: service_healthy }
@@ -94,53 +96,59 @@ ${services}
94
96
  test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/health >/dev/null || exit 1"]
95
97
  interval: 5s
96
98
  retries: 20
97
- ${p.urls ? caddyService : ""}
98
99
  volumes:
99
100
  pg_data:
100
101
  redis_data:
101
102
  storage_data:
102
- keys:${p.urls ? "\n caddy_data:\n caddy_config:" : ""}
103
+ keys:
103
104
  `;
104
105
  }
105
- const caddyService = `
106
- # ───────── Production : https automatique (Let's Encrypt), un domaine par boutique ─────────
107
- caddy:
108
- image: caddy:2-alpine
109
- restart: unless-stopped
110
- ports: ["80:80", "443:443"]
111
- volumes:
112
- - ./Caddyfile:/etc/caddy/Caddyfile:ro
113
- - caddy_data:/data
114
- - caddy_config:/config
115
- depends_on:
116
- backend: { condition: service_healthy }
117
- `;
118
- /** Caddyfile (production) : chaque domaine vers son container ; certificats https obtenus et renouvelés par Caddy. */
106
+ /**
107
+ * Exemple de Caddyfile (production) : le CLI ne lance aucun reverse proxy, c'est toi qui le gères sur la machine.
108
+ * Ce fichier n'est utilisé par rien : il montre quel domaine envoyer vers quel port (127.0.0.1).
109
+ */
119
110
  export function caddyfile(p) {
120
- const site = (url, upstream) => `${host(url)} {
111
+ const site = (url, port) => `${host(url)} {
121
112
  encode zstd gzip
122
- reverse_proxy ${upstream}
113
+ reverse_proxy 127.0.0.1:${port}
123
114
  }
124
115
  `;
125
116
  return [
126
- "# GÉNÉRÉ par create-boutique à partir de config/project.json — ne pas modifier à la main.",
127
- ...p.boutiques.filter((b) => b.url).map((b) => site(b.url, `storefront-${b.slug}:8000`)),
128
- site(p.urls.api, "backend:9000"),
129
- site(p.urls.files, "storage:9000"),
117
+ "# EXEMPLE généré par create-boutique (non utilisé) : à recopier dans ton propre Caddyfile, puis recharger Caddy.",
118
+ "# Régénéré à chaque apply / urls à partir de config/project.json.",
119
+ ...fronts(p).filter((f) => f.url).map((f) => site(f.url, f.port)),
120
+ site(p.urls.api, p.apiPort),
121
+ // Images : lecture seule des fichiers du bucket (GET /medusa/…), tout le reste 404 (ni liste, ni écriture, ni console)
122
+ `${host(p.urls.files)} {
123
+ encode zstd gzip
124
+ @read {
125
+ method GET HEAD
126
+ path /medusa/*
127
+ }
128
+ handle @read {
129
+ reverse_proxy 127.0.0.1:${p.storagePort}
130
+ }
131
+ respond 404
132
+ }
133
+ `,
130
134
  ].join("\n");
131
135
  }
132
- /** Écrit docker-compose.yml (+ Caddyfile en production, supprimé sinon). */
136
+ /** Écrit docker-compose.yml (+ Caddyfile.exemple en production). */
133
137
  export function writeCompose(dir, p) {
134
138
  fs.writeFileSync(path.join(dir, "docker-compose.yml"), composeYaml(p));
135
- const file = path.join(dir, "Caddyfile");
139
+ // Ancien Caddyfile généré par une version précédente (service caddy retiré) : supprimé ; un Caddyfile à toi n'est jamais touché
140
+ const old = path.join(dir, "Caddyfile");
141
+ if (fs.existsSync(old) && fs.readFileSync(old, "utf8").startsWith("# GÉNÉRÉ par create-boutique"))
142
+ fs.rmSync(old);
143
+ const example = path.join(dir, "Caddyfile.exemple");
136
144
  if (p.urls)
137
- fs.writeFileSync(file, caddyfile(p));
138
- else if (fs.existsSync(file))
139
- fs.rmSync(file);
145
+ fs.writeFileSync(example, caddyfile(p));
146
+ else if (fs.existsSync(example))
147
+ fs.rmSync(example);
140
148
  }
141
149
  /** Variables qui dépendent de project.json (réécrites à chaque apply). Les secrets ne sont jamais régénérés. */
142
150
  function derivedEnv(p) {
143
- const origins = p.boutiques.map((b) => b.url ?? `http://localhost:${b.port}`);
151
+ const origins = fronts(p).map((f) => f.url ?? `http://localhost:${f.port}`);
144
152
  const trim = (u) => u.replace(/\/+$/, "");
145
153
  const admin = trim(p.urls?.api ?? `http://localhost:${p.apiPort}`);
146
154
  const files = trim(p.urls?.files ?? `http://localhost:${p.storagePort}`);
@@ -177,9 +185,27 @@ export function writeEnv(dir, p, s) {
177
185
  `STRIPE_WEBHOOK_SECRET=${s.stripeWhsec ?? ""}`,
178
186
  `STRIPE_PUBLISHABLE_KEY=${s.stripePk ?? ""}`,
179
187
  "",
180
- "# E-mails (Resend ; vide = aucun e-mail envoyé)",
188
+ "# E-mails : Resend (prioritaire) OU un serveur SMTP ; les deux vides = aucun e-mail envoyé",
181
189
  `RESEND_API_KEY=${s.resendKey ?? ""}`,
182
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",
183
209
  "",
184
210
  "# Dérivé de config/project.json (réécrit par `create-boutique apply`)",
185
211
  ...Object.entries(derivedEnv(p)).map(([k, v]) => `${k}=${v}`),
@@ -261,13 +287,28 @@ export function campaignDates(theme, now = new Date()) {
261
287
  return [new Date(Date.UTC(y, 11, 1)), day(new Date(Date.UTC(y, 11, 24)), 0, true)];
262
288
  case "nouvel-an":
263
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
+ }
264
305
  default:
265
306
  return null;
266
307
  }
267
308
  };
268
309
  let range = build(now.getUTCFullYear());
269
- if (range && range[1] < now)
270
- range = build(now.getUTCFullYear() + 1);
310
+ if (!range || range[1] < now)
311
+ range = build(now.getUTCFullYear() + 1) ?? range;
271
312
  if (!range)
272
313
  range = [day(now), day(now, 30, true)];
273
314
  return { startsAt: range[0].toISOString(), endsAt: range[1].toISOString() };
@@ -309,7 +350,7 @@ export function writeReadme(dir, p) {
309
350
  const rows = p.boutiques
310
351
  .map((b) => {
311
352
  const e = effective(p, b);
312
- return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} | http://localhost:${b.port} |`;
353
+ return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} | ${boutiqueUrl(p, b)} |`;
313
354
  })
314
355
  .join("\n");
315
356
  const text = `# ${p.defaults.storeName} — projet \`${p.project}\`
@@ -365,13 +406,41 @@ create-boutique backup . # sauvegarde base + images dans backups/<date>/
365
406
  puis copier le secret \`whsec_…\` dans \`STRIPE_WEBHOOK_SECRET\` et \`docker compose restart backend\`.
366
407
  4. Apple Pay : déclarer les domaines des boutiques dans Stripe → Paramètres → Moyens de paiement.
367
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
+
368
437
  ## Avant de livrer : checklist
369
438
 
370
439
  - [ ] Chaque boutique s'ouvre à son adresse, avec son thème et sa devise.
371
440
  - [ ] Chaque produit a un prix dans la devise de chaque boutique où il est vendu.
372
441
  - [ ] Logo, textes et dates de chaque campagne vérifiés (Admin → Campagnes).
373
442
  - [ ] Stripe configuré (clés + webhook) ou paiement manuel assumé.
374
- - [ ] 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.
375
444
  - [ ] Une commande test passée dans chaque boutique (\`create-boutique check .\`).
376
445
  - [ ] Sauvegarde programmée (\`create-boutique backup .\`).
377
446
 
package/cli/dist/guide.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { PACKAGE_ROOT } from "./paths.js";
6
- import { currencies, effective } from "./project.js";
6
+ import { boutiqueUrl, currencies, effective } from "./project.js";
7
7
  const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
8
  /** Chrome / Edge / Chromium installé (CHROME_PATH prioritaire). */
9
9
  function findBrowser() {
@@ -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,17 +56,17 @@ 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);
55
- return `<tr><td>${escapeHtml(e.storeName)}</td><td><code>${escapeHtml(b.slug)}</code></td><td>${escapeHtml(b.theme)}</td><td>${e.currency.toUpperCase()}</td><td>${escapeHtml(b.url ?? `http://localhost:${b.port}`)}</td></tr>`;
63
+ return `<tr><td>${escapeHtml(e.storeName)}</td><td><code>${escapeHtml(b.slug)}</code></td><td>${escapeHtml(b.theme)}</td><td>${e.currency.toUpperCase()}</td><td>${escapeHtml(boutiqueUrl(p, b))}</td></tr>`;
56
64
  })
57
65
  .join("");
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(", "),
package/cli/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import { parseArgs } from "node:util";
4
4
  import pc from "picocolors";
5
5
  import { z } from "zod";
6
- import { add, admin, apply, backup, campaignExport, campaignImport, check, guide, remove, set, stripeWebhook, themes, update } from "./commands.js";
6
+ import { add, admin, apply, urls, backup, restore, campaignExport, campaignImport, check, guide, remove, set, stripeWebhook, themes, update } from "./commands.js";
7
7
  import { answersSchema, create } from "./create.js";
8
8
  import { ProjectError } from "./project.js";
9
9
  const HELP = `create-boutique — Usine à Boutiques
@@ -14,28 +14,38 @@ const HELP = `create-boutique — Usine à Boutiques
14
14
  --currency usd (défaut usd) --country us (défaut us) --language en (défaut en)
15
15
  --themes a,b,c | all une boutique par thème, slug = <dossier>-<thème>
16
16
  --boutique <slug>:<thème> ajoute une boutique au nom choisi (répétable)
17
+ --api-url <domaine> --files-url <domaine> production : domaines de l'admin et des images (avant le 1er démarrage)
18
+ --site-url <domaine> mode single : domaine du site
19
+ --mode multi|single multi (défaut) : un site par boutique ; single : un seul site, boutique active choisie dans l'admin
17
20
  --stripe-pk / --stripe-sk / --stripe-whsec clés Stripe (optionnelles)
18
- --resend-key / --email-from e-mails (optionnels)
21
+ --resend-key / --email-from e-mails par Resend (optionnels)
22
+ --smtp-host smtp.gmail.com --smtp-user <adresse> e-mails par SMTP (mot de passe : SMTP_PASS dans .env)
23
+ --mobile paytech,paydunya,naboopay paiement Wave / Orange Money (clés dans .env ; détails : create-boutique sans --yes)
19
24
  --products <csv> --images <dossier> catalogue (optionnel)
20
25
  --store-port 8001 --api-port 9000 --storage-port 9100 premiers ports essayés
21
26
  --config <fichier.json> toutes les options dans un fichier (mêmes clés, en camelCase)
22
27
  --yes aucune question, valeurs par défaut
23
28
 
24
29
  create-boutique apply <dossier> [--no-up] applique config/project.json
30
+ create-boutique urls <dossier> [--api-url …] [--files-url …] [--site-url …] [--from <ancienne adresse des images>]
31
+ change les domaines publics ; réécrit l'adresse des images déjà en base
25
32
  create-boutique add <dossier> --boutique <slug>:<thème>
26
33
  create-boutique remove <dossier> --boutique <slug>
34
+ create-boutique set <dossier> [--mode multi|single] change de mode (un site par boutique ↔ un seul site)
27
35
  create-boutique set <dossier> [--boutique <slug>] [--name …] [--theme …] [--currency …] [--country …] [--language …]
28
36
  create-boutique themes liste les thèmes
29
- create-boutique update <dossier> [--no-up] met à jour le code (config, .env, données conservés)
37
+ create-boutique update <dossier> [--no-up] [--yes] met à jour le code (config, .env, données, custom.css conservés ;
38
+ code modifié à la main : listé, copié dans backups/, confirmation)
30
39
  create-boutique check <dossier> commande test dans chaque boutique
31
- create-boutique backup <dossier> base + images dans backups/<date>/
40
+ create-boutique backup <dossier> [--keep 7] base + images dans backups/<date>/ (--keep : garde les N plus récentes)
41
+ create-boutique restore <dossier> [--from <nom>] [--yes] remet une sauvegarde (la plus récente par défaut)
32
42
  create-boutique admin <dossier> adresse, e-mail et mot de passe de l'admin
33
43
  create-boutique guide <dossier> régénère GUIDE-CLIENT.pdf et GUIDE-COMPLET.pdf
34
44
  create-boutique stripe-webhook <dossier> --url https://api.marque.com crée le webhook Stripe, écrit STRIPE_WEBHOOK_SECRET
35
45
  create-boutique campaign export <dossier> campagnes en base → config/campaigns/*.json
36
46
  create-boutique campaign import <dossier> [--boutique <slug>] [--yes] JSON → base (écrase)
37
47
  `;
38
- const COMMANDS = new Set(["apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
48
+ const COMMANDS = new Set(["restore", "urls", "apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
39
49
  async function main() {
40
50
  const { values, positionals } = parseArgs({
41
51
  allowPositionals: true,
@@ -48,11 +58,20 @@ async function main() {
48
58
  themes: { type: "string" },
49
59
  boutique: { type: "string", multiple: true },
50
60
  theme: { type: "string" },
61
+ mode: { type: "string" },
62
+ "api-url": { type: "string" },
63
+ "files-url": { type: "string" },
64
+ "site-url": { type: "string" },
65
+ from: { type: "string" },
66
+ keep: { type: "string" },
51
67
  "stripe-pk": { type: "string" },
52
68
  "stripe-sk": { type: "string" },
53
69
  "stripe-whsec": { type: "string" },
54
70
  "resend-key": { type: "string" },
55
71
  "email-from": { type: "string" },
72
+ "smtp-host": { type: "string" },
73
+ "smtp-user": { type: "string" },
74
+ mobile: { type: "string" },
56
75
  products: { type: "string" },
57
76
  images: { type: "string" },
58
77
  "store-port": { type: "string" },
@@ -88,11 +107,18 @@ async function main() {
88
107
  language: lower(values.language),
89
108
  themes: values.themes === "all" ? "all" : values.themes?.split(",").map((s) => s.trim()).filter(Boolean),
90
109
  boutiques: values.boutique,
110
+ mode: lower(values.mode),
111
+ apiUrl: values["api-url"],
112
+ filesUrl: values["files-url"],
113
+ siteUrl: values["site-url"],
91
114
  stripePk: values["stripe-pk"],
92
115
  stripeSk: values["stripe-sk"],
93
116
  stripeWhsec: values["stripe-whsec"],
94
117
  resendKey: values["resend-key"],
95
118
  emailFrom: values["email-from"],
119
+ smtpHost: values["smtp-host"],
120
+ smtpUser: values["smtp-user"],
121
+ mobile: values.mobile ? values.mobile.split(",").map((m) => m.trim()).filter(Boolean) : undefined,
96
122
  products: values.products,
97
123
  images: values.images,
98
124
  storePort: port(values["store-port"]),
@@ -104,6 +130,8 @@ async function main() {
104
130
  switch (first) {
105
131
  case "apply":
106
132
  return apply(dirArg(), up);
133
+ case "urls":
134
+ return urls(dirArg(), { api: values["api-url"], files: values["files-url"], site: values["site-url"], from: values.from }, up);
107
135
  case "add":
108
136
  return add(dirArg(), values.boutique ?? [], up);
109
137
  case "remove":
@@ -118,15 +146,18 @@ async function main() {
118
146
  currency: lower(values.currency),
119
147
  country: lower(values.country),
120
148
  language: lower(values.language),
149
+ mode: lower(values.mode),
121
150
  }, up);
122
151
  case "themes":
123
152
  return themes();
124
153
  case "update":
125
- return update(dirArg(), up);
154
+ return update(dirArg(), { ...up, yes: !!values.yes });
126
155
  case "check":
127
156
  return check(dirArg());
128
157
  case "backup":
129
- return backup(dirArg());
158
+ return void backup(dirArg(), { keep: values.keep ? Number(values.keep) : undefined });
159
+ case "restore":
160
+ return restore(dirArg(), { from: values.from, yes: !!values.yes });
130
161
  case "admin":
131
162
  return admin(dirArg());
132
163
  case "guide":