@salifbiaye/create-boutique 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +241 -17
  3. package/cli/dist/create.js +108 -15
  4. package/cli/dist/generate.js +108 -39
  5. package/cli/dist/guide.js +12 -4
  6. package/cli/dist/index.js +38 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/cli/dist/project.js +42 -3
  9. package/docs/fiche-client.en.html +2 -2
  10. package/docs/fiche-client.html +2 -2
  11. package/docs/guide-complet.fr.html +681 -256
  12. package/docs/guide-images/categories.jpg +0 -0
  13. package/docs/guide-images/clients.jpg +0 -0
  14. package/docs/guide-images/collections.jpg +0 -0
  15. package/docs/guide-images/commande-detail.jpg +0 -0
  16. package/docs/guide-images/commande-expedier.jpg +0 -0
  17. package/docs/guide-images/commande-menu.jpg +0 -0
  18. package/docs/guide-images/commande-paiement.jpg +0 -0
  19. package/docs/guide-images/commandes-liste.jpg +0 -0
  20. package/docs/guide-images/connexion.jpg +0 -0
  21. package/docs/guide-images/groupes-clients.jpg +0 -0
  22. package/docs/guide-images/inventaire.jpg +0 -0
  23. package/docs/guide-images/listes-prix.jpg +0 -0
  24. package/docs/guide-images/menu.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  27. package/docs/guide-images/mes-boutiques.jpg +0 -0
  28. package/docs/guide-images/parametres-boutique.jpg +0 -0
  29. package/docs/guide-images/parametres-canaux.jpg +0 -0
  30. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  31. package/docs/guide-images/parametres-profil.jpg +0 -0
  32. package/docs/guide-images/parametres-regions.jpg +0 -0
  33. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  34. package/docs/guide-images/produit-creer-details.jpg +0 -0
  35. package/docs/guide-images/produit-detail.jpg +0 -0
  36. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  37. package/docs/guide-images/produit-variantes.jpg +0 -0
  38. package/docs/guide-images/produits-liste.jpg +0 -0
  39. package/docs/guide-images/promotion-details.jpg +0 -0
  40. package/docs/guide-images/promotion-type.jpg +0 -0
  41. package/docs/guide-images/promotions-liste.jpg +0 -0
  42. package/docs/guide-images/site-boutique.jpg +0 -0
  43. package/docs/guide-images/site-fermee.png +0 -0
  44. package/docs/guide-images/vue-ensemble.jpg +0 -0
  45. package/package.json +3 -2
  46. package/templates/backend/medusa-config.ts +31 -25
  47. package/templates/backend/package-lock.json +21 -0
  48. package/templates/backend/package.json +2 -0
  49. package/templates/backend/src/admin/i18n/json/en.json +63 -5
  50. package/templates/backend/src/admin/i18n/json/fr.json +63 -5
  51. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
  52. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
  53. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  54. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  55. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  56. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
  57. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  58. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  59. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  60. package/templates/backend/src/api/middlewares.ts +9 -0
  61. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  62. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  63. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  64. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  65. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  66. package/templates/backend/src/emails/en.json +3 -1
  67. package/templates/backend/src/emails/fr.json +3 -1
  68. package/templates/backend/src/lib/active.ts +41 -0
  69. package/templates/backend/src/lib/email-config.ts +14 -0
  70. package/templates/backend/src/lib/emails.ts +9 -0
  71. package/templates/backend/src/lib/notify.ts +14 -8
  72. package/templates/backend/src/lib/payment-config.ts +66 -0
  73. package/templates/backend/src/lib/project.ts +38 -1
  74. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  75. package/templates/backend/src/lib/review-token.ts +40 -0
  76. package/templates/backend/src/lib/reviews.ts +25 -0
  77. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  78. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  79. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  80. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  81. package/templates/backend/src/modules/paytech/index.ts +6 -0
  82. package/templates/backend/src/modules/paytech/service.ts +114 -0
  83. package/templates/backend/src/modules/product-review/index.ts +8 -0
  84. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  85. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  86. package/templates/backend/src/modules/product-review/service.ts +6 -0
  87. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  88. package/templates/backend/src/modules/smtp/index.ts +6 -0
  89. package/templates/backend/src/modules/smtp/service.ts +73 -0
  90. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  91. package/templates/backend/src/scripts/seed.ts +60 -36
  92. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  93. package/templates/backend/src/workflows/reviews.ts +21 -0
  94. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  95. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  96. package/templates/storefront/PERSONNALISER.md +134 -0
  97. package/templates/storefront/locales/en.json +198 -11
  98. package/templates/storefront/locales/fr.json +198 -11
  99. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  100. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  101. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  102. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  103. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  104. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  105. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  106. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  107. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  108. package/templates/storefront/src/app/layout.tsx +13 -1
  109. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  110. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  111. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  112. package/templates/storefront/src/lib/boutique/schema.ts +7 -2
  113. package/templates/storefront/src/lib/config.ts +37 -8
  114. package/templates/storefront/src/lib/constants.tsx +17 -0
  115. package/templates/storefront/src/lib/data/cart.ts +4 -1
  116. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  117. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  118. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  119. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  120. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  121. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  122. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  123. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  124. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  125. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  126. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  127. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  128. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  129. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  130. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  131. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  132. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  133. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  134. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  135. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  136. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  137. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  138. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  139. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  140. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  141. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  142. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  143. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  144. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  145. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  146. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  147. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  148. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  149. package/templates/storefront/src/proxy.ts +4 -1
  150. package/templates/storefront/src/styles/custom.css +26 -0
  151. package/templates/storefront/src/styles/ub/album.css +1 -1
  152. package/templates/storefront/src/styles/ub/arena.css +120 -0
  153. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  154. package/templates/storefront/src/styles/ub/gala.css +1 -1
  155. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  156. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  157. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  158. package/templates/storefront/src/styles/ub/locker.css +115 -0
  159. package/templates/storefront/src/styles/ub/media.css +10 -0
  160. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  161. package/templates/storefront/src/styles/ub/motion.css +0 -2
  162. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  163. package/templates/storefront/src/styles/ub/pages.css +176 -0
  164. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  165. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  166. package/templates/storefront/src/styles/ub/souk.css +144 -0
  167. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  168. package/templates/storefront/src/styles/ub/tech.css +5 -8
  169. package/templates/storefront/src/styles/ub/training.css +120 -0
  170. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  171. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  172. package/templates/storefront/themes/jeux/theme.json +11 -0
  173. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  174. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  175. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  176. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  177. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  178. package/templates/storefront/themes/mubarak/theme.json +11 -0
  179. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  180. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  181. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  182. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  183. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  184. package/templates/storefront/themes/sport/theme.json +11 -0
  185. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  186. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  187. package/templates/storefront/themes/sport-2/theme.json +11 -0
  188. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  189. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  190. package/templates/storefront/themes/summer/theme.json +11 -0
  191. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  192. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  193. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -1,12 +1,11 @@
1
1
  import { Suspense } from "react"
2
2
  import { connection } from "next/server"
3
3
 
4
- import { campaignStatus, getCampaign } from "@lib/boutique/campaign"
4
+ import { getCampaign } from "@lib/boutique/campaign"
5
5
  import { t } from "@lib/boutique/i18n"
6
6
  import { listCategories } from "@lib/data/categories"
7
7
  import { listProducts } from "@lib/data/products"
8
8
  import { Hero, PromoBanner, SectionHead, TrustBar } from "@modules/ub/campaign-blocks"
9
- import CampaignEnded from "@modules/ub/campaign-ended"
10
9
  import CategoryTiles from "@modules/ub/category-tiles"
11
10
  import { ProductGrid } from "@modules/ub/product-card"
12
11
  import { DealsFold, DealsSpotlight } from "@modules/ub/showcase"
@@ -16,7 +15,17 @@ import { CinemaHero, CinemaRow, CinemaScreening, CinemaTop } from "@modules/ub/c
16
15
  import { TechBento, TechFinal, TechFlash, TechGrid, TechHero, TechSpecs, TechTicker } from "@modules/ub/tech"
17
16
  import { GrimoireHero, GrimoireShelf, GrimoireSpell, GrimoireTarot } from "@modules/ub/grimoire"
18
17
  import { AlbumGarland, AlbumHero, AlbumPolaroids, AlbumStory, AlbumTicket } from "@modules/ub/album"
18
+ import { RivieraColours, RivieraHero, RivieraLookbook, RivieraTailor } from "@modules/ub/riviera"
19
+ import { SunsetBand, SunsetChecklistSection, SunsetHero, SunsetPostcards } from "@modules/ub/sunset"
20
+ import { LockerHalftime, LockerHero, LockerLineup, LockerRack } from "@modules/ub/locker"
21
+ import { TrainingHero, TrainingPack, TrainingProgram, TrainingPromise, TrainingTop } from "@modules/ub/training"
22
+ import { ArenaHero, ArenaLanes, ArenaPodium, ArenaRecord } from "@modules/ub/arena"
23
+ import { MosaicDisciplines, MosaicHero, MosaicMedals, MosaicRelay } from "@modules/ub/mosaic"
24
+ import { LanternArches, LanternBudget, LanternHero, LanternNight } from "@modules/ub/lantern"
25
+ import { SoukHero, SoukLetter, SoukStalls, SoukWho } from "@modules/ub/souk"
26
+ import { discountOf } from "@modules/ub/home-kit"
19
27
  import { getBoutique } from "@lib/boutique/config"
28
+ import { HomeReviews } from "@modules/ub/reviews"
20
29
  import type { Campaign } from "@lib/boutique/campaign"
21
30
 
22
31
  async function Categories({ title }: { title: string }) {
@@ -188,16 +197,152 @@ async function GalaProducts({ campaign }: { campaign: Campaign }) {
188
197
  )
189
198
  }
190
199
 
200
+ /** Produits de l'accueil : tels quels et triés par remise (plus forte d'abord). */
201
+ async function homeProducts(limit = 12) {
202
+ const {
203
+ response: { products },
204
+ } = await listProducts({ queryParams: { limit, fields: "*categories,*collection" } })
205
+ return { products, sorted: [...products].sort((a, b) => discountOf(b) - discountOf(a)) }
206
+ }
207
+
208
+ /** Accueil « Riviera » (Summer). */
209
+ async function RivieraPage({ campaign }: { campaign: Campaign }) {
210
+ const { products } = await homeProducts()
211
+ return (
212
+ <>
213
+ <RivieraLookbook products={products} title={campaign.sections?.products ?? t("section.products")} />
214
+ <RivieraColours products={products} />
215
+ <RivieraTailor campaign={campaign} />
216
+ </>
217
+ )
218
+ }
219
+
220
+ /** Accueil « Sunset » (Summer 2) : cartes postales des plus fortes remises, liste de plage interactive. */
221
+ async function SunsetPage({ campaign }: { campaign: Campaign }) {
222
+ const { products, sorted } = await homeProducts()
223
+ return (
224
+ <>
225
+ <SunsetPostcards products={sorted} campaign={campaign} title={campaign.sections?.products ?? t("section.products")} />
226
+ <SunsetChecklistSection products={products} />
227
+ <SunsetBand campaign={campaign} />
228
+ </>
229
+ )
230
+ }
231
+
232
+ /** Accueil « Vestiaire » (Sport). */
233
+ async function LockerPage({ campaign }: { campaign: Campaign }) {
234
+ const { products, sorted } = await homeProducts()
235
+ return (
236
+ <>
237
+ <LockerLineup products={sorted} title={campaign.sections?.products ?? t("section.products")} />
238
+ <LockerRack products={products} title={campaign.sections?.categories ?? t("section.categories")} />
239
+ <LockerHalftime campaign={campaign} />
240
+ </>
241
+ )
242
+ }
243
+
244
+ /** Accueil « Training » (Sport 2) : les plus choisis juste sous le hero. */
245
+ async function TrainingPage({ campaign }: { campaign: Campaign }) {
246
+ const { products, sorted } = await homeProducts()
247
+ return (
248
+ <>
249
+ <TrainingTop products={sorted} title={campaign.sections?.products ?? t("section.products")} />
250
+ <TrainingProgram products={products} />
251
+ <TrainingPack campaign={campaign} products={sorted.slice(4)} />
252
+ <TrainingPromise freeShipping={campaign.freeShipping} />
253
+ </>
254
+ )
255
+ }
256
+
257
+ /** Accueil « Arène » (Jeux) : podium des 3 plus fortes remises, puis les couloirs. */
258
+ async function ArenaPage({ campaign }: { campaign: Campaign }) {
259
+ const { sorted } = await homeProducts()
260
+ return (
261
+ <>
262
+ <ArenaPodium products={sorted} />
263
+ <ArenaLanes products={sorted} title={campaign.sections?.products ?? t("section.products")} />
264
+ <ArenaRecord campaign={campaign} />
265
+ </>
266
+ )
267
+ }
268
+
269
+ /** Accueil « Mosaïque » (Jeux 2). */
270
+ async function MosaicPage({ campaign }: { campaign: Campaign }) {
271
+ const [{ products, sorted }, categories] = await Promise.all([homeProducts(), listCategories().catch(() => [])])
272
+ return (
273
+ <>
274
+ <MosaicDisciplines categories={categories} title={campaign.sections?.categories ?? t("section.categories")} />
275
+ <MosaicMedals products={sorted} title={campaign.sections?.products ?? t("section.products")} />
276
+ <MosaicRelay campaign={campaign} products={products} />
277
+ </>
278
+ )
279
+ }
280
+
281
+ /** Accueil « Lanterne » (Mubarak · Nuit) : la sélection (plus fortes remises), idées cadeaux par budget. */
282
+ async function LanternPage({ campaign }: { campaign: Campaign }) {
283
+ const { products, sorted } = await homeProducts()
284
+ return (
285
+ <>
286
+ <LanternArches products={sorted} title={campaign.sections?.products ?? t("section.products")} />
287
+ <LanternBudget products={products} />
288
+ <LanternNight campaign={campaign} />
289
+ </>
290
+ )
291
+ }
292
+
293
+ /** Accueil « Souk » (Mubarak · Sable). */
294
+ async function SoukPage({ campaign }: { campaign: Campaign }) {
295
+ const [{ sorted }, categories] = await Promise.all([homeProducts(), listCategories().catch(() => [])])
296
+ return (
297
+ <>
298
+ <SoukStalls products={sorted} title={campaign.sections?.products ?? t("section.products")} />
299
+ <SoukWho categories={categories} title={campaign.sections?.categories ?? t("section.categories")} />
300
+ <SoukLetter campaign={campaign} />
301
+ </>
302
+ )
303
+ }
304
+
191
305
  function GridFallback() {
192
306
  return <div className="ub-section" aria-busy="true" style={{ minHeight: 320 }} />
193
307
  }
194
308
 
309
+ /** Accueil : la mise en page du thème, puis les derniers avis clients (section commune, aux couleurs du thème). */
195
310
  export default async function Home() {
311
+ return (
312
+ <>
313
+ <HomeBody />
314
+ <Suspense fallback={null}>
315
+ <HomeReviews />
316
+ </Suspense>
317
+ </>
318
+ )
319
+ }
320
+
321
+ async function HomeBody() {
196
322
  await connection()
197
323
  const campaign = await getCampaign()
198
324
 
199
- if (campaignStatus(campaign) === "ended") {
200
- return <CampaignEnded nextUrl={campaign.nextCampaignUrl} />
325
+
326
+ const home = getBoutique().theme.home
327
+ const themed: Partial<Record<string, [string, React.ReactNode, React.ReactNode]>> = {
328
+ riviera: ["ub-riviera", <RivieraHero key="h" campaign={campaign} />, <RivieraPage campaign={campaign} />],
329
+ sunset: ["ub-sunset", <SunsetHero key="h" campaign={campaign} />, <SunsetPage campaign={campaign} />],
330
+ locker: ["ub-locker", <LockerHero key="h" campaign={campaign} />, <LockerPage campaign={campaign} />],
331
+ training: ["ub-training", <TrainingHero key="h" campaign={campaign} />, <TrainingPage campaign={campaign} />],
332
+ arena: ["ub-arena", <ArenaHero key="h" campaign={campaign} />, <ArenaPage campaign={campaign} />],
333
+ mosaic: ["ub-mosaic", <MosaicHero key="h" campaign={campaign} />, <MosaicPage campaign={campaign} />],
334
+ lantern: ["ub-lantern", <LanternHero key="h" campaign={campaign} />, <LanternPage campaign={campaign} />],
335
+ souk: ["ub-souk", <SoukHero key="h" campaign={campaign} />, <SoukPage campaign={campaign} />],
336
+ }
337
+ const page = home ? themed[home] : undefined
338
+ if (page) {
339
+ const [cls, hero, rest] = page
340
+ return (
341
+ <div className={cls}>
342
+ {hero}
343
+ <Suspense fallback={<GridFallback />}>{rest}</Suspense>
344
+ </div>
345
+ )
201
346
  }
202
347
 
203
348
  if (getBoutique().theme.home === "grimoire") {
@@ -1,5 +1,6 @@
1
1
  import { Metadata } from "next"
2
- import { notFound } from "next/navigation"
2
+ import { notFound, redirect } from "next/navigation"
3
+ import { isSingleSite } from "@lib/boutique/config"
3
4
  import { listProducts } from "@lib/data/products"
4
5
  import { getRegion } from "@lib/data/regions"
5
6
  import ProductTemplate from "@modules/products/templates"
@@ -71,6 +72,8 @@ export default async function ProductPage(props: Props) {
71
72
  }).then(({ response }) => response.products[0])
72
73
 
73
74
  if (!pricedProduct) {
75
+ // Mode single : lien vers un produit d'une boutique qui n'est plus en ligne → la boutique actuelle
76
+ if (isSingleSite()) redirect("/store")
74
77
  notFound()
75
78
  }
76
79
 
@@ -0,0 +1,60 @@
1
+ import { Metadata } from "next"
2
+ import Link from "next/link"
3
+ import { t } from "@lib/boutique/i18n"
4
+ import { getReviewOrder } from "@lib/data/reviews"
5
+ import FadeImg from "@modules/ub/fade-img"
6
+ import ReviewForm from "@modules/ub/review-form"
7
+
8
+ export async function generateMetadata(): Promise<Metadata> {
9
+ return { title: t("reviews.pageTitle"), robots: { index: false, follow: false } }
10
+ }
11
+
12
+ type Props = { searchParams: Promise<{ token?: string }> }
13
+
14
+ /**
15
+ * « Laisser un avis » : ouverte depuis le lien de l'e-mail « commande livrée » (jeton signé).
16
+ * Un formulaire par produit de la commande ; les produits déjà notés sont indiqués. Page non indexée.
17
+ */
18
+ export default async function ReviewPage(props: Props) {
19
+ const { token = "" } = await props.searchParams
20
+ const order = token ? await getReviewOrder(token) : null
21
+
22
+ return (
23
+ <section className="ub-section ub-reviewpage">
24
+ <div className="ub-wrap">
25
+ <h1 className="ub-head ub-reviewpage__title">{t("reviews.pageTitle")}</h1>
26
+ {!order ? (
27
+ <>
28
+ <p className="ub-reviewpage__lead">{t("reviews.invalidLink")}</p>
29
+ <Link href="/" className="ub-rform__btn">
30
+ {t("reviews.backHome")}
31
+ </Link>
32
+ </>
33
+ ) : (
34
+ <>
35
+ <p className="ub-reviewpage__lead">{t("reviews.pageLead", { number: String(order.number) })}</p>
36
+ <ul className="ub-reviewpage__list">
37
+ {order.products.map((p) => (
38
+ <li key={p.product_id} className="ub-reviewpage__item">
39
+ <div className="ub-reviewpage__product">
40
+ {p.thumbnail && (
41
+ <span className="ub-reviewpage__img">
42
+ <FadeImg src={p.thumbnail} alt="" />
43
+ </span>
44
+ )}
45
+ <b>{p.title}</b>
46
+ </div>
47
+ {p.reviewed ? (
48
+ <p className="ub-rform__done">{t("reviews.already")}</p>
49
+ ) : (
50
+ <ReviewForm token={token} productId={p.product_id} firstName={order.first_name} />
51
+ )}
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ </>
56
+ )}
57
+ </div>
58
+ </section>
59
+ )
60
+ }
@@ -13,18 +13,20 @@ type Params = {
13
13
  sortBy?: SortOptions
14
14
  page?: string
15
15
  category?: string
16
+ q?: string
16
17
  }>
17
18
  }
18
19
 
19
20
  export default async function StorePage(props: Params) {
20
21
  const searchParams = await props.searchParams;
21
- const { sortBy, page, category } = searchParams
22
+ const { sortBy, page, category, q } = searchParams
22
23
 
23
24
  return (
24
25
  <StoreTemplate
25
26
  sortBy={sortBy}
26
27
  page={page}
27
28
  category={category}
29
+ q={typeof q === "string" ? q.slice(0, 100) : undefined}
28
30
  />
29
31
  )
30
32
  }
@@ -1,4 +1,19 @@
1
- /** Remonté à chaque navigation : sert de point d'accroche à la transition entre pages (styles/ub/motion.css, desktop). */
1
+ "use client"
2
+
3
+ import { usePathname, useSearchParams } from "next/navigation"
4
+ import { pageName } from "@lib/boutique/page-name"
5
+
6
+ /**
7
+ * Remonté à chaque navigation : point d'accroche de la transition entre pages (styles/ub/motion.css, desktop)
8
+ * et nom de la page (data-page) pour la cibler dans styles/custom.css. Calculé depuis l'adresse courante (côté client) :
9
+ * juste au premier affichage comme après chaque clic dans le site.
10
+ */
2
11
  export default function Template({ children }: { children: React.ReactNode }) {
3
- return <div className="ub-page">{children}</div>
12
+ const path = usePathname()
13
+ const search = useSearchParams().toString()
14
+ return (
15
+ <div className="ub-page" data-page={pageName(path, search ? `?${search}` : "")}>
16
+ {children}
17
+ </div>
18
+ )
4
19
  }
@@ -4,6 +4,7 @@ import { themeFontClasses } from "@lib/boutique/fonts"
4
4
  import { messages } from "@lib/boutique/i18n"
5
5
  import { I18nProvider } from "@lib/boutique/i18n-client"
6
6
  import Motion from "@modules/ub/motion"
7
+ import CookieBanner from "@modules/ub/cookie-banner"
7
8
  import { Metadata } from "next"
8
9
  import "styles/globals.css"
9
10
  import "styles/ub/tokens.css"
@@ -20,12 +21,22 @@ import "styles/ub/cinema.css"
20
21
  import "styles/ub/album.css"
21
22
  import "styles/ub/tech.css"
22
23
  import "styles/ub/grimoire.css"
24
+ import "styles/ub/riviera.css"
25
+ import "styles/ub/sunset.css"
26
+ import "styles/ub/locker.css"
27
+ import "styles/ub/training.css"
28
+ import "styles/ub/arena.css"
29
+ import "styles/ub/mosaic.css"
30
+ import "styles/ub/lantern.css"
31
+ import "styles/ub/souk.css"
32
+ import "styles/ub/reviews.css"
23
33
  import "styles/ub/motion.css"
24
34
  import "styles/ub/media.css"
25
35
  import "styles/ub/responsive.css"
26
36
  import "styles/ub/fonts.css"
27
37
  import "styles/ub/medusa-ui.css"
28
38
  import "styles/ub/overrides.css"
39
+ import "styles/custom.css" // retouches du projet, conservées par update (toujours en dernier)
29
40
 
30
41
  // Tout dépend de la boutique de l'instance (lue à l'exécution) : rien n'est pré-rendu au build.
31
42
  export const dynamic = "force-dynamic"
@@ -63,11 +74,12 @@ export default function RootLayout(props: { children: React.ReactNode }) {
63
74
  const b = getBoutique()
64
75
 
65
76
  return (
66
- <html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-event={b.theme.id.replace(/-2$/, "")} data-mode="light" className={themeFontClasses(b.theme)}>
77
+ <html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-event={b.theme.id.replace(/-2$/, "")} data-boutique={b.slug} data-mode="light" className={themeFontClasses(b.theme)}>
67
78
  <body className="ub">
68
79
  <I18nProvider messages={messages("")} locale={b.locale}>
69
80
  <main className="relative">{props.children}</main>
70
81
  <Motion event={b.theme.id.replace(/-2$/, "")} />
82
+ <CookieBanner />
71
83
  </I18nProvider>
72
84
  </body>
73
85
  </html>
@@ -20,6 +20,8 @@ export type Campaign = CampaignTexts & {
20
20
  seo?: { title?: string; description?: string; image?: string }
21
21
  /** Compte à rebours : undefined = selon le thème (CSS), true = toujours, false = jamais */
22
22
  showCountdown?: boolean
23
+ /** Site ouvert (défaut) ou fermé dans l'admin. Les dates de campagne ne ferment jamais le site. */
24
+ online: boolean
23
25
  }
24
26
 
25
27
  function readJsonIfExists(file: string): unknown | undefined {
@@ -105,8 +107,10 @@ export const getCampaign = cache(async (): Promise<Campaign> => {
105
107
  nextCampaignUrl: own.nextCampaign ? b.siblings[own.nextCampaign] : undefined,
106
108
  freeShipping: own.vars?.freeShipping,
107
109
  logo: own.logo,
108
- showCountdown: own.showCountdown,
110
+ // Campagne terminée : le site reste une boutique normale, seul le compte à rebours disparaît
111
+ showCountdown: campaignStatus(own) === "ended" ? false : own.showCountdown,
109
112
  seo: own.seo,
113
+ online: own.online !== false,
110
114
  }
111
115
  })
112
116
 
@@ -3,12 +3,14 @@ import "server-only"
3
3
  import fs from "node:fs"
4
4
  import path from "node:path"
5
5
  import { z } from "zod"
6
- import { projectSchema, themeSchema, type Theme } from "./schema"
6
+ import { projectSchema, themeSchema, type Project, type Theme } from "./schema"
7
7
 
8
8
  /**
9
- * Configuration de l'instance. La boutique est fixée par la variable
10
- * d'environnement BOUTIQUE et par rien d'autre : aucun paramètre d'URL,
11
- * cookie ou en-tête ne peut en changer.
9
+ * Configuration de l'instance. La boutique est fixée par la variable d'environnement BOUTIQUE :
10
+ * - mode multi : BOUTIQUE=<slug>, une instance par boutique ;
11
+ * - mode single : BOUTIQUE=auto, la boutique affichée est celle mise en ligne dans l'admin
12
+ * (fichier KEYS_DIR/_active écrit par le backend).
13
+ * Aucun paramètre d'URL, cookie ou en-tête ne peut changer la boutique affichée.
12
14
  */
13
15
  export type Boutique = {
14
16
  project: string
@@ -22,7 +24,7 @@ export type Boutique = {
22
24
  language: string
23
25
  /** ex. "en-US" : pour Intl et <html lang> */
24
26
  locale: string
25
- port: number
27
+ port?: number
26
28
  publishableKey: string
27
29
  merchantEmail?: string
28
30
  /** URL publique des autres boutiques du projet (lien « campagne suivante ») */
@@ -78,19 +80,14 @@ function loadTheme(id: string): Theme {
78
80
  return parseOrThrow(themeSchema, readJson(file), file)
79
81
  }
80
82
 
81
- function load(): Boutique {
82
- const slug = process.env.BOUTIQUE
83
- if (!slug) {
84
- throw new BoutiqueConfigError("variable d'environnement BOUTIQUE absente (ex. BOUTIQUE=yout-black-friday)")
85
- }
83
+ const projectFile = () => path.join(/*turbopackIgnore: true*/ CONFIG_DIR, "project.json")
86
84
 
87
- const projectFile = path.join(/*turbopackIgnore: true*/ CONFIG_DIR, "project.json")
88
- const project = parseOrThrow(projectSchema, readJson(projectFile), projectFile)
85
+ function load(slug: string, project: Project): Boutique {
89
86
 
90
87
  const entry = project.boutiques.find((b) => b.slug === slug)
91
88
  if (!entry) {
92
89
  throw new BoutiqueConfigError(
93
- `boutique « ${slug} » absente de ${projectFile}. Boutiques déclarées : ${project.boutiques.map((b) => b.slug).join(", ")}`
90
+ `boutique « ${slug} » absente de ${projectFile()}. Boutiques déclarées : ${project.boutiques.map((b) => b.slug).join(", ")}`
94
91
  )
95
92
  }
96
93
 
@@ -131,23 +128,74 @@ function load(): Boutique {
131
128
  port: entry.port,
132
129
  publishableKey,
133
130
  merchantEmail: defaults.merchantEmail,
131
+ // Mode single : toutes les boutiques partagent l'adresse du site unique
134
132
  siblings: Object.fromEntries(
135
- project.boutiques.map((b) => [b.slug, b.url ?? `http://localhost:${b.port}`])
133
+ project.boutiques.map((b) => [
134
+ b.slug,
135
+ project.mode === "single"
136
+ ? (project.site?.url ?? `http://localhost:${project.site?.port ?? 8000}`)
137
+ : (b.url ?? `http://localhost:${b.port}`),
138
+ ])
136
139
  ),
137
140
  }
138
141
  }
139
142
 
140
- let cached: Boutique | undefined
143
+ // Cache relu si project.json change (mode single : une boutique ajoutée par `apply` sans redémarrage)
144
+ let project: { mtime: number; data: Project } | undefined
145
+ const boutiques = new Map<string, Boutique>()
146
+
147
+ function readProject(): Project {
148
+ const file = projectFile()
149
+ const mtime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
150
+ if (!project || project.mtime !== mtime) {
151
+ project = { mtime, data: parseOrThrow(projectSchema, readJson(file), file) }
152
+ boutiques.clear()
153
+ }
154
+ return project.data
155
+ }
156
+
157
+ // Mode single : boutique active, relue au plus toutes les 2 s (le changement dans l'admin est quasi immédiat)
158
+ let active: { at: number; slug: string } | undefined
159
+ function activeSlug(p: Project): string {
160
+ if (active && Date.now() - active.at < 2000) return active.slug
161
+ let slug = ""
162
+ try {
163
+ slug = fs.readFileSync(path.join(/*turbopackIgnore: true*/ KEYS_DIR, "_active"), "utf8").trim()
164
+ } catch {
165
+ // pas encore écrit par le backend : première boutique
166
+ }
167
+ if (!p.boutiques.some((b) => b.slug === slug)) slug = p.boutiques[0].slug
168
+ active = { at: Date.now(), slug }
169
+ return slug
170
+ }
171
+
172
+ /** Mode du projet : multi (une instance par boutique) ou single (un site, la boutique active). */
173
+ export function isSingleSite(): boolean {
174
+ return process.env.BOUTIQUE === "auto"
175
+ }
141
176
 
142
177
  /**
143
- * Boutique de cette instance. Lue une fois (au démarrage via instrumentation.ts),
144
- * puis gardée en mémoire : modifier project.json demande un redémarrage (`apply`).
178
+ * Boutique affichée par cette instance (mode single : la boutique en ligne en ce moment),
179
+ * ou une boutique précise du projet (slug) : utilisé pour finir un panier commencé dans une autre boutique.
145
180
  */
146
- export function getBoutique(): Boutique {
147
- if (!cached) {
148
- cached = load()
149
- // Lu par lib/util/money.ts, partagé avec les composants client (qui lisent <html lang>).
150
- process.env.BOUTIQUE_LOCALE = cached.locale
181
+ export function getBoutique(slug?: string): Boutique {
182
+ const env = process.env.BOUTIQUE
183
+ if (!env) {
184
+ throw new BoutiqueConfigError("variable d'environnement BOUTIQUE absente (ex. BOUTIQUE=yout-black-friday, ou auto en mode single)")
185
+ }
186
+ const p = readProject()
187
+ const wanted = slug ?? (env === "auto" ? activeSlug(p) : env)
188
+ if (!p.boutiques.some((b) => b.slug === wanted)) {
189
+ throw new BoutiqueConfigError(
190
+ `boutique « ${wanted} » absente de ${projectFile()}. Boutiques déclarées : ${p.boutiques.map((b) => b.slug).join(", ")}`
191
+ )
192
+ }
193
+ let b = boutiques.get(wanted)
194
+ if (!b) {
195
+ b = load(wanted, p)
196
+ boutiques.set(wanted, b)
197
+ // Lu par lib/util/money.ts, partagé avec les composants client (qui lisent <html lang>) ; même langue pour tout le projet.
198
+ process.env.BOUTIQUE_LOCALE = b.locale
151
199
  }
152
- return cached
200
+ return b
153
201
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Nom de la page courante, posé en data-page sur .ub-page (app/(main)/template.tsx) et sur le checkout :
3
+ * sert à cibler une page dans styles/custom.css, ex. [data-page="product"] .ub-… ou :root:has([data-page="home"]) .ub-nav
4
+ */
5
+ export type PageName =
6
+ | "home" | "store" | "search" | "product" | "category" | "collection" | "cart" | "checkout"
7
+ | "account" | "order" | "track" | "contact" | "shipping" | "returns" | "privacy" | "terms" | "review" | "other"
8
+
9
+ export function pageName(path: string, search = ""): PageName {
10
+ const p = path.replace(/\/+$/, "") || "/"
11
+ if (p === "/") return "home"
12
+ if (p === "/store") return /(^|[?&])q=/.test(search) ? "search" : "store"
13
+ if (p.startsWith("/products/")) return "product"
14
+ if (p.startsWith("/categories/")) return "category"
15
+ if (p.startsWith("/collections/")) return "collection"
16
+ if (p === "/cart") return "cart"
17
+ if (p.startsWith("/checkout")) return "checkout"
18
+ if (p.startsWith("/account")) return "account"
19
+ if (p === "/review") return "review"
20
+ if (p === "/order/track") return "track"
21
+ if (p.startsWith("/order/")) return "order"
22
+ const legal = p.match(/^\/legal\/(contact|shipping|returns|privacy|terms)$/)
23
+ if (legal) return legal[1] as PageName
24
+ return "other"
25
+ }
@@ -33,6 +33,9 @@ const overridable = {
33
33
  export const projectSchema = z.object({
34
34
  project: z.string().regex(SLUG),
35
35
  templateVersion: z.string().optional(),
36
+ /** single : un seul site (site.port, site.url) qui affiche la boutique active (choisie dans l'admin) */
37
+ mode: z.enum(["multi", "single"]).default("multi"),
38
+ site: z.object({ port: z.number().int().min(1).max(65535), url: z.url().optional() }).optional(),
36
39
  defaults: z
37
40
  .object({
38
41
  ...overridable,
@@ -48,7 +51,7 @@ export const projectSchema = z.object({
48
51
  .object({
49
52
  slug: z.string().regex(SLUG, "slug : minuscules, chiffres et tirets"),
50
53
  theme: z.string().regex(SLUG),
51
- port: z.number().int().min(1).max(65535),
54
+ port: z.number().int().min(1).max(65535).optional(),
52
55
  url: z.url().optional(),
53
56
  storeName: overridable.storeName.optional(),
54
57
  currency: overridable.currency.optional(),
@@ -71,7 +74,7 @@ export const themeSchema = z.object({
71
74
  /** Variante : reprend la mise en page (règles CSS) de ce thème, avec la palette de styles/ub/variants.css. */
72
75
  layout: z.string().regex(SLUG).optional(),
73
76
  /** Page d'accueil : « default » (sections standard) ou « harvest » (accueil éditorial des thèmes Thanksgiving) */
74
- home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire"]).optional(),
77
+ home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire", "riviera", "sunset", "locker", "training", "arena", "mosaic", "lantern", "souk"]).optional(),
75
78
  fonts: z.object({
76
79
  head: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
77
80
  body: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
@@ -131,6 +134,8 @@ export const campaignFileSchema = z
131
134
  logo: z.url().max(500).optional(),
132
135
  /** undefined = selon le thème */
133
136
  showCountdown: z.boolean().optional(),
137
+ /** Site ouvert (défaut) ou fermé, décidé dans admin → Campagnes (mode multi) ; indépendant des dates */
138
+ online: z.boolean().optional(),
134
139
  })
135
140
  .refine((c) => new Date(c.startsAt) < new Date(c.endsAt), "startsAt doit précéder endsAt")
136
141
 
@@ -2,21 +2,45 @@ import "server-only"
2
2
 
3
3
  import { getBoutique } from "@lib/boutique/config"
4
4
  import Medusa, { FetchArgs, FetchInput } from "@medusajs/js-sdk"
5
+ import { cookies } from "next/headers"
5
6
 
6
7
  // URL interne du backend (réseau Docker) : jamais exposée au navigateur.
7
8
  const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
8
9
 
9
- let client: Medusa | undefined
10
+ /** Cookie : boutique où le panier a été créé (mode single, cf. setCartId). */
11
+ export const CART_BOUTIQUE_COOKIE = "_ub_cart_boutique"
12
+
13
+ // Requêtes liées au panier : elles utilisent la clé de la boutique du panier, pas celle affichée.
14
+ const CART_PATHS = /^\/?store\/(carts|payment-collections|shipping-options)\b/
10
15
 
11
16
  /**
12
- * Client Medusa de la boutique : la publishable key vient de KEYS_DIR/<slug>,
17
+ * Mode single : un client qui a commencé son panier avant un changement de boutique le termine
18
+ * dans sa boutique d'origine (même canal de vente, mêmes prix et même livraison). Mode multi : sans effet.
19
+ */
20
+ async function cartKey(input: FetchInput): Promise<string | undefined> {
21
+ if (process.env.BOUTIQUE !== "auto" || !CART_PATHS.test(String(input))) return undefined
22
+ try {
23
+ const slug = (await cookies()).get(CART_BOUTIQUE_COOKIE)?.value
24
+ if (!slug || slug === getBoutique().slug) return undefined
25
+ return getBoutique(slug).publishableKey
26
+ } catch {
27
+ // Boutique retirée du projet, ou appel hors requête : clé de la boutique affichée
28
+ return undefined
29
+ }
30
+ }
31
+
32
+ const clients = new Map<string, Medusa>()
33
+
34
+ /**
35
+ * Client Medusa de la boutique affichée : la publishable key vient de KEYS_DIR/<slug>,
13
36
  * lue à l'exécution (pas de variable NEXT_PUBLIC_* figée au build).
14
- * Créé au premier usage pour que `next build` n'ait besoin d'aucune boutique.
37
+ * Un client par boutique (mode single : la boutique active change sans redémarrage).
15
38
  */
16
39
  function getClient(): Medusa {
17
- if (client) return client
18
-
19
40
  const boutique = getBoutique()
41
+ const existing = clients.get(boutique.slug)
42
+ if (existing) return existing
43
+
20
44
  const medusa = new Medusa({
21
45
  baseUrl: MEDUSA_BACKEND_URL,
22
46
  debug: process.env.NODE_ENV === "development",
@@ -26,12 +50,17 @@ function getClient(): Medusa {
26
50
  // Langue fixée par l'instance : les traductions Medusa suivent la langue du projet.
27
51
  const originalFetch = medusa.client.fetch.bind(medusa.client)
28
52
  medusa.client.fetch = async <T>(input: FetchInput, init?: FetchArgs): Promise<T> => {
29
- const headers = { "x-medusa-locale": boutique.locale, ...(init?.headers ?? {}) }
53
+ const key = await cartKey(input)
54
+ const headers = {
55
+ "x-medusa-locale": boutique.locale,
56
+ ...(key ? { "x-publishable-api-key": key } : {}),
57
+ ...(init?.headers ?? {}),
58
+ }
30
59
  return originalFetch(input, { ...init, headers })
31
60
  }
32
61
 
33
- client = medusa
34
- return client
62
+ clients.set(boutique.slug, medusa)
63
+ return medusa
35
64
  }
36
65
 
37
66
  export const sdk = new Proxy({} as Medusa, {