@salifbiaye/create-boutique 0.1.4 → 0.2.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 (72) hide show
  1. package/cli/dist/commands.js +3 -3
  2. package/cli/dist/create.js +5 -5
  3. package/cli/dist/generate.js +68 -10
  4. package/cli/dist/project.js +2 -0
  5. package/cli/dist/themes.js +5 -1
  6. package/docs/guide-complet.en.html +11 -0
  7. package/docs/guide-complet.fr.html +11 -0
  8. package/package.json +1 -1
  9. package/templates/backend/src/lib/emails.ts +8 -1
  10. package/templates/backend/src/scripts/campaign-import.ts +6 -4
  11. package/templates/backend/src/scripts/seed.ts +15 -3
  12. package/templates/storefront/locales/en.json +40 -3
  13. package/templates/storefront/locales/fr.json +40 -3
  14. package/templates/storefront/package-lock.json +7 -0
  15. package/templates/storefront/package.json +1 -0
  16. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
  17. package/templates/storefront/src/app/(main)/page.tsx +25 -0
  18. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  19. package/templates/storefront/src/app/layout.tsx +9 -2
  20. package/templates/storefront/src/lib/boutique/config.ts +1 -0
  21. package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
  22. package/templates/storefront/src/lib/boutique/schema.ts +2 -0
  23. package/templates/storefront/src/lib/constants.tsx +5 -4
  24. package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
  25. package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
  26. package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
  27. package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
  28. package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
  29. package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
  30. package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
  31. package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
  32. package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
  33. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +2 -1
  34. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +65 -48
  35. package/templates/storefront/src/modules/checkout/components/payment-container/index.tsx +23 -29
  36. package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx +51 -6
  37. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
  38. package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
  39. package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
  40. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
  41. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
  42. package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
  43. package/templates/storefront/src/modules/ub/footer.tsx +26 -5
  44. package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
  45. package/templates/storefront/src/modules/ub/nav.tsx +2 -0
  46. package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
  47. package/templates/storefront/src/proxy.ts +3 -3
  48. package/templates/storefront/src/styles/ub/account.css +199 -0
  49. package/templates/storefront/src/styles/ub/black-friday.css +96 -0
  50. package/templates/storefront/src/styles/ub/forms.css +61 -0
  51. package/templates/storefront/src/styles/ub/pages.css +382 -0
  52. package/templates/storefront/src/styles/ub/responsive.css +57 -0
  53. package/templates/storefront/src/styles/ub/showcase.css +85 -0
  54. package/templates/storefront/src/styles/ub/variants.css +64 -0
  55. package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
  56. package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
  57. package/templates/storefront/themes/black-friday-2/theme.json +10 -0
  58. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
  59. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
  60. package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
  61. package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
  62. package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
  63. package/templates/storefront/themes/halloween-2/theme.json +10 -0
  64. package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
  65. package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
  66. package/templates/storefront/themes/noel-2/theme.json +10 -0
  67. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
  68. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
  69. package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
  70. package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
  71. package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
  72. package/templates/storefront/themes/thanksgiving-2/theme.json +10 -0
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import pc from "picocolors";
5
5
  import { compose, composeOutput, isRunning } from "./docker.js";
6
- import { composeYaml, readEnv, refreshEnv, writeCampaignFiles, writeReadme } from "./generate.js";
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
9
  import { currencies, effective, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG } from "./project.js";
@@ -64,7 +64,7 @@ export async function apply(dir, opts = { up: true }) {
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);
66
66
  writeJsonSchema(target);
67
- fs.writeFileSync(path.join(target, "docker-compose.yml"), composeYaml(p));
67
+ writeCompose(target, p);
68
68
  refreshEnv(target, p);
69
69
  writeReadme(target, p);
70
70
  reservePorts(target, [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
@@ -155,7 +155,7 @@ export async function set(dir, o, opts) {
155
155
  // ─────────────────────────── themes ───────────────────────────
156
156
  export function themes() {
157
157
  for (const t of listThemes())
158
- console.log(`${t.id.padEnd(16)} ${t.name.padEnd(16)} langues : ${t.languages.join(", ")}`);
158
+ console.log(`${t.id.padEnd(16)} ${t.name.padEnd(26)} langues : ${t.languages.join(", ")}`);
159
159
  }
160
160
  // ─────────────────────────── update ───────────────────────────
161
161
  /** Remplace storefront/ et backend/ par la version actuelle du template (config, seed, .env et données conservés). */
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import * as p from "@clack/prompts";
4
4
  import pc from "picocolors";
5
5
  import { z } from "zod";
6
- import { composeYaml, writeCampaignFiles, writeEnv, writeReadme } from "./generate.js";
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
9
  import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
@@ -159,8 +159,8 @@ export async function create(dir, input, opts) {
159
159
  // Récapitulatif
160
160
  const width = Math.max(...proj.boutiques.map((b) => b.slug.length)) + 2;
161
161
  const lines = [
162
- ...proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(14)}→ http://localhost:${b.port}`),
163
- `${"Backend / admin".padEnd(width + 14)}→ http://localhost:${proj.apiPort}/app`,
162
+ ...proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(16)}→ http://localhost:${b.port}`),
163
+ `${"Backend / admin".padEnd(width + 16)}→ http://localhost:${proj.apiPort}/app`,
164
164
  `Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
165
165
  `Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
166
166
  `Catalogue : ${a.products ?? "aucun (à ajouter dans l'admin)"}`,
@@ -179,12 +179,12 @@ export async function create(dir, input, opts) {
179
179
  copyTemplate("backend", target);
180
180
  writeProject(target, proj);
181
181
  writeJsonSchema(target);
182
- writeCampaignFiles(target, proj, a.language);
183
182
  fs.mkdirSync(path.join(target, "seed", "images"), { recursive: true });
184
183
  if (a.products)
185
184
  fs.copyFileSync(path.resolve(a.products), path.join(target, "seed", "products.csv"));
186
185
  if (a.images)
187
186
  copyDir(path.resolve(a.images), path.join(target, "seed", "images"));
187
+ writeCampaignFiles(target, proj, a.language); // après les images : hero.* devient le visuel du hero
188
188
  writeEnv(target, proj, {
189
189
  adminEmail: a.email,
190
190
  stripePk: a.stripePk,
@@ -193,7 +193,7 @@ export async function create(dir, input, opts) {
193
193
  resendKey: a.resendKey,
194
194
  emailFrom: a.emailFrom,
195
195
  });
196
- fs.writeFileSync(path.join(target, "docker-compose.yml"), composeYaml(proj));
196
+ writeCompose(target, proj);
197
197
  writeReadme(target, proj);
198
198
  fs.writeFileSync(path.join(target, ".gitignore"), ".env\nbackups/\nnode_modules/\n");
199
199
  reservePorts(target, [proj.apiPort, proj.storagePort, ...proj.boutiques.map((b) => b.port)]);
@@ -4,11 +4,14 @@ import path from "node:path";
4
4
  import { currencies, effective } 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). */
8
+ const bind = (p, port) => (p.urls ? `127.0.0.1:${port}` : String(port));
9
+ const host = (url) => new URL(url).host;
7
10
  export function composeYaml(p) {
8
11
  const services = p.boutiques
9
12
  .map((b) => ` storefront-${b.slug}:
10
13
  <<: *storefront
11
- ports: ["${b.port}:8000"]
14
+ ports: ["${bind(p, b.port)}:8000"]
12
15
  environment: { <<: *storefront-env, BOUTIQUE: ${b.slug} }
13
16
  `)
14
17
  .join("\n");
@@ -44,7 +47,7 @@ ${services}
44
47
  DATABASE_URL: postgres://medusa:\${POSTGRES_PASSWORD}@postgres:5432/medusa?sslmode=disable
45
48
  REDIS_URL: redis://redis:6379
46
49
  S3_ENDPOINT: http://storage:9000
47
- ports: ["${p.apiPort}:9000"] # API + admin (/app)
50
+ ports: ["${bind(p, p.apiPort)}:9000"] # API + admin (/app)
48
51
  volumes:
49
52
  - ./config:/app/config:ro # boutiques, devises, pays → régions, sales channels, clés
50
53
  - ./seed:/app/seed:ro # products.csv + images à importer
@@ -85,30 +88,68 @@ ${services}
85
88
  RUSTFS_SECRET_KEY: \${S3_SECRET_ACCESS_KEY}
86
89
  # Seule exception au « pas de port public » : les navigateurs chargent les images ici.
87
90
  # Le backend rend le bucket lisible (lecture seule) ; l'écriture exige les identifiants.
88
- ports: ["${p.storagePort}:9000"]
91
+ ports: ["${bind(p, p.storagePort)}:9000"]
89
92
  volumes: [storage_data:/data]
90
93
  healthcheck:
91
94
  test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/health >/dev/null || exit 1"]
92
95
  interval: 5s
93
96
  retries: 20
94
-
97
+ ${p.urls ? caddyService : ""}
95
98
  volumes:
96
99
  pg_data:
97
100
  redis_data:
98
101
  storage_data:
99
- keys:
102
+ keys:${p.urls ? "\n caddy_data:\n caddy_config:" : ""}
103
+ `;
104
+ }
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 }
100
117
  `;
118
+ /** Caddyfile (production) : chaque domaine vers son container ; certificats https obtenus et renouvelés par Caddy. */
119
+ export function caddyfile(p) {
120
+ const site = (url, upstream) => `${host(url)} {
121
+ encode zstd gzip
122
+ reverse_proxy ${upstream}
123
+ }
124
+ `;
125
+ 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"),
130
+ ].join("\n");
131
+ }
132
+ /** Écrit docker-compose.yml (+ Caddyfile en production, supprimé sinon). */
133
+ export function writeCompose(dir, p) {
134
+ fs.writeFileSync(path.join(dir, "docker-compose.yml"), composeYaml(p));
135
+ const file = path.join(dir, "Caddyfile");
136
+ if (p.urls)
137
+ fs.writeFileSync(file, caddyfile(p));
138
+ else if (fs.existsSync(file))
139
+ fs.rmSync(file);
101
140
  }
102
141
  /** Variables qui dépendent de project.json (réécrites à chaque apply). Les secrets ne sont jamais régénérés. */
103
142
  function derivedEnv(p) {
104
143
  const origins = p.boutiques.map((b) => b.url ?? `http://localhost:${b.port}`);
105
- const admin = `http://localhost:${p.apiPort}`;
144
+ const trim = (u) => u.replace(/\/+$/, "");
145
+ const admin = trim(p.urls?.api ?? `http://localhost:${p.apiPort}`);
146
+ const files = trim(p.urls?.files ?? `http://localhost:${p.storagePort}`);
106
147
  return {
107
148
  PROJECT: p.project,
108
149
  STORE_CORS: origins.join(","),
109
150
  ADMIN_CORS: admin,
110
151
  AUTH_CORS: admin,
111
- S3_FILE_URL: `http://localhost:${p.storagePort}/medusa`,
152
+ S3_FILE_URL: `${files}/medusa`,
112
153
  };
113
154
  }
114
155
  export function writeEnv(dir, p, s) {
@@ -188,11 +229,23 @@ const day = (d, add = 0, end = false) => {
188
229
  x.setUTCHours(end ? 23 : 0, end ? 59 : 0, end ? 59 : 0, 0);
189
230
  return x;
190
231
  };
232
+ /** Événement d'un thème : « black-friday-2 » → « black-friday ». */
233
+ const baseTheme = (theme) => theme.replace(/-\d+$/, "");
234
+ /** Visuel du hero dans seed/images : hero-<slug>.*, sinon hero-<thème>.*, sinon hero.* (importé par le seed). */
235
+ function heroImage(images, slug, theme) {
236
+ for (const name of [`hero-${slug}`, `hero-${theme}`, "hero"]) {
237
+ const found = images.find((f) => /\.(png|jpe?g|webp)$/i.test(f) && f.replace(/\.[^.]+$/, "").toLowerCase() === name);
238
+ if (found)
239
+ return found;
240
+ }
241
+ return undefined;
242
+ }
191
243
  /** Dates de la prochaine occurrence de la campagne (si la saison est passée, l'année suivante). */
192
244
  export function campaignDates(theme, now = new Date()) {
193
245
  const build = (y) => {
194
246
  const tg = thanksgiving(y);
195
- switch (theme) {
247
+ // Variantes (black-friday-2…) : mêmes dates que leur événement
248
+ switch (baseTheme(theme)) {
196
249
  case "halloween":
197
250
  return [new Date(Date.UTC(y, 9, 20)), day(new Date(Date.UTC(y, 9, 31)), 0, true)];
198
251
  case "thanksgiving":
@@ -222,7 +275,9 @@ export function writeCampaignFiles(dir, p, language) {
222
275
  fs.mkdirSync(campaignsDir, { recursive: true });
223
276
  const locale = `${language}-${p.defaults.country.toUpperCase()}`;
224
277
  // Campagne suivante : prochaine boutique dans l'ordre des saisons.
225
- const ordered = [...p.boutiques].sort((a, b) => THEME_ORDER.indexOf(a.theme) - THEME_ORDER.indexOf(b.theme));
278
+ const ordered = [...p.boutiques].sort((a, b) => THEME_ORDER.indexOf(baseTheme(a.theme)) - THEME_ORDER.indexOf(baseTheme(b.theme)));
279
+ const imagesDir = path.join(dir, "seed", "images");
280
+ const images = fs.existsSync(imagesDir) ? fs.readdirSync(imagesDir) : [];
226
281
  for (const b of p.boutiques) {
227
282
  const file = path.join(campaignsDir, `${b.slug}.json`);
228
283
  if (fs.existsSync(file))
@@ -235,8 +290,11 @@ export function writeCampaignFiles(dir, p, language) {
235
290
  const campaign = {
236
291
  ...campaignDates(b.theme),
237
292
  ...(next ? { nextCampaign: next } : {}),
238
- vars: { discount: DISCOUNT[b.theme] ?? "20%", freeShipping },
293
+ vars: { discount: DISCOUNT[b.theme] ?? DISCOUNT[baseTheme(b.theme)] ?? "20%", freeShipping },
239
294
  };
295
+ const hero = heroImage(images, b.slug, b.theme);
296
+ if (hero)
297
+ Object.assign(campaign, { hero: { image: hero } });
240
298
  fs.writeFileSync(file, JSON.stringify(campaign, null, 2) + "\n");
241
299
  }
242
300
  }
@@ -16,6 +16,8 @@ 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. */
20
+ urls: z.strictObject({ api: z.url(), files: z.url() }).optional(),
19
21
  defaults: z.looseObject({
20
22
  storeName: z.string().min(1).max(60),
21
23
  currency: code3,
@@ -3,7 +3,11 @@ import path from "node:path";
3
3
  import { THEMES_DIR } from "./paths.js";
4
4
  // Ordre des saisons ; un thème ajouté plus tard se range après, par ordre alphabétique.
5
5
  const SEASONS = ["halloween", "thanksgiving", "black-friday", "cyber-monday", "noel", "nouvel-an"];
6
- const rank = (id) => (SEASONS.includes(id) ? SEASONS.indexOf(id) : SEASONS.length);
6
+ // Variantes (halloween-2…) : rangées juste après leur événement.
7
+ const rank = (id) => {
8
+ const base = id.replace(/-\d+$/, "");
9
+ return SEASONS.includes(base) ? SEASONS.indexOf(base) : SEASONS.length;
10
+ };
7
11
  /** Registre des thèmes : un dossier templates/storefront/themes/<id>/ avec theme.json. */
8
12
  export function listThemes() {
9
13
  if (!fs.existsSync(THEMES_DIR))
@@ -168,6 +168,7 @@ create-boutique check . # a test order in each store: OK / KO</
168
168
  <li><b>Sale price (strikethrough)</b>: <span class="menu">Price Lists → Create</span>, type <b>Sale</b>, start and end dates, add the products and the sale price per currency. The site shows the old price struck through and the percentage.</li>
169
169
  <li><b>Prices for a group</b> (e.g. VIP customers, resellers): price list of type <b>Override</b> limited to a customer group (B11).</li>
170
170
  </ul>
171
+ <div class="box warn">A price list is not limited to one store: the sale price applies in <b>every store</b> that sells the product (in the same currency). To discount a single store whose products are also sold elsewhere, use a promotion with a <b>Sales channel</b> condition (B10).</div>
171
172
 
172
173
  <h2>B6. Categories and collections</h2>
173
174
  <ul>
@@ -204,6 +205,7 @@ create-boutique check . # a test order in each store: OK / KO</
204
205
  </table>
205
206
  <p>An empty field uses the theme text. <b>Reset to theme texts</b> clears your texts (images, logo and dates are kept). Plain text only; images jpg, png or webp, 5 MB max.</p>
206
207
  <div class="box info">Not to be confused with Medusa's <span class="menu">Promotions → Campaigns</span>, which groups discount codes with a budget (B10).</div>
208
+ <div class="box warn">Campaign texts and dates only <b>display</b> the offer ("up to −35%", countdown). They apply <b>no discount</b>: create the discount itself in <span class="menu">Promotions</span> (B10) or <span class="menu">Price Lists</span> (B5), with the same dates, and check that the text matches.</div>
207
209
 
208
210
  <h2>B10. Discount codes and promotions</h2>
209
211
  <p><span class="menu">Promotions → Create</span>:</p>
@@ -220,6 +222,15 @@ create-boutique check . # a test order in each store: OK / KO</
220
222
  <li><b>Limits and dates</b>: attach the promotion to a <b>promotion campaign</b> (start/end dates, budget in number of uses or amount).</li>
221
223
  <li>Disable: set the promotion to <b>Draft</b>.</li>
222
224
  </ul>
225
+ <h3>Discount on a whole store (e.g. −35% on the New Year store)</h3>
226
+ <ol>
227
+ <li><span class="menu">Promotions → Create</span>, type <b>Automatic</b> (no code) or <b>Code</b>.</li>
228
+ <li>Template <b>Percentage off order</b>, value <b>35</b>.</li>
229
+ <li>Conditions ("who can use it"): <b>Sales channel</b> = the store identifier (first page).</li>
230
+ <li>Attach it to a promotion campaign with a <b>start and end date</b> (the same as the store campaign, B9).</li>
231
+ <li>Check: a product in this store's cart shows the −35% line; in another store, no discount.</li>
232
+ </ol>
233
+ <p>The discount covers every item in the cart (not shipping) and shows <b>in the cart</b>. To show a <b>strikethrough price</b> on product pages, use a "Sale" price list instead (B5). To exclude products: template <b>Percentage off products</b> targeting a category or collection (e.g. "Sale").</p>
223
234
  <div class="box warn">An amount discount (e.g. −10) applies in the chosen currency: create one promotion per currency if needed. Test each code in the right store before announcing it.</div>
224
235
 
225
236
  <h2>B11. Customers and groups</h2>
@@ -168,6 +168,7 @@ create-boutique check . # une commande test dans chaque boutiqu
168
168
  <li><b>Prix barré (soldes)</b> : <span class="menu">Listes de prix → Créer</span>, type <b>Soldes (Sale)</b>, dates de début et de fin, ajouter les produits et le prix soldé par devise. Le site affiche l'ancien prix barré et le pourcentage.</li>
169
169
  <li><b>Prix réservés à un groupe</b> (ex. clients VIP, revendeurs) : liste de prix de type <b>Remplacement (Override)</b> limitée à un groupe de clients (B11).</li>
170
170
  </ul>
171
+ <div class="box warn">Une liste de prix ne se limite pas à une boutique : le prix soldé s'applique dans <b>toutes les boutiques</b> qui vendent le produit (dans la même devise). Pour solder une seule boutique alors que ses produits sont aussi vendus ailleurs, utilisez une promotion avec la condition <b>Canal de vente</b> (B10).</div>
171
172
 
172
173
  <h2>B6. Catégories et collections</h2>
173
174
  <ul>
@@ -204,6 +205,7 @@ create-boutique check . # une commande test dans chaque boutiqu
204
205
  </table>
205
206
  <p>Un champ laissé vide reprend le texte du thème. <b>Réinitialiser aux textes du thème</b> efface vos textes (images, logo et dates sont gardés). Texte simple uniquement ; images jpg, png ou webp, 5 Mo max.</p>
206
207
  <div class="box info">Ne pas confondre avec <span class="menu">Promotions → Campagnes</span> de Medusa, qui regroupe des codes promo avec un budget (B10).</div>
208
+ <div class="box warn">Les textes et les dates de campagne ne font qu'<b>afficher</b> l'offre (« jusqu'à −35 % », compte à rebours). Ils n'appliquent <b>aucune remise</b> : créez la remise elle-même dans <span class="menu">Promotions</span> (B10) ou <span class="menu">Listes de prix</span> (B5), avec les mêmes dates, et vérifiez que le texte correspond.</div>
207
209
 
208
210
  <h2>B10. Codes promo et promotions</h2>
209
211
  <p><span class="menu">Promotions → Créer</span> :</p>
@@ -220,6 +222,15 @@ create-boutique check . # une commande test dans chaque boutiqu
220
222
  <li><b>Limites et dates</b> : rattacher la promotion à une <b>campagne de promotion</b> (dates de début/fin, budget en nombre d'utilisations ou en montant).</li>
221
223
  <li>Désactiver : passer la promotion en <b>Brouillon</b>.</li>
222
224
  </ul>
225
+ <h3>Remise sur toute une boutique (ex. −35 % sur la boutique Nouvel An)</h3>
226
+ <ol>
227
+ <li><span class="menu">Promotions → Créer</span>, type <b>Automatique</b> (sans code) ou <b>Code</b>.</li>
228
+ <li>Modèle <b>Pourcentage sur la commande</b>, valeur <b>35</b>.</li>
229
+ <li>Conditions (« qui peut l'utiliser ») : <b>Canal de vente</b> = l'identifiant de la boutique (première page).</li>
230
+ <li>Rattacher à une campagne de promotion avec <b>date de début et de fin</b> (les mêmes que la campagne de la boutique, B9).</li>
231
+ <li>Vérifier : un produit au panier de cette boutique affiche la ligne −35 % ; dans une autre boutique, aucune remise.</li>
232
+ </ol>
233
+ <p>La remise porte sur tous les articles du panier (pas sur la livraison) et apparaît <b>dans le panier</b>. Pour afficher un <b>prix barré</b> sur les fiches produits, utilisez plutôt une liste de prix « Soldes » (B5). Pour exclure des produits : modèle <b>Pourcentage sur des produits</b> ciblant une catégorie ou une collection (ex. « Soldes »).</p>
223
234
  <div class="box warn">Une remise en montant (ex. −10) s'applique dans la devise choisie : créez une promotion par devise si besoin. Testez chaque code dans la bonne boutique avant de l'annoncer.</div>
224
235
 
225
236
  <h2>B11. Clients et groupes</h2>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.1.4",
3
+ "version": "0.2.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": { "type": "git", "url": "git+https://github.com/salifbiaye/create-boutique.git" },
@@ -24,10 +24,17 @@ const DICTS: Record<string, typeof en> = { en, fr }
24
24
  const THEMES: Record<string, { primary: string; onPrimary: string }> = {
25
25
  halloween: { primary: "#ff7a1a", onPrimary: "#0e0c14" },
26
26
  thanksgiving: { primary: "#c2410c", onPrimary: "#ffffff" },
27
- "black-friday": { primary: "#f97316", onPrimary: "#09090b" },
27
+ "black-friday": { primary: "#ffd400", onPrimary: "#0a0a0a" },
28
28
  "cyber-monday": { primary: "#4a2bd6", onPrimary: "#ffffff" },
29
29
  noel: { primary: "#b91c1c", onPrimary: "#ffffff" },
30
30
  "nouvel-an": { primary: "#d9b54a", onPrimary: "#111018" },
31
+ // Variantes (2e site par événement)
32
+ "halloween-2": { primary: "#a3e635", onPrimary: "#0b0a10" },
33
+ "thanksgiving-2": { primary: "#2f5d3a", onPrimary: "#ffffff" },
34
+ "black-friday-2": { primary: "#ff6a00", onPrimary: "#0a0a0a" },
35
+ "cyber-monday-2": { primary: "#22d3ee", onPrimary: "#04111a" },
36
+ "noel-2": { primary: "#d4af37", onPrimary: "#0f1d17" },
37
+ "nouvel-an-2": { primary: "#f5b5c8", onPrimary: "#0c0f24" },
31
38
  }
32
39
 
33
40
  export const escapeHtml = (s: unknown) =>
@@ -6,6 +6,7 @@ import { ExecArgs } from "@medusajs/framework/types"
6
6
  import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
7
7
  import { CampaignFile, CampaignInput } from "../modules/campaign/validators"
8
8
  import { upsertCampaignWorkflow } from "../workflows/upsert-campaign"
9
+ import { uploadSeedImage } from "./seed"
9
10
 
10
11
  export default async function campaignImport({ container }: ExecArgs) {
11
12
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
@@ -14,6 +15,7 @@ export default async function campaignImport({ container }: ExecArgs) {
14
15
  const data: Record<string, unknown> = JSON.parse(Buffer.from(raw, "base64").toString("utf8"))
15
16
 
16
17
  const salesChannels = container.resolve(Modules.SALES_CHANNEL)
18
+ const image = async (v?: string) => (v ? uploadSeedImage(container, v) : undefined)
17
19
  let count = 0
18
20
  for (const [slug, value] of Object.entries(data)) {
19
21
  const [channel] = await salesChannels.listSalesChannels({ name: slug })
@@ -25,10 +27,10 @@ export default async function campaignImport({ container }: ExecArgs) {
25
27
  ...file,
26
28
  hero: file.hero ? hero : undefined,
27
29
  promo: file.promo ? promo : undefined,
28
- // Seules les URL sont reprises (un nom de fichier n'est pas une image stockée).
29
- heroImage: heroImage && /^https?:\/\//.test(heroImage) ? heroImage : undefined,
30
- promoImage: promoImage && /^https?:\/\//.test(promoImage) ? promoImage : undefined,
31
- logo: file.logo && /^https?:\/\//.test(file.logo) ? file.logo : undefined,
30
+ // URL gardées telles quelles ; un nom de fichier de seed/images est envoyé au stockage.
31
+ heroImage: await image(heroImage),
32
+ promoImage: await image(promoImage),
33
+ logo: await image(file.logo),
32
34
  })
33
35
  await upsertCampaignWorkflow(container).run({ input: { sales_channel_id: channel.id, campaign } })
34
36
  count++
@@ -271,13 +271,14 @@ export default async function seed({ container }: ExecArgs) {
271
271
  }
272
272
 
273
273
  // ---------- Produits : seed/products.csv ----------
274
- await importProducts({ container, logger, currencies, channelIds, locationId: location.id, profileId: profile.id, productService })
274
+ const themeOf = Object.fromEntries(project.boutiques.map((b) => [b.slug, b.theme]))
275
+ await importProducts({ container, logger, currencies, channelIds, themeOf, locationId: location.id, profileId: profile.id, productService })
275
276
 
276
277
  logger.info("[seed] terminé")
277
278
  }
278
279
 
279
280
  /** Envoie seed/images/<nom> au stockage et renvoie son URL (URL http(s) renvoyée telle quelle). */
280
- async function uploadSeedImage(container: ExecArgs["container"], name: string): Promise<string | undefined> {
281
+ export async function uploadSeedImage(container: ExecArgs["container"], name: string): Promise<string | undefined> {
281
282
  if (/^https?:\/\//.test(name)) return name
282
283
  const file = path.join(SEED_DIR, "images", path.basename(name))
283
284
  if (!fs.existsSync(file)) return undefined
@@ -304,6 +305,7 @@ async function importProducts({
304
305
  logger,
305
306
  currencies,
306
307
  channelIds,
308
+ themeOf,
307
309
  locationId,
308
310
  profileId,
309
311
  productService,
@@ -312,6 +314,8 @@ async function importProducts({
312
314
  logger: { info: (m: string) => void; warn: (m: string) => void }
313
315
  currencies: string[]
314
316
  channelIds: Record<string, string>
317
+ /** slug → thème, pour la colonne « themes » du CSV */
318
+ themeOf: Record<string, string>
315
319
  locationId: string
316
320
  profileId: string
317
321
  productService: { listProducts: (f: { handle: string[] }, c: { select: string[] }) => Promise<{ handle: string }[]> }
@@ -345,7 +349,15 @@ async function importProducts({
345
349
  const images = (await Promise.all((r.images || "").split("|").filter(Boolean).map(imageUrl))).filter((u): u is string => !!u)
346
350
  const optionTitle = r.variant_option || "Default option"
347
351
  const values = r.variant_values ? r.variant_values.split("|").map((v) => v.trim()) : ["Default option value"]
348
- const boutiques = r.boutiques ? r.boutiques.split("|").map((s) => s.trim()) : Object.keys(channelIds)
352
+ // Boutiques du produit : colonne « boutiques » (slugs), ou « themes » (toutes les boutiques de ces thèmes,
353
+ // réutilisable d'un projet à l'autre), sinon toutes les boutiques.
354
+ const list = (v: string) => v.split("|").map((x) => x.trim()).filter(Boolean)
355
+ const boutiques = r.boutiques
356
+ ? list(r.boutiques)
357
+ : r.themes
358
+ ? Object.keys(channelIds).filter((slug) => list(r.themes).includes(themeOf[slug]))
359
+ : Object.keys(channelIds)
360
+ if (!boutiques.length) logger.warn(`[seed] ${r.handle} : aucune boutique ne correspond (colonnes boutiques/themes)`)
349
361
 
350
362
  const prices = Object.keys(r)
351
363
  .filter((k) => /^price_[a-z]{3}$/.test(k) && r[k])
@@ -155,10 +155,15 @@
155
155
  "checkout.paymentMethod": "Payment method",
156
156
  "checkout.paymentDetails": "Payment details",
157
157
  "checkout.giftCard": "Gift card",
158
+ "checkout.cardAndWallets": "Card, Apple Pay, Google Pay",
159
+ "checkout.manualPayment": "Manual payment",
158
160
  "checkout.continueReview": "Continue to review",
159
- "checkout.enterCard": "Enter card details",
161
+ "checkout.enterCard": "Enter payment details",
160
162
  "checkout.anotherStep": "Another step will appear",
161
- "checkout.cardDetails": "Enter your card details:",
163
+ "checkout.cardDetails": "Choose how to pay:",
164
+ "checkout.card": "Card",
165
+ "checkout.paymentPending": "Payment is still being processed. Refresh this page in a moment.",
166
+ "checkout.paymentFailed": "The payment was not completed. Please try again or choose another method.",
162
167
  "checkout.selectPayment": "Select a payment method",
163
168
  "checkout.placeOrder": "Place order",
164
169
  "checkout.review": "Review",
@@ -265,5 +270,37 @@
265
270
  "track.error.notFound": "No order matches this number and email.",
266
271
  "track.error.tooMany": "Too many attempts. Please try again in a few minutes.",
267
272
  "track.error.invalid": "Please enter a valid order number and email.",
268
- "track.account": "You have an account?"
273
+ "track.account": "You have an account?",
274
+ "shop.all": "All",
275
+ "shop.count": "{count} products",
276
+ "contact.eyebrow": "Customer care",
277
+ "contact.title": "Got a question? We're here.",
278
+ "contact.badge.value": "24h",
279
+ "contact.badge.label": "average reply time",
280
+ "contact.email.title": "Email us",
281
+ "contact.email.text": "Questions about a product, an order or a refund: we reply within 24 business hours.",
282
+ "contact.track.title": "Track my order",
283
+ "contact.track.text": "Your order number and email are all you need to see where your parcel is.",
284
+ "contact.track.cta": "Track my parcel",
285
+ "contact.shipping.title": "Shipping",
286
+ "contact.shipping.text": "Delivery times, destinations and shipping costs, shown before you pay.",
287
+ "contact.returns.title": "Returns and refunds",
288
+ "contact.returns.text": "30 days to change your mind, refunded to your original payment method.",
289
+ "contact.more": "Learn more",
290
+ "contact.faq.q1": "When will I receive my order?",
291
+ "contact.faq.a1": "Delivery times depend on the shipping method you choose and are shown at checkout. You get an email with tracking as soon as it ships.",
292
+ "contact.faq.q2": "How do I return an item?",
293
+ "contact.faq.a2": "Email us within 30 days with your order number and we'll send return instructions. Refunds are issued as soon as we receive it.",
294
+ "contact.faq.q3": "Is payment secure?",
295
+ "contact.faq.a3": "Yes. Payments are processed by Stripe (card, Apple Pay, Google Pay): {storeName} never sees or stores your card details.",
296
+ "contact.faq.q4": "Can I change or cancel my order?",
297
+ "contact.faq.a4": "Yes, as long as it hasn't shipped: contact us as soon as possible with your order number.",
298
+ "section.fold": "Top deals right now",
299
+ "section.spotlight": "The deals that go first",
300
+ "card.viewShort": "View",
301
+ "form.country": "Country",
302
+ "account.keepShopping": "Shop the deals",
303
+ "account.profileDone": "Profile completed",
304
+ "account.completeProfile": "Complete your profile (phone, billing address) to check out faster.",
305
+ "nav.menu": "Menu"
269
306
  }
@@ -155,10 +155,15 @@
155
155
  "checkout.paymentMethod": "Moyen de paiement",
156
156
  "checkout.paymentDetails": "Détails du paiement",
157
157
  "checkout.giftCard": "Carte cadeau",
158
+ "checkout.cardAndWallets": "Carte, Apple Pay, Google Pay",
159
+ "checkout.manualPayment": "Paiement manuel",
158
160
  "checkout.continueReview": "Vérifier la commande",
159
- "checkout.enterCard": "Saisir les données de carte",
161
+ "checkout.enterCard": "Saisir le paiement",
160
162
  "checkout.anotherStep": "Une étape supplémentaire va s'afficher",
161
- "checkout.cardDetails": "Saisissez vos données de carte :",
163
+ "checkout.cardDetails": "Choisissez votre moyen de paiement :",
164
+ "checkout.card": "Carte",
165
+ "checkout.paymentPending": "Le paiement est encore en cours de traitement. Actualisez la page dans un instant.",
166
+ "checkout.paymentFailed": "Le paiement n’a pas abouti. Réessayez ou choisissez un autre moyen de paiement.",
162
167
  "checkout.selectPayment": "Choisissez un moyen de paiement",
163
168
  "checkout.placeOrder": "Valider la commande",
164
169
  "checkout.review": "Vérification",
@@ -265,5 +270,37 @@
265
270
  "track.error.notFound": "Aucune commande ne correspond à ce numéro et à cet e-mail.",
266
271
  "track.error.tooMany": "Trop de tentatives. Réessayez dans quelques minutes.",
267
272
  "track.error.invalid": "Saisissez un numéro de commande et un e-mail valides.",
268
- "track.account": "Vous avez un compte ?"
273
+ "track.account": "Vous avez un compte ?",
274
+ "shop.all": "Tout",
275
+ "shop.count": "{count} produits",
276
+ "contact.eyebrow": "Service client",
277
+ "contact.title": "Une question ? On est là.",
278
+ "contact.badge.value": "24 h",
279
+ "contact.badge.label": "délai de réponse moyen",
280
+ "contact.email.title": "Écrivez-nous",
281
+ "contact.email.text": "Une question sur un produit, une commande ou un remboursement : réponse sous 24 h ouvrées.",
282
+ "contact.track.title": "Suivre ma commande",
283
+ "contact.track.text": "Numéro de commande et e-mail suffisent pour voir où en est votre colis.",
284
+ "contact.track.cta": "Suivre mon colis",
285
+ "contact.shipping.title": "Livraison",
286
+ "contact.shipping.text": "Délais, zones desservies et frais de port, affichés avant le paiement.",
287
+ "contact.returns.title": "Retours et remboursements",
288
+ "contact.returns.text": "30 jours pour changer d'avis, remboursement sur le moyen de paiement utilisé.",
289
+ "contact.more": "En savoir plus",
290
+ "contact.faq.q1": "Quand vais-je recevoir ma commande ?",
291
+ "contact.faq.a1": "Les délais dépendent du mode de livraison choisi et sont affichés au moment de la commande. Vous recevez un e-mail avec le numéro de suivi dès l'expédition.",
292
+ "contact.faq.q2": "Comment retourner un article ?",
293
+ "contact.faq.a2": "Écrivez-nous dans les 30 jours avec votre numéro de commande : nous vous envoyons les instructions de retour. Le remboursement est fait dès réception.",
294
+ "contact.faq.q3": "Le paiement est-il sécurisé ?",
295
+ "contact.faq.a3": "Oui. Les paiements sont traités par Stripe (carte, Apple Pay, Google Pay) : {storeName} ne voit ni ne stocke jamais vos données de carte.",
296
+ "contact.faq.q4": "Puis-je modifier ou annuler ma commande ?",
297
+ "contact.faq.a4": "Tant qu'elle n'est pas expédiée, oui : contactez-nous au plus vite avec votre numéro de commande.",
298
+ "section.fold": "Les offres phares",
299
+ "section.spotlight": "Les offres qui partent en premier",
300
+ "card.viewShort": "Voir",
301
+ "form.country": "Pays",
302
+ "account.keepShopping": "Voir les offres",
303
+ "account.profileDone": "Profil complété",
304
+ "account.completeProfile": "Complétez votre profil (téléphone, adresse de facturation) pour commander plus vite.",
305
+ "nav.menu": "Menu"
269
306
  }
@@ -15,6 +15,7 @@
15
15
  "@radix-ui/react-accordion": "1.2.20",
16
16
  "@stripe/react-stripe-js": "5.6.1",
17
17
  "@stripe/stripe-js": "8.11.0",
18
+ "libphonenumber-js": "1.13.14",
18
19
  "lodash": "4.18.1",
19
20
  "next": "16.3.6",
20
21
  "qs": "6.16.0",
@@ -7408,6 +7409,12 @@
7408
7409
  "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
7409
7410
  "license": "MIT"
7410
7411
  },
7412
+ "node_modules/libphonenumber-js": {
7413
+ "version": "1.13.14",
7414
+ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
7415
+ "integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
7416
+ "license": "MIT"
7417
+ },
7411
7418
  "node_modules/lilconfig": {
7412
7419
  "version": "3.1.3",
7413
7420
  "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -21,6 +21,7 @@
21
21
  "@radix-ui/react-accordion": "1.2.20",
22
22
  "@stripe/react-stripe-js": "5.6.1",
23
23
  "@stripe/stripe-js": "8.11.0",
24
+ "libphonenumber-js": "1.13.14",
24
25
  "lodash": "4.18.1",
25
26
  "next": "16.3.6",
26
27
  "qs": "6.16.0",
@@ -2,6 +2,7 @@ import { Metadata } from "next"
2
2
  import { notFound } from "next/navigation"
3
3
  import { getBoutique } from "@lib/boutique/config"
4
4
  import { t } from "@lib/boutique/i18n"
5
+ import ContactPage from "@modules/ub/contact-page"
5
6
 
6
7
  const PAGES = ["shipping", "returns", "privacy", "terms", "contact"] as const
7
8
  type Page = (typeof PAGES)[number]
@@ -18,8 +19,9 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
18
19
  export default async function LegalPage(props: Props) {
19
20
  const { page } = await props.params
20
21
  if (!isPage(page)) notFound()
22
+ if (page === "contact") return <ContactPage />
21
23
 
22
- const { storeName, merchantEmail } = getBoutique()
24
+ const { storeName } = getBoutique()
23
25
 
24
26
  return (
25
27
  <section className="ub-section">
@@ -28,14 +30,6 @@ export default async function LegalPage(props: Props) {
28
30
  {t(`legal.${page}`)}
29
31
  </h1>
30
32
  <p>{t(`legal.${page}.body`, { storeName })}</p>
31
- {page === "contact" && merchantEmail && (
32
- <p style={{ marginTop: 16 }}>
33
- {t("legal.contact.email", { email: "" })}
34
- <a href={`mailto:${merchantEmail}`} style={{ textDecoration: "underline" }}>
35
- {merchantEmail}
36
- </a>
37
- </p>
38
- )}
39
33
  </div>
40
34
  </section>
41
35
  )