@salifbiaye/create-boutique 0.3.0 → 0.3.2

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.
@@ -231,9 +231,12 @@ const day = (d, add = 0, end = false) => {
231
231
  };
232
232
  /** Événement d'un thème : « black-friday-2 » → « black-friday ». */
233
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"]) {
234
+ /**
235
+ * Visuel de campagne dans seed/images (importé par le seed) : <prefix>-<slug>.*, sinon <prefix>-<thème>.*, sinon <prefix>.*
236
+ * prefix = "hero" (bannière du haut) ou "promo" (bandeau promo).
237
+ */
238
+ function heroImage(images, slug, theme, prefix = "hero") {
239
+ for (const name of [`${prefix}-${slug}`, `${prefix}-${theme}`, prefix]) {
237
240
  const found = images.find((f) => /\.(png|jpe?g|webp)$/i.test(f) && f.replace(/\.[^.]+$/, "").toLowerCase() === name);
238
241
  if (found)
239
242
  return found;
@@ -295,6 +298,9 @@ export function writeCampaignFiles(dir, p, language) {
295
298
  const hero = heroImage(images, b.slug, b.theme);
296
299
  if (hero)
297
300
  Object.assign(campaign, { hero: { image: hero } });
301
+ const promo = heroImage(images, b.slug, b.theme, "promo");
302
+ if (promo)
303
+ Object.assign(campaign, { promo: { image: promo } });
298
304
  fs.writeFileSync(file, JSON.stringify(campaign, null, 2) + "\n");
299
305
  }
300
306
  }
@@ -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 &lt;date&gt;"</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 &lt;date&gt; »</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.0",
3
+ "version": "0.3.2",
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
- await importProducts({ container, logger, currencies, channelIds, themeOf, locationId: location.id, profileId: profile.id, productService })
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 » pour les produits importés
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
- title: `Promotions (import ${new Date().toISOString().slice(0, 10)})`,
420
- description: "Prix barrés importés depuis seed/products.csv",
421
- status: "active",
422
- prices: salePrices
423
- .filter((p) => variantId.has(p.variantKey))
424
- .map((p) => ({ variant_id: variantId.get(p.variantKey)!, currency_code: p.currency_code, amount: p.amount })),
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
  }
@@ -77,6 +77,20 @@ async function HarvestProducts({ campaign }: { campaign: Campaign }) {
77
77
  )
78
78
  }
79
79
 
80
+ /** Bandeau promo avec, à défaut d'image de campagne, la photo du produit le plus remisé. */
81
+ async function Promo({ campaign }: { campaign: Campaign }) {
82
+ const {
83
+ response: { products },
84
+ } = await listProducts({ queryParams: { limit: 12, fields: "*categories,*collection" } })
85
+ const off = (p: (typeof products)[number]) =>
86
+ Math.max(0, ...(p.variants ?? []).map((v) => {
87
+ const c = v.calculated_price
88
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
89
+ }))
90
+ const best = [...products].filter((p) => p.thumbnail).sort((a, b) => off(b) - off(a))[0]
91
+ return <PromoBanner campaign={campaign} fallbackImage={best?.thumbnail ?? undefined} />
92
+ }
93
+
80
94
  function GridFallback() {
81
95
  return <div className="ub-section" aria-busy="true" style={{ minHeight: 320 }} />
82
96
  }
@@ -114,7 +128,9 @@ export default async function Home() {
114
128
  <Suspense fallback={<GridFallback />}>
115
129
  <Showcase part="spotlight" eyebrow={campaign.hero.eyebrow} />
116
130
  </Suspense>
117
- <PromoBanner campaign={campaign} />
131
+ <Suspense fallback={<GridFallback />}>
132
+ <Promo campaign={campaign} />
133
+ </Suspense>
118
134
  <Suspense fallback={<GridFallback />}>
119
135
  <Products title={campaign.sections?.products ?? t("section.products")} />
120
136
  </Suspense>
@@ -145,9 +145,10 @@ export function TrustBar({ freeShipping }: { freeShipping?: string }) {
145
145
  )
146
146
  }
147
147
 
148
- export function PromoBanner({ campaign }: { campaign: Campaign }) {
148
+ /** Bandeau promo. Image : celle de la campagne (admin → Campagnes), sinon `fallbackImage` (photo d'un produit remisé). */
149
+ export function PromoBanner({ campaign, fallbackImage }: { campaign: Campaign; fallbackImage?: string }) {
149
150
  const { promo } = campaign
150
- const promoImage = promo.image && /^https?:\/\//.test(promo.image) ? promo.image : undefined
151
+ const promoImage = (promo.image && /^https?:\/\//.test(promo.image) ? promo.image : undefined) ?? fallbackImage
151
152
 
152
153
  return (
153
154
  <section className="ub-section" style={{ paddingTop: 0 }}>