@salifbiaye/create-boutique 0.4.1 → 0.6.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 (45) hide show
  1. package/cli/dist/commands.js +98 -7
  2. package/cli/dist/create.js +56 -10
  3. package/cli/dist/generate.js +43 -35
  4. package/cli/dist/guide.js +2 -2
  5. package/cli/dist/index.js +20 -2
  6. package/cli/dist/project.js +42 -3
  7. package/package.json +1 -1
  8. package/templates/backend/src/admin/i18n/json/en.json +12 -2
  9. package/templates/backend/src/admin/i18n/json/fr.json +12 -2
  10. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +27 -17
  11. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +49 -6
  12. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  13. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  14. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +6 -0
  15. package/templates/backend/src/api/middlewares.ts +2 -0
  16. package/templates/backend/src/lib/active.ts +41 -0
  17. package/templates/backend/src/lib/project.ts +26 -1
  18. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  19. package/templates/backend/src/scripts/seed.ts +8 -0
  20. package/templates/storefront/locales/en.json +9 -1
  21. package/templates/storefront/locales/fr.json +9 -1
  22. package/templates/storefront/src/app/(main)/page.tsx +32 -0
  23. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  24. package/templates/storefront/src/app/(main)/template.tsx +4 -0
  25. package/templates/storefront/src/app/layout.tsx +5 -1
  26. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  27. package/templates/storefront/src/lib/boutique/schema.ts +5 -2
  28. package/templates/storefront/src/lib/config.ts +37 -8
  29. package/templates/storefront/src/lib/data/cart.ts +4 -1
  30. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  31. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  32. package/templates/storefront/src/modules/ub/album.tsx +7 -3
  33. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  34. package/templates/storefront/src/modules/ub/fx-runtime.ts +272 -0
  35. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  36. package/templates/storefront/src/modules/ub/grimoire.tsx +194 -0
  37. package/templates/storefront/src/modules/ub/motion.tsx +20 -0
  38. package/templates/storefront/src/modules/ub/surprise-button.tsx +20 -0
  39. package/templates/storefront/src/proxy.ts +2 -1
  40. package/templates/storefront/src/styles/ub/grimoire.css +178 -0
  41. package/templates/storefront/src/styles/ub/motion.css +105 -0
  42. package/templates/storefront/src/styles/ub/tech.css +7 -7
  43. package/templates/storefront/themes/halloween-2/campaign.en.json +2 -2
  44. package/templates/storefront/themes/halloween-2/campaign.fr.json +2 -2
  45. package/templates/storefront/themes/halloween-2/theme.json +3 -3
@@ -6,7 +6,7 @@ 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
8
  import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
9
- import { currencies, effective, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG } from "./project.js";
9
+ import { boutiqueUrl, currencies, effective, normalizeUrl, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG, storePorts } from "./project.js";
10
10
  import { listThemes } from "./themes.js";
11
11
  import { writeGuides } from "./guide.js";
12
12
  const STATE = (dir) => path.join(dir, ".create-boutique-state.json");
@@ -59,7 +59,7 @@ export async function apply(dir, opts = { up: true }) {
59
59
  const p = readProject(target); // lève une ProjectError détaillée si invalide
60
60
  checkCsvCurrencies(target, p); // avant toute écriture
61
61
  const others = reservedByOthers(target);
62
- const clash = [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)].filter((port) => others.has(port));
62
+ const clash = [p.apiPort, p.storagePort, ...storePorts(p)].filter((port) => others.has(port));
63
63
  if (clash.length)
64
64
  throw new ProjectError(`port(s) déjà réservé(s) par un autre projet : ${clash.join(", ")}`);
65
65
  writeCampaignFiles(target, p, p.defaults.language);
@@ -67,7 +67,7 @@ export async function apply(dir, opts = { up: true }) {
67
67
  writeCompose(target, p);
68
68
  refreshEnv(target, p);
69
69
  writeReadme(target, p);
70
- reservePorts(target, [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
70
+ reservePorts(target, [p.apiPort, p.storagePort, ...storePorts(p)]);
71
71
  const previous = fs.existsSync(STATE(target)) ? JSON.parse(fs.readFileSync(STATE(target), "utf8")).hash : null;
72
72
  const changed = previous !== hash(p);
73
73
  console.log(pc.green("✔ project.json valide, docker-compose.yml et .env régénérés"));
@@ -84,18 +84,77 @@ export async function apply(dir, opts = { up: true }) {
84
84
  fs.writeFileSync(STATE(target), JSON.stringify({ hash: hash(p), appliedAt: new Date().toISOString() }, null, 2) + "\n");
85
85
  priceWarnings(target);
86
86
  }
87
+ // ─────────────────────────── urls ───────────────────────────
88
+ /** Préfixe des images en base : <domaine>/medusa (le bucket). Accepte le domaine seul ou déjà suffixé. */
89
+ const filesPrefix = (v) => {
90
+ const t = v.trim().replace(/\/+$/, "");
91
+ return /\/medusa$/.test(t) ? t : `${t}/medusa`;
92
+ };
93
+ /**
94
+ * Change les domaines publics (admin/API, images, site en mode single), régénère .env / compose / Caddyfile.exemple,
95
+ * puis réécrit l'adresse des images déjà en base : ancienne adresse (S3_FILE_URL avant le changement,
96
+ * ou --from) → nouvelle. Relançable sans risque (ne touche que ce qui commence par l'ancienne adresse).
97
+ */
98
+ export async function urls(dir, o, opts) {
99
+ const target = path.resolve(dir);
100
+ const p = readProject(target);
101
+ const before = readEnv(target).S3_FILE_URL;
102
+ if (o.api || o.files) {
103
+ const api = o.api ? normalizeUrl(o.api, "--api-url") : p.urls?.api;
104
+ const files = o.files ? normalizeUrl(o.files, "--files-url") : p.urls?.files;
105
+ if (!api || !files)
106
+ throw new ProjectError("première configuration : --api-url et --files-url ensemble (domaine de l'admin et domaine des images)");
107
+ p.urls = { api, files };
108
+ }
109
+ if (o.site) {
110
+ if (p.mode !== "single")
111
+ throw new ProjectError("--site-url concerne le mode single ; en mode multi, mettez url sur chaque boutique dans config/project.json");
112
+ p.site = { ...p.site, url: normalizeUrl(o.site, "--site-url") };
113
+ }
114
+ if (!o.api && !o.files && !o.site && !o.from)
115
+ throw new ProjectError("rien à changer : --api-url, --files-url, --site-url ou --from");
116
+ writeProject(target, validateProject(p));
117
+ await apply(target, opts);
118
+ const after = readEnv(target).S3_FILE_URL;
119
+ console.log(`Images : ${after}`);
120
+ // Images déjà importées : réécriture de l'ancienne adresse
121
+ const from = o.from ? filesPrefix(normalizeFrom(o.from)) : before;
122
+ if (!from || from === after)
123
+ return;
124
+ if (!opts.up) {
125
+ console.log(pc.yellow(`! --no-up : images non réécrites. Après le démarrage : create-boutique urls . --from ${from}`));
126
+ return;
127
+ }
128
+ const backend = `http://localhost:${p.apiPort}`;
129
+ for (let i = 0; i < 60 && !(await fetch(`${backend}/health`).then((r) => r.ok).catch(() => false)); i++)
130
+ await new Promise((r) => setTimeout(r, 5000));
131
+ const r = composeOutput(target, ["exec", "-T", "-e", `FILES_FROM=${from}`, "-e", `FILES_TO=${after}`, "backend", "npx", "medusa", "exec", "./src/scripts/rewrite-file-urls.js"]);
132
+ const m = r.stdout.match(/<<<URLS(.*)URLS>>>/);
133
+ if (r.code !== 0 || !m)
134
+ throw new Error(`réécriture des images échouée :\n${r.stderr.slice(-800)}`);
135
+ const n = JSON.parse(m[1]);
136
+ console.log(pc.green(`✔ images réécrites : ${from} → ${after} (${n.products} produit(s), ${n.campaigns} campagne(s))`));
137
+ }
138
+ /** --from accepte aussi « localhost:9100 » : on complète le schéma. */
139
+ const normalizeFrom = (v) => (/^https?:\/\//.test(v.trim()) ? v.trim() : `http://${v.trim()}`);
87
140
  // ─────────────────────────── add / remove / set ───────────────────────────
88
141
  export async function add(dir, specs, opts) {
89
142
  if (!specs.length)
90
143
  throw new ProjectError("--boutique <slug>:<thème> obligatoire");
91
144
  const target = path.resolve(dir);
92
145
  const p = readProject(target);
93
- const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
94
- let from = Math.max(...p.boutiques.map((b) => b.port)) + 1;
146
+ const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...storePorts(p)]);
147
+ let from = Math.max(8000, ...storePorts(p)) + 1;
95
148
  for (const spec of specs) {
96
149
  const [slug, theme] = spec.split(":");
97
150
  if (!slug || !theme || !SLUG.test(slug))
98
151
  throw new ProjectError(`format attendu : --boutique <slug>:<thème> (reçu « ${spec} »)`);
152
+ if (p.mode === "single") {
153
+ // Mode single : pas de port, la boutique s'active dans admin → Campagnes
154
+ p.boutiques.push({ slug, theme });
155
+ console.log(`+ ${slug} (${theme}) : à mettre en ligne dans admin → Campagnes`);
156
+ continue;
157
+ }
99
158
  const port = await nextFreePort(from, taken);
100
159
  taken.add(port);
101
160
  from = port + 1;
@@ -119,6 +178,35 @@ export async function remove(dir, slugs, opts) {
119
178
  console.log(`- ${slugs.join(", ")} retirée(s). Ses commandes et son sales channel restent dans l'admin.`);
120
179
  await apply(target, opts);
121
180
  }
181
+ /**
182
+ * multi → single : un seul site, sur le port de la première boutique ; les boutiques perdent leur port et leur domaine.
183
+ * single → multi : chaque boutique reçoit un port libre à partir de celui du site.
184
+ */
185
+ async function switchMode(target, p, mode) {
186
+ if (mode === "single") {
187
+ const first = p.boutiques.find((b) => b.port);
188
+ p.site = { port: first?.port ?? 8001, ...(first?.url ? { url: first.url } : {}) };
189
+ for (const b of p.boutiques) {
190
+ delete b.port;
191
+ delete b.url;
192
+ }
193
+ 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`);
194
+ }
195
+ else {
196
+ const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort]);
197
+ let from = p.site?.port ?? 8001;
198
+ for (const b of p.boutiques) {
199
+ const port = await nextFreePort(from, taken);
200
+ taken.add(port);
201
+ from = port + 1;
202
+ b.port = port;
203
+ console.log(`${b.slug} → http://localhost:${port}`);
204
+ }
205
+ delete p.site;
206
+ console.log("mode multi : toutes les boutiques sont en ligne, chacune sur son port");
207
+ }
208
+ p.mode = mode;
209
+ }
122
210
  export async function set(dir, o, opts) {
123
211
  const target = path.resolve(dir);
124
212
  const p = readProject(target);
@@ -140,6 +228,8 @@ export async function set(dir, o, opts) {
140
228
  else {
141
229
  if (o.theme)
142
230
  throw new ProjectError("--theme s'applique à une boutique : ajoutez --boutique <slug>");
231
+ if (o.mode && o.mode !== p.mode)
232
+ await switchMode(target, p, o.mode);
143
233
  if (o.name)
144
234
  p.defaults.storeName = o.name;
145
235
  if (o.currency)
@@ -248,9 +338,10 @@ export async function check(dir) {
248
338
  const e = effective(p, b);
249
339
  const steps = [];
250
340
  try {
251
- const url = b.url ?? `http://localhost:${b.port}`;
341
+ const url = boutiqueUrl(p, b);
252
342
  const html = await fetch(url).then((r) => (r.ok ? r.text() : Promise.reject(new Error(`boutique HTTP ${r.status}`))));
253
- if (!html.includes(`data-theme="${b.theme}"`))
343
+ // Mode single : le site n'affiche que la boutique active
344
+ if (p.mode !== "single" && !html.includes(`data-theme="${b.theme}"`))
254
345
  throw new Error(`thème ${b.theme} absent de la page`);
255
346
  steps.push("page");
256
347
  const key = composeOutput(target, ["exec", "-T", "backend", "cat", `/app/keys/${b.slug}`]).stdout.trim();
@@ -7,7 +7,7 @@ import { writeCampaignFiles, writeCompose, writeEnv, writeReadme } from "./gener
7
7
  import { writeGuides } from "./guide.js";
8
8
  import { copyDir, copyTemplate, templateVersion } from "./paths.js";
9
9
  import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
10
- import { validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
10
+ import { normalizeUrl, storePorts, validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
11
11
  import { listThemes, themeIds } from "./themes.js";
12
12
  /** Réponses du CLI : mêmes clés pour les options, le fichier --config et les questions. */
13
13
  export const answersSchema = z.object({
@@ -18,6 +18,13 @@ export const answersSchema = z.object({
18
18
  language: z.string().regex(/^[a-z]{2}$/).optional(),
19
19
  themes: z.union([z.literal("all"), z.array(z.string())]).optional(),
20
20
  boutiques: z.array(z.string().regex(/^[a-z0-9-]+:[a-z0-9-]+$/, "format slug:thème")).optional(),
21
+ /** multi : un site par boutique ; single : un seul site, la boutique active se choisit dans l'admin */
22
+ mode: z.enum(["multi", "single"]).optional(),
23
+ /** Production : domaines publics de l'admin/API et des images (le CLI ajoute /medusa pour les images) */
24
+ apiUrl: z.string().optional(),
25
+ filesUrl: z.string().optional(),
26
+ /** Mode single : domaine du site */
27
+ siteUrl: z.string().optional(),
21
28
  stripePk: z.string().optional(),
22
29
  stripeSk: z.string().optional(),
23
30
  stripeWhsec: z.string().optional(),
@@ -88,6 +95,22 @@ async function ask(a, dirName, yes) {
88
95
  extra.push(`${slug}:${theme}`);
89
96
  }
90
97
  }
98
+ const mode = a.mode ??
99
+ cancelled(await p.select({
100
+ message: "Sites",
101
+ options: [
102
+ { value: "multi", label: "Un site par boutique", hint: "toutes en ligne, chacune sur son port / domaine" },
103
+ { value: "single", label: "Un seul site", hint: "une boutique en ligne à la fois, choisie dans l'admin → Campagnes" },
104
+ ],
105
+ initialValue: "multi",
106
+ }));
107
+ // Adresses publiques (production) : inscrites dans les images importées, donc à donner avant le premier démarrage
108
+ const apiUrl = a.apiUrl ??
109
+ (cancelled(await p.text({ message: "Domaine de l'admin / API en production (vide = test en local)", placeholder: "https://admin.marque.com", defaultValue: "" })).trim() ||
110
+ undefined);
111
+ const filesUrl = apiUrl
112
+ ? (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())
113
+ : a.filesUrl;
91
114
  const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
92
115
  const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
93
116
  // E-mails clients (confirmation, expédition, mot de passe oublié…) via Resend ; vide = aucun e-mail envoyé.
@@ -112,7 +135,7 @@ async function ask(a, dirName, yes) {
112
135
  };
113
136
  const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "", validate: exists("file") })).trim() || undefined);
114
137
  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 };
138
+ return { ...a, name, email, currency, country, language, mode, apiUrl, filesUrl, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
116
139
  }
117
140
  export async function create(dir, input, opts) {
118
141
  const target = path.resolve(dir);
@@ -144,19 +167,39 @@ export async function create(dir, input, opts) {
144
167
  taken.add(apiPort);
145
168
  const storagePort = await nextFreePort(a.storagePort ?? 9100, taken);
146
169
  taken.add(storagePort);
170
+ const mode = a.mode ?? "multi";
147
171
  let nextStore = a.storePort ?? 8001;
148
172
  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 });
173
+ let site;
174
+ if (mode === "single") {
175
+ // Un seul site : un port pour tout le projet, les boutiques n'en ont pas
176
+ site = { port: await nextFreePort(nextStore, taken) };
177
+ taken.add(site.port);
178
+ for (const w of wanted)
179
+ boutiques.push({ slug: w.slug, theme: w.theme });
180
+ }
181
+ else {
182
+ for (const w of wanted) {
183
+ const port = await nextFreePort(nextStore, taken);
184
+ taken.add(port);
185
+ nextStore = port + 1;
186
+ boutiques.push({ slug: w.slug, theme: w.theme, port });
187
+ }
154
188
  }
189
+ // Adresses publiques : les deux ensemble (les images importées gardent l'adresse du stockage)
190
+ if (!!a.apiUrl !== !!a.filesUrl)
191
+ throw new ProjectError("--api-url et --files-url vont ensemble (domaine de l'admin et domaine des images)");
192
+ const urls = a.apiUrl && a.filesUrl ? { api: normalizeUrl(a.apiUrl, "--api-url"), files: normalizeUrl(a.filesUrl, "--files-url") } : undefined;
193
+ if (site && a.siteUrl)
194
+ Object.assign(site, { url: normalizeUrl(a.siteUrl, "--site-url") });
155
195
  const proj = validateProject({
156
196
  project,
157
197
  templateVersion: templateVersion(),
158
198
  apiPort,
159
199
  storagePort,
200
+ mode,
201
+ ...(site ? { site } : {}),
202
+ ...(urls ? { urls } : {}),
160
203
  defaults: {
161
204
  storeName: a.name,
162
205
  currency: a.currency,
@@ -171,8 +214,11 @@ export async function create(dir, input, opts) {
171
214
  // Récapitulatif
172
215
  const width = Math.max(...proj.boutiques.map((b) => b.slug.length)) + 2;
173
216
  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`,
217
+ ...(proj.mode === "single"
218
+ ? [`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}`)]
219
+ : proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(16)}→ http://localhost:${b.port}`)),
220
+ `${"Backend / admin".padEnd(width + 16)}→ ${proj.urls ? `${proj.urls.api}/app` : `http://localhost:${proj.apiPort}/app`}`,
221
+ `Images : ${proj.urls ? `${proj.urls.files}/medusa` : `http://localhost:${proj.storagePort}/medusa (local : à changer avant la production avec create-boutique urls)`}`,
176
222
  `Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
177
223
  `Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
178
224
  `E-mails : ${a.resendKey ? `Resend, depuis ${a.emailFrom ?? "(adresse manquante)"}` : "désactivés (pas de clé Resend)"}`,
@@ -209,7 +255,7 @@ export async function create(dir, input, opts) {
209
255
  writeCompose(target, proj);
210
256
  writeReadme(target, proj);
211
257
  fs.writeFileSync(path.join(target, ".gitignore"), ".env\nbackups/\nnode_modules/\n");
212
- reservePorts(target, [proj.apiPort, proj.storagePort, ...proj.boutiques.map((b) => b.port)]);
258
+ reservePorts(target, [proj.apiPort, proj.storagePort, ...storePorts(proj)]);
213
259
  for (const g of writeGuides(target, proj, a.email)) {
214
260
  if (!g.pdf)
215
261
  console.log(pc.yellow(`Chrome/Edge introuvable : ${path.basename(g.file)} à imprimer en PDF.`));
@@ -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}`);
@@ -309,7 +317,7 @@ export function writeReadme(dir, p) {
309
317
  const rows = p.boutiques
310
318
  .map((b) => {
311
319
  const e = effective(p, b);
312
- return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} | http://localhost:${b.port} |`;
320
+ return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} | ${boutiqueUrl(p, b)} |`;
313
321
  })
314
322
  .join("\n");
315
323
  const text = `# ${p.defaults.storeName} — projet \`${p.project}\`
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() {
@@ -52,7 +52,7 @@ export function writeGuides(dir, p, adminEmail, support = "") {
52
52
  const stores = p.boutiques
53
53
  .map((b) => {
54
54
  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>`;
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(boutiqueUrl(p, b))}</td></tr>`;
56
56
  })
57
57
  .join("");
58
58
  const vars = {
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, 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,6 +14,9 @@ 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
21
  --resend-key / --email-from e-mails (optionnels)
19
22
  --products <csv> --images <dossier> catalogue (optionnel)
@@ -22,8 +25,11 @@ const HELP = `create-boutique — Usine à Boutiques
22
25
  --yes aucune question, valeurs par défaut
23
26
 
24
27
  create-boutique apply <dossier> [--no-up] applique config/project.json
28
+ create-boutique urls <dossier> [--api-url …] [--files-url …] [--site-url …] [--from <ancienne adresse des images>]
29
+ change les domaines publics ; réécrit l'adresse des images déjà en base
25
30
  create-boutique add <dossier> --boutique <slug>:<thème>
26
31
  create-boutique remove <dossier> --boutique <slug>
32
+ create-boutique set <dossier> [--mode multi|single] change de mode (un site par boutique ↔ un seul site)
27
33
  create-boutique set <dossier> [--boutique <slug>] [--name …] [--theme …] [--currency …] [--country …] [--language …]
28
34
  create-boutique themes liste les thèmes
29
35
  create-boutique update <dossier> [--no-up] met à jour le code (config, .env, données conservés)
@@ -35,7 +41,7 @@ const HELP = `create-boutique — Usine à Boutiques
35
41
  create-boutique campaign export <dossier> campagnes en base → config/campaigns/*.json
36
42
  create-boutique campaign import <dossier> [--boutique <slug>] [--yes] JSON → base (écrase)
37
43
  `;
38
- const COMMANDS = new Set(["apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
44
+ const COMMANDS = new Set(["urls", "apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
39
45
  async function main() {
40
46
  const { values, positionals } = parseArgs({
41
47
  allowPositionals: true,
@@ -48,6 +54,11 @@ async function main() {
48
54
  themes: { type: "string" },
49
55
  boutique: { type: "string", multiple: true },
50
56
  theme: { type: "string" },
57
+ mode: { type: "string" },
58
+ "api-url": { type: "string" },
59
+ "files-url": { type: "string" },
60
+ "site-url": { type: "string" },
61
+ from: { type: "string" },
51
62
  "stripe-pk": { type: "string" },
52
63
  "stripe-sk": { type: "string" },
53
64
  "stripe-whsec": { type: "string" },
@@ -88,6 +99,10 @@ async function main() {
88
99
  language: lower(values.language),
89
100
  themes: values.themes === "all" ? "all" : values.themes?.split(",").map((s) => s.trim()).filter(Boolean),
90
101
  boutiques: values.boutique,
102
+ mode: lower(values.mode),
103
+ apiUrl: values["api-url"],
104
+ filesUrl: values["files-url"],
105
+ siteUrl: values["site-url"],
91
106
  stripePk: values["stripe-pk"],
92
107
  stripeSk: values["stripe-sk"],
93
108
  stripeWhsec: values["stripe-whsec"],
@@ -104,6 +119,8 @@ async function main() {
104
119
  switch (first) {
105
120
  case "apply":
106
121
  return apply(dirArg(), up);
122
+ case "urls":
123
+ return urls(dirArg(), { api: values["api-url"], files: values["files-url"], site: values["site-url"], from: values.from }, up);
107
124
  case "add":
108
125
  return add(dirArg(), values.boutique ?? [], up);
109
126
  case "remove":
@@ -118,6 +135,7 @@ async function main() {
118
135
  currency: lower(values.currency),
119
136
  country: lower(values.country),
120
137
  language: lower(values.language),
138
+ mode: lower(values.mode),
121
139
  }, up);
122
140
  case "themes":
123
141
  return themes();
@@ -16,8 +16,14 @@ export const projectSchema = z.object({
16
16
  templateVersion: z.string().optional(),
17
17
  apiPort: z.number().int().min(1).max(65535).default(9000),
18
18
  storagePort: z.number().int().min(1).max(65535).default(9100),
19
- /** Production : adresses publiques (https) du backend/admin et des images. Présent = Caddy + https dans le compose. */
19
+ /** Production : adresses publiques (https) du backend/admin et des images. Présent = ports sur 127.0.0.1 (ton reverse proxy les expose), Caddyfile.exemple fourni. */
20
20
  urls: z.strictObject({ api: z.url(), files: z.url() }).optional(),
21
+ /**
22
+ * multi (défaut) : un site par boutique, toutes en ligne (1 container, 1 port, 1 domaine chacune).
23
+ * single : un seul site (site.port, site.url) qui affiche la boutique active, choisie dans admin → Campagnes.
24
+ */
25
+ mode: z.enum(["multi", "single"]).default("multi"),
26
+ site: z.strictObject({ port: z.number().int().min(1).max(65535), url: z.url().optional() }).optional(),
21
27
  defaults: z.looseObject({
22
28
  storeName: z.string().min(1).max(60),
23
29
  currency: code3,
@@ -31,7 +37,8 @@ export const projectSchema = z.object({
31
37
  .array(z.strictObject({
32
38
  slug: z.string().regex(SLUG, "slug : minuscules, chiffres et tirets"),
33
39
  theme: z.string(),
34
- port: z.number().int().min(1).max(65535),
40
+ /** Mode multi : port du site de la boutique (inutile en mode single) */
41
+ port: z.number().int().min(1).max(65535).optional(),
35
42
  url: z.url().optional(),
36
43
  storeName: z.string().min(1).max(60).optional(),
37
44
  currency: code3.optional(),
@@ -59,7 +66,13 @@ export function validateProject(raw) {
59
66
  for (const b of p.boutiques)
60
67
  if (!themes.includes(b.theme))
61
68
  errors.push(`${b.slug} : thème inconnu « ${b.theme} » (disponibles : ${themes.join(", ")})`);
62
- const ports = [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)];
69
+ if (p.mode === "single" && !p.site)
70
+ errors.push("mode single : bloc site { port } obligatoire");
71
+ if (p.mode === "multi")
72
+ for (const b of p.boutiques)
73
+ if (!b.port)
74
+ errors.push(`${b.slug} : port obligatoire (mode multi)`);
75
+ const ports = [p.apiPort, p.storagePort, ...storePorts(p)];
63
76
  for (const port of new Set(ports))
64
77
  if (ports.filter((x) => x === port).length > 1)
65
78
  errors.push(`port utilisé deux fois : ${port}`);
@@ -119,3 +132,29 @@ export function writeJsonSchema(dir) {
119
132
  const schema = z.toJSONSchema(projectSchema, { io: "input", unrepresentable: "any" });
120
133
  fs.writeFileSync(path.join(dir, "config", "project.schema.json"), JSON.stringify(schema, null, 2) + "\n");
121
134
  }
135
+ /**
136
+ * Adresse publique saisie par l'utilisateur → origine seule (https://images.marque.com).
137
+ * Accepte « images.marque.com », une barre finale ou « /medusa » (ajouté ensuite par le CLI pour S3_FILE_URL).
138
+ */
139
+ export function normalizeUrl(v, what) {
140
+ const raw = /^https?:\/\//.test(v.trim()) ? v.trim() : `https://${v.trim()}`;
141
+ let u;
142
+ try {
143
+ u = new URL(raw);
144
+ }
145
+ catch {
146
+ throw new ProjectError(`${what} : adresse invalide « ${v} » (ex. https://images.marque.com)`);
147
+ }
148
+ const path = u.pathname.replace(/\/+$/, "").replace(/\/medusa$/, "");
149
+ if (path)
150
+ throw new ProjectError(`${what} : donnez seulement le domaine (ex. https://images.marque.com), sans chemin « ${u.pathname} »`);
151
+ return u.origin;
152
+ }
153
+ export function fronts(p) {
154
+ if (p.mode === "single")
155
+ return p.site ? [{ service: "storefront", boutique: "auto", port: p.site.port, url: p.site.url }] : [];
156
+ return p.boutiques.filter((b) => b.port).map((b) => ({ service: `storefront-${b.slug}`, boutique: b.slug, port: b.port, url: b.url }));
157
+ }
158
+ export const storePorts = (p) => fronts(p).map((f) => f.port);
159
+ /** Adresse publique d'une boutique (en mode single : celle du site unique). */
160
+ export const boutiqueUrl = (p, b) => p.mode === "single" ? (p.site?.url ?? `http://localhost:${p.site?.port}`) : (b.url ?? `http://localhost:${b.port}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Usine à Boutiques : génère un projet complet (backend Medusa + une boutique Next.js par campagne) prêt pour docker compose",
5
5
  "type": "module",
6
6
  "repository": {
@@ -16,7 +16,7 @@
16
16
  "saved": "Campaign saved",
17
17
  "reset": "Reset to theme texts",
18
18
  "resetDone": "Theme texts restored",
19
- "emptyHint": "Leave empty to use the theme text.",
19
+ "emptyHint": "Fields show the current texts (the theme ones by default): change what you want. {discount}, {endDate} and {freeShipping} are replaced automatically by the discount, end date and free-shipping threshold.",
20
20
  "sections": {
21
21
  "dates": "Dates",
22
22
  "hero": "Hero",
@@ -66,6 +66,16 @@
66
66
  "save": "Save image",
67
67
  "saved": "Image saved"
68
68
  },
69
- "shareHint": "What shows when the store link is shared (WhatsApp, Facebook, LinkedIn…). Empty = store name, subtitle and main visual."
69
+ "shareHint": "What shows when the store link is shared (WhatsApp, Facebook, LinkedIn…). Empty = store name, subtitle and main visual.",
70
+ "online": {
71
+ "column": "Site",
72
+ "live": "Live",
73
+ "action": "Put live",
74
+ "hint": "Single site: the “Live” store is the one customers see. Orders and in-progress carts of the other stores are not lost.",
75
+ "confirmTitle": "Change the live store?",
76
+ "confirmText": "The site will switch from {{from}} to {{to}}. Customers paying right now can finish their order.",
77
+ "confirm": "Put live",
78
+ "done": "Store is live: the site has switched."
79
+ }
70
80
  }
71
81
  }