@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.
- 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/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 +33 -1
- package/templates/storefront/locales/fr.json +33 -1
- 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/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/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/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))
|
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])
|
|
@@ -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",
|
|
@@ -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
|
)
|
|
@@ -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
|
|
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
|
}
|