@salifbiaye/create-boutique 0.3.1 → 0.3.3
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/create.js
CHANGED
|
@@ -90,6 +90,18 @@ async function ask(a, dirName, yes) {
|
|
|
90
90
|
}
|
|
91
91
|
const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
|
|
92
92
|
const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
|
|
93
|
+
// E-mails clients (confirmation, expédition, mot de passe oublié…) via Resend ; vide = aucun e-mail envoyé.
|
|
94
|
+
const resendKey = a.resendKey ??
|
|
95
|
+
(cancelled(await p.password({ message: "Clé API Resend (vide = aucun e-mail envoyé)", validate: (v) => (!v || v.startsWith("re_") ? undefined : "une clé Resend commence par re_") })) ||
|
|
96
|
+
undefined);
|
|
97
|
+
const emailFrom = resendKey
|
|
98
|
+
? (a.emailFrom ??
|
|
99
|
+
cancelled(await p.text({
|
|
100
|
+
message: "Adresse d'expédition des e-mails (domaine vérifié chez Resend ; test : onboarding@resend.dev)",
|
|
101
|
+
placeholder: "commandes@votredomaine.com",
|
|
102
|
+
validate: (v) => (z.email().safeParse(v).success ? undefined : "adresse e-mail invalide"),
|
|
103
|
+
})))
|
|
104
|
+
: a.emailFrom;
|
|
93
105
|
const exists = (kind) => (v) => {
|
|
94
106
|
if (!v?.trim())
|
|
95
107
|
return undefined;
|
|
@@ -100,7 +112,7 @@ async function ask(a, dirName, yes) {
|
|
|
100
112
|
};
|
|
101
113
|
const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "", validate: exists("file") })).trim() || undefined);
|
|
102
114
|
const images = a.images ?? (cancelled(await p.text({ message: "Dossier images (optionnel)", defaultValue: "", validate: exists("dir") })).trim() || undefined);
|
|
103
|
-
return { ...a, name, email, currency, country, language, themes: chosen, boutiques: extra, stripePk, stripeSk, products, images };
|
|
115
|
+
return { ...a, name, email, currency, country, language, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
|
|
104
116
|
}
|
|
105
117
|
export async function create(dir, input, opts) {
|
|
106
118
|
const target = path.resolve(dir);
|
|
@@ -163,6 +175,7 @@ export async function create(dir, input, opts) {
|
|
|
163
175
|
`${"Backend / admin".padEnd(width + 16)}→ http://localhost:${proj.apiPort}/app`,
|
|
164
176
|
`Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
|
|
165
177
|
`Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
|
|
178
|
+
`E-mails : ${a.resendKey ? `Resend, depuis ${a.emailFrom ?? "(adresse manquante)"}` : "désactivés (pas de clé Resend)"}`,
|
|
166
179
|
`Catalogue : ${a.products ?? "aucun (à ajouter dans l'admin)"}`,
|
|
167
180
|
];
|
|
168
181
|
if (opts.yes)
|
|
@@ -207,6 +207,15 @@ create-boutique check . # a test order in each store: OK / KO</
|
|
|
207
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
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>
|
|
209
209
|
|
|
210
|
+
<h3>Run a new sale (sale prices + countdown in sync)</h3>
|
|
211
|
+
<p>At creation, the catalogue's sale prices go into a price list <b>"Promotions jusqu'au <date>"</b> that ends with the store's campaign: the countdown and the discounts end together. For a new sale:</p>
|
|
212
|
+
<ol>
|
|
213
|
+
<li><span class="menu">Pricing</span> → open the "Promotions jusqu'au …" list (or <b>Create</b> a <b>Sale</b> list) → <b>Edit</b>: new <b>start and end dates</b>. To change prices: <b>Prices</b> tab → sale price per product and currency.</li>
|
|
214
|
+
<li><span class="menu">Campaigns</span> → the store → <b>Edit</b>: set <b>exactly the same</b> start and end dates, update the texts ("up to −40%", Discount variable) and images → <b>Save</b>.</li>
|
|
215
|
+
<li>Check the site (about 1 minute): countdown, sale prices and percentages.</li>
|
|
216
|
+
</ol>
|
|
217
|
+
<div class="box info">The countdown shows the campaign end date; actual prices come from the price list. Same dates in both places = a consistent sale. After the end date, prices go back to normal and the store shows "This sale has ended".</div>
|
|
218
|
+
|
|
210
219
|
<h2>B10. Discount codes and promotions</h2>
|
|
211
220
|
<p><span class="menu">Promotions → Create</span>:</p>
|
|
212
221
|
<table>
|
|
@@ -207,6 +207,15 @@ create-boutique check . # une commande test dans chaque boutiqu
|
|
|
207
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
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>
|
|
209
209
|
|
|
210
|
+
<h3>Relancer une promo (prix barrés + compte à rebours alignés)</h3>
|
|
211
|
+
<p>À la création, les prix barrés du catalogue sont regroupés dans une liste de prix <b>« Promotions jusqu'au <date> »</b> qui s'arrête à la fin de la campagne de la boutique : le compte à rebours et les remises finissent ensemble. Pour une nouvelle promo :</p>
|
|
212
|
+
<ol>
|
|
213
|
+
<li><span class="menu">Tarification</span> → ouvrir la liste « Promotions jusqu'au … » (ou <b>Créer</b> une liste de type <b>Soldes</b>) → <b>Modifier</b> : nouvelles <b>dates de début et de fin</b>. Pour changer les prix : onglet <b>Prix</b> → prix soldé par produit et par devise.</li>
|
|
214
|
+
<li><span class="menu">Campagnes</span> → la boutique → <b>Modifier</b> : mettre <b>exactement les mêmes dates</b> de début et de fin, adapter les textes (« jusqu'à −40 % », variables Remise) et les images → <b>Enregistrer</b>.</li>
|
|
215
|
+
<li>Vérifier sur le site (environ 1 minute) : compte à rebours, prix barrés et pourcentages.</li>
|
|
216
|
+
</ol>
|
|
217
|
+
<div class="box info">Le compte à rebours affiche la date de fin de la campagne ; les prix réels dépendent de la liste de prix. Mêmes dates aux deux endroits = une promo cohérente. Après la date de fin : les prix redeviennent normaux et la boutique affiche « Cette vente est terminée ».</div>
|
|
218
|
+
|
|
210
219
|
<h2>B10. Codes promo et promotions</h2>
|
|
211
220
|
<p><span class="menu">Promotions → Créer</span> :</p>
|
|
212
221
|
<table>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salifbiaye/create-boutique",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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" },
|
|
@@ -272,7 +272,15 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
272
272
|
|
|
273
273
|
// ---------- Produits : seed/products.csv ----------
|
|
274
274
|
const themeOf = Object.fromEntries(project.boutiques.map((b) => [b.slug, b.theme]))
|
|
275
|
-
|
|
275
|
+
// Fin de campagne de chaque boutique (config/campaigns/<slug>.json) : les prix barrés s'arrêtent avec le compte à rebours
|
|
276
|
+
const endsOf = Object.fromEntries(
|
|
277
|
+
project.boutiques.map((b) => {
|
|
278
|
+
const file = path.join(CONFIG_DIR, "campaigns", `${b.slug}.json`)
|
|
279
|
+
const ends = fs.existsSync(file) ? (JSON.parse(fs.readFileSync(file, "utf8")).endsAt as string | undefined) : undefined
|
|
280
|
+
return [b.slug, ends]
|
|
281
|
+
})
|
|
282
|
+
)
|
|
283
|
+
await importProducts({ container, logger, currencies, channelIds, themeOf, endsOf, locationId: location.id, profileId: profile.id, productService })
|
|
276
284
|
|
|
277
285
|
logger.info("[seed] terminé")
|
|
278
286
|
}
|
|
@@ -306,6 +314,7 @@ async function importProducts({
|
|
|
306
314
|
currencies,
|
|
307
315
|
channelIds,
|
|
308
316
|
themeOf,
|
|
317
|
+
endsOf,
|
|
309
318
|
locationId,
|
|
310
319
|
profileId,
|
|
311
320
|
productService,
|
|
@@ -316,6 +325,8 @@ async function importProducts({
|
|
|
316
325
|
channelIds: Record<string, string>
|
|
317
326
|
/** slug → thème, pour la colonne « themes » du CSV */
|
|
318
327
|
themeOf: Record<string, string>
|
|
328
|
+
/** slug → fin de campagne (ISO) : date de fin des prix barrés des produits de cette boutique */
|
|
329
|
+
endsOf: Record<string, string | undefined>
|
|
319
330
|
locationId: string
|
|
320
331
|
profileId: string
|
|
321
332
|
productService: { listProducts: (f: { handle: string[] }, c: { select: string[] }) => Promise<{ handle: string }[]> }
|
|
@@ -340,7 +351,7 @@ async function importProducts({
|
|
|
340
351
|
return url
|
|
341
352
|
}
|
|
342
353
|
|
|
343
|
-
const salePrices: { variantKey: string; currency_code: string; amount: number }[] = []
|
|
354
|
+
const salePrices: { variantKey: string; currency_code: string; amount: number; endsAt?: string }[] = []
|
|
344
355
|
const products: CreateProductWorkflowInputDTO[] = []
|
|
345
356
|
for (const r of toCreate) {
|
|
346
357
|
const missing = currencies.filter((c) => !r[`price_${c}`])
|
|
@@ -358,6 +369,8 @@ async function importProducts({
|
|
|
358
369
|
? Object.keys(channelIds).filter((slug) => list(r.themes).includes(themeOf[slug]))
|
|
359
370
|
: Object.keys(channelIds)
|
|
360
371
|
if (!boutiques.length) logger.warn(`[seed] ${r.handle} : aucune boutique ne correspond (colonnes boutiques/themes)`)
|
|
372
|
+
// Fin des prix barrés = fin de campagne la plus tardive parmi les boutiques du produit
|
|
373
|
+
const endsAt = boutiques.map((b) => endsOf[b]).filter((d): d is string => !!d).sort().pop()
|
|
361
374
|
|
|
362
375
|
const prices = Object.keys(r)
|
|
363
376
|
.filter((k) => /^price_[a-z]{3}$/.test(k) && r[k])
|
|
@@ -367,7 +380,7 @@ async function importProducts({
|
|
|
367
380
|
const compareAt = Number(r[`compare_at_price_${currency_code}`] || 0)
|
|
368
381
|
// Prix barré : le prix de base est le prix « avant », le prix réel passe par une price list « sale ».
|
|
369
382
|
if (compareAt > price) {
|
|
370
|
-
for (const v of values) salePrices.push({ variantKey: `${r.handle}::${v}`, currency_code, amount: price })
|
|
383
|
+
for (const v of values) salePrices.push({ variantKey: `${r.handle}::${v}`, currency_code, amount: price, endsAt })
|
|
371
384
|
return { currency_code, amount: compareAt }
|
|
372
385
|
}
|
|
373
386
|
return { currency_code, amount: price }
|
|
@@ -409,22 +422,26 @@ async function importProducts({
|
|
|
409
422
|
)
|
|
410
423
|
if (levels.length) await createInventoryLevelsWorkflow(container).run({ input: { inventory_levels: levels } })
|
|
411
424
|
|
|
412
|
-
// Prix promo (compare_at_price_*) : une price list « sale »
|
|
425
|
+
// Prix promo (compare_at_price_*) : une price list « sale » par date de fin de campagne. Active tout de suite,
|
|
426
|
+
// elle s'arrête avec le compte à rebours de la boutique (modifiable ensuite dans admin → Tarification).
|
|
413
427
|
if (salePrices.length) {
|
|
414
428
|
const variantId = new Map(variants.map((v) => [`${v.product!.handle}::${v.title}`, v.id]))
|
|
429
|
+
const groups = new Map<string, typeof salePrices>()
|
|
430
|
+
for (const p of salePrices) groups.set(p.endsAt ?? "", [...(groups.get(p.endsAt ?? "") ?? []), p])
|
|
431
|
+
const day = (iso: string) => new Date(iso).toISOString().slice(0, 10)
|
|
415
432
|
await createPriceListsWorkflow(container).run({
|
|
416
433
|
input: {
|
|
417
|
-
price_lists_data: [
|
|
418
|
-
{
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
],
|
|
434
|
+
price_lists_data: [...groups].map(([endsAt, prices]) => ({
|
|
435
|
+
title: endsAt ? `Promotions jusqu'au ${day(endsAt)}` : `Promotions (import ${day(new Date().toISOString())})`,
|
|
436
|
+
description: "Prix barrés importés depuis seed/products.csv ; fin = fin de la campagne des boutiques concernées",
|
|
437
|
+
status: "active" as const,
|
|
438
|
+
...(endsAt ? { ends_at: endsAt } : {}),
|
|
439
|
+
prices: prices
|
|
440
|
+
.filter((p) => variantId.has(p.variantKey))
|
|
441
|
+
.map((p) => ({ variant_id: variantId.get(p.variantKey)!, currency_code: p.currency_code, amount: p.amount })),
|
|
442
|
+
})),
|
|
427
443
|
},
|
|
428
444
|
})
|
|
445
|
+
logger.info(`[seed] prix barrés : ${groups.size} liste(s) de prix, fin alignée sur les campagnes`)
|
|
429
446
|
}
|
|
430
447
|
}
|