@salifbiaye/create-boutique 0.1.5 → 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 (64) 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/package.json +1 -1
  7. package/templates/backend/src/lib/emails.ts +8 -1
  8. package/templates/backend/src/scripts/campaign-import.ts +6 -4
  9. package/templates/backend/src/scripts/seed.ts +15 -3
  10. package/templates/storefront/locales/en.json +33 -1
  11. package/templates/storefront/locales/fr.json +33 -1
  12. package/templates/storefront/package-lock.json +7 -0
  13. package/templates/storefront/package.json +1 -0
  14. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
  15. package/templates/storefront/src/app/(main)/page.tsx +25 -0
  16. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  17. package/templates/storefront/src/app/layout.tsx +9 -2
  18. package/templates/storefront/src/lib/boutique/config.ts +1 -0
  19. package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
  20. package/templates/storefront/src/lib/boutique/schema.ts +2 -0
  21. package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
  22. package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
  23. package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
  24. package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
  25. package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
  26. package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
  27. package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
  28. package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
  29. package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
  30. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
  31. package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
  32. package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
  33. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
  34. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
  35. package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
  36. package/templates/storefront/src/modules/ub/footer.tsx +26 -5
  37. package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
  38. package/templates/storefront/src/modules/ub/nav.tsx +2 -0
  39. package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
  40. package/templates/storefront/src/styles/ub/account.css +199 -0
  41. package/templates/storefront/src/styles/ub/black-friday.css +96 -0
  42. package/templates/storefront/src/styles/ub/forms.css +61 -0
  43. package/templates/storefront/src/styles/ub/pages.css +382 -0
  44. package/templates/storefront/src/styles/ub/responsive.css +57 -0
  45. package/templates/storefront/src/styles/ub/showcase.css +85 -0
  46. package/templates/storefront/src/styles/ub/variants.css +64 -0
  47. package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
  48. package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
  49. package/templates/storefront/themes/black-friday-2/theme.json +10 -0
  50. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
  51. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
  52. package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
  53. package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
  54. package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
  55. package/templates/storefront/themes/halloween-2/theme.json +10 -0
  56. package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
  57. package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
  58. package/templates/storefront/themes/noel-2/theme.json +10 -0
  59. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
  60. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
  61. package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
  62. package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
  63. package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
  64. 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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.1.5",
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])
@@ -270,5 +270,37 @@
270
270
  "track.error.notFound": "No order matches this number and email.",
271
271
  "track.error.tooMany": "Too many attempts. Please try again in a few minutes.",
272
272
  "track.error.invalid": "Please enter a valid order number and email.",
273
- "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"
274
306
  }
@@ -270,5 +270,37 @@
270
270
  "track.error.notFound": "Aucune commande ne correspond à ce numéro et à cet e-mail.",
271
271
  "track.error.tooMany": "Trop de tentatives. Réessayez dans quelques minutes.",
272
272
  "track.error.invalid": "Saisissez un numéro de commande et un e-mail valides.",
273
- "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"
274
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
  )
@@ -9,6 +9,7 @@ import { Hero, PromoBanner, SectionHead, TrustBar } from "@modules/ub/campaign-b
9
9
  import CampaignEnded from "@modules/ub/campaign-ended"
10
10
  import CategoryTiles from "@modules/ub/category-tiles"
11
11
  import { ProductGrid } from "@modules/ub/product-card"
12
+ import { DealsFold, DealsSpotlight } from "@modules/ub/showcase"
12
13
 
13
14
  async function Categories({ title }: { title: string }) {
14
15
  const categories = await listCategories().catch(() => [])
@@ -40,6 +41,24 @@ async function Products({ title }: { title: string }) {
40
41
  )
41
42
  }
42
43
 
44
+ /** Vitrines interactives : accordion « offres phares » puis grande carte sombre (produits les plus remisés d'abord). */
45
+ async function Showcase({ part, eyebrow }: { part: "fold" | "spotlight"; eyebrow?: string }) {
46
+ const {
47
+ response: { products },
48
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
49
+ const off = (p: (typeof products)[number]) =>
50
+ Math.max(0, ...(p.variants ?? []).map((v) => {
51
+ const c = v.calculated_price
52
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
53
+ }))
54
+ const sorted = [...products].sort((a, b) => off(b) - off(a))
55
+ return part === "fold" ? (
56
+ <DealsFold products={sorted} title={t("section.fold")} />
57
+ ) : (
58
+ <DealsSpotlight products={sorted.slice(5)} eyebrow={eyebrow} title={t("section.spotlight")} />
59
+ )
60
+ }
61
+
43
62
  function GridFallback() {
44
63
  return <div className="ub-section" aria-busy="true" style={{ minHeight: 320 }} />
45
64
  }
@@ -56,9 +75,15 @@ export default async function Home() {
56
75
  <>
57
76
  <Hero campaign={campaign} />
58
77
  <TrustBar freeShipping={campaign.freeShipping} />
78
+ <Suspense fallback={<GridFallback />}>
79
+ <Showcase part="fold" />
80
+ </Suspense>
59
81
  <Suspense fallback={<GridFallback />}>
60
82
  <Categories title={campaign.sections?.categories ?? t("section.categories")} />
61
83
  </Suspense>
84
+ <Suspense fallback={<GridFallback />}>
85
+ <Showcase part="spotlight" eyebrow={campaign.hero.eyebrow} />
86
+ </Suspense>
62
87
  <PromoBanner campaign={campaign} />
63
88
  <Suspense fallback={<GridFallback />}>
64
89
  <Products title={campaign.sections?.products ?? t("section.products")} />
@@ -12,17 +12,19 @@ type Params = {
12
12
  searchParams: Promise<{
13
13
  sortBy?: SortOptions
14
14
  page?: string
15
+ category?: string
15
16
  }>
16
17
  }
17
18
 
18
19
  export default async function StorePage(props: Params) {
19
20
  const searchParams = await props.searchParams;
20
- const { sortBy, page } = searchParams
21
+ const { sortBy, page, category } = searchParams
21
22
 
22
23
  return (
23
24
  <StoreTemplate
24
25
  sortBy={sortBy}
25
26
  page={page}
27
+ category={category}
26
28
  />
27
29
  )
28
30
  }
@@ -7,6 +7,13 @@ import { Metadata } from "next"
7
7
  import "styles/globals.css"
8
8
  import "styles/ub/tokens.css"
9
9
  import "styles/ub/bundle.css"
10
+ import "styles/ub/variants.css"
11
+ import "styles/ub/pages.css"
12
+ import "styles/ub/showcase.css"
13
+ import "styles/ub/forms.css"
14
+ import "styles/ub/account.css"
15
+ import "styles/ub/black-friday.css"
16
+ import "styles/ub/responsive.css"
10
17
  import "styles/ub/fonts.css"
11
18
  import "styles/ub/medusa-ui.css"
12
19
  import "styles/ub/overrides.css"
@@ -31,9 +38,9 @@ export default function RootLayout(props: { children: React.ReactNode }) {
31
38
  const b = getBoutique()
32
39
 
33
40
  return (
34
- <html lang={b.locale} data-theme={b.theme.id} data-mode="light" className={themeFontClasses(b.theme)}>
41
+ <html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-mode="light" className={themeFontClasses(b.theme)}>
35
42
  <body className="ub">
36
- <I18nProvider messages={messages("")}>
43
+ <I18nProvider messages={messages("")} locale={b.locale}>
37
44
  <main className="relative">{props.children}</main>
38
45
  </I18nProvider>
39
46
  </body>
@@ -95,6 +95,7 @@ function load(): Boutique {
95
95
  }
96
96
 
97
97
  const theme = loadTheme(entry.theme)
98
+ if (theme.layout) loadTheme(theme.layout) // variante : la mise en page empruntée doit exister
98
99
 
99
100
  const keyFile = path.join(/*turbopackIgnore: true*/ KEYS_DIR, slug)
100
101
  let publishableKey = ""
@@ -5,15 +5,23 @@ import { createContext, useContext } from "react"
5
5
  type Dict = Record<string, string>
6
6
 
7
7
  const I18nContext = createContext<Dict>({})
8
+ /** Locale de l'instance (ex. fr-SN) : langue du site + pays de la boutique. */
9
+ const LocaleContext = createContext("en-US")
8
10
 
9
11
  /**
10
12
  * Dictionnaire de l'instance pour les composants client (checkout, panier, compte).
11
13
  * Rempli par le layout racine (serveur) avec locales/<lang>.json + repli anglais.
12
14
  */
13
- export function I18nProvider({ messages, children }: { messages: Dict; children: React.ReactNode }) {
14
- return <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
15
+ export function I18nProvider({ messages, locale = "en-US", children }: { messages: Dict; locale?: string; children: React.ReactNode }) {
16
+ return (
17
+ <LocaleContext.Provider value={locale}>
18
+ <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
19
+ </LocaleContext.Provider>
20
+ )
15
21
  }
16
22
 
23
+ export const useLocale = () => useContext(LocaleContext)
24
+
17
25
  export function format(text: string, vars?: Record<string, string | number>) {
18
26
  return vars ? text.replace(/\{(\w+)\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m)) : text
19
27
  }