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