@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.
- package/cli/dist/commands.js +3 -3
- package/cli/dist/create.js +5 -5
- package/cli/dist/generate.js +68 -10
- package/cli/dist/project.js +2 -0
- package/cli/dist/themes.js +5 -1
- package/docs/guide-complet.en.html +11 -0
- package/docs/guide-complet.fr.html +11 -0
- package/package.json +1 -1
- package/templates/backend/src/lib/emails.ts +8 -1
- package/templates/backend/src/scripts/campaign-import.ts +6 -4
- package/templates/backend/src/scripts/seed.ts +15 -3
- package/templates/storefront/locales/en.json +40 -3
- package/templates/storefront/locales/fr.json +40 -3
- package/templates/storefront/package-lock.json +7 -0
- package/templates/storefront/package.json +1 -0
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
- package/templates/storefront/src/app/(main)/page.tsx +25 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/layout.tsx +9 -2
- package/templates/storefront/src/lib/boutique/config.ts +1 -0
- package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
- package/templates/storefront/src/lib/boutique/schema.ts +2 -0
- package/templates/storefront/src/lib/constants.tsx +5 -4
- package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
- package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
- package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
- package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
- package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
- package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
- package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
- package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
- package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +2 -1
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +65 -48
- package/templates/storefront/src/modules/checkout/components/payment-container/index.tsx +23 -29
- package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx +51 -6
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
- package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
- package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
- package/templates/storefront/src/modules/ub/footer.tsx +26 -5
- package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
- package/templates/storefront/src/modules/ub/nav.tsx +2 -0
- package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
- package/templates/storefront/src/proxy.ts +3 -3
- package/templates/storefront/src/styles/ub/account.css +199 -0
- package/templates/storefront/src/styles/ub/black-friday.css +96 -0
- package/templates/storefront/src/styles/ub/forms.css +61 -0
- package/templates/storefront/src/styles/ub/pages.css +382 -0
- package/templates/storefront/src/styles/ub/responsive.css +57 -0
- package/templates/storefront/src/styles/ub/showcase.css +85 -0
- package/templates/storefront/src/styles/ub/variants.css +64 -0
- package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
- package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/black-friday-2/theme.json +10 -0
- package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
- package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
- package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
- package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/halloween-2/theme.json +10 -0
- package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
- package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/noel-2/theme.json +10 -0
- package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
- package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
- package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
- package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/thanksgiving-2/theme.json +10 -0
package/cli/dist/commands.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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). */
|
package/cli/dist/create.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
163
|
-
`${"Backend / admin".padEnd(width +
|
|
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
|
-
|
|
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)]);
|
package/cli/dist/generate.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/cli/dist/project.js
CHANGED
|
@@ -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,
|
package/cli/dist/themes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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: "#
|
|
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
|
-
//
|
|
29
|
-
heroImage:
|
|
30
|
-
promoImage:
|
|
31
|
-
logo:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
+
"checkout.enterCard": "Enter payment details",
|
|
160
162
|
"checkout.anotherStep": "Another step will appear",
|
|
161
|
-
"checkout.cardDetails": "
|
|
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
|
|
161
|
+
"checkout.enterCard": "Saisir le paiement",
|
|
160
162
|
"checkout.anotherStep": "Une étape supplémentaire va s'afficher",
|
|
161
|
-
"checkout.cardDetails": "
|
|
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",
|
|
@@ -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
|
|
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
|
)
|