@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.
- package/cli/dist/code.js +110 -0
- package/cli/dist/commands.js +241 -17
- package/cli/dist/create.js +108 -15
- package/cli/dist/generate.js +108 -39
- package/cli/dist/guide.js +12 -4
- package/cli/dist/index.js +38 -7
- package/cli/dist/payments.js +150 -0
- package/cli/dist/project.js +42 -3
- package/docs/fiche-client.en.html +2 -2
- package/docs/fiche-client.html +2 -2
- package/docs/guide-complet.fr.html +681 -256
- package/docs/guide-images/categories.jpg +0 -0
- package/docs/guide-images/clients.jpg +0 -0
- package/docs/guide-images/collections.jpg +0 -0
- package/docs/guide-images/commande-detail.jpg +0 -0
- package/docs/guide-images/commande-expedier.jpg +0 -0
- package/docs/guide-images/commande-menu.jpg +0 -0
- package/docs/guide-images/commande-paiement.jpg +0 -0
- package/docs/guide-images/commandes-liste.jpg +0 -0
- package/docs/guide-images/connexion.jpg +0 -0
- package/docs/guide-images/groupes-clients.jpg +0 -0
- package/docs/guide-images/inventaire.jpg +0 -0
- package/docs/guide-images/listes-prix.jpg +0 -0
- package/docs/guide-images/menu.jpg +0 -0
- package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
- package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
- package/docs/guide-images/mes-boutiques.jpg +0 -0
- package/docs/guide-images/parametres-boutique.jpg +0 -0
- package/docs/guide-images/parametres-canaux.jpg +0 -0
- package/docs/guide-images/parametres-emplacements.jpg +0 -0
- package/docs/guide-images/parametres-profil.jpg +0 -0
- package/docs/guide-images/parametres-regions.jpg +0 -0
- package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
- package/docs/guide-images/produit-creer-details.jpg +0 -0
- package/docs/guide-images/produit-detail.jpg +0 -0
- package/docs/guide-images/produit-variantes-menu.jpg +0 -0
- package/docs/guide-images/produit-variantes.jpg +0 -0
- package/docs/guide-images/produits-liste.jpg +0 -0
- package/docs/guide-images/promotion-details.jpg +0 -0
- package/docs/guide-images/promotion-type.jpg +0 -0
- package/docs/guide-images/promotions-liste.jpg +0 -0
- package/docs/guide-images/site-boutique.jpg +0 -0
- package/docs/guide-images/site-fermee.png +0 -0
- package/docs/guide-images/vue-ensemble.jpg +0 -0
- package/package.json +3 -2
- package/templates/backend/medusa-config.ts +31 -25
- package/templates/backend/package-lock.json +21 -0
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/admin/i18n/json/en.json +63 -5
- package/templates/backend/src/admin/i18n/json/fr.json +63 -5
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
- package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
- package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
- package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
- package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
- package/templates/backend/src/api/admin/reviews/route.ts +47 -0
- package/templates/backend/src/api/middlewares.ts +9 -0
- package/templates/backend/src/api/store/campaign/route.ts +5 -1
- package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
- package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
- package/templates/backend/src/api/store/reviews/route.ts +43 -0
- package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
- package/templates/backend/src/emails/en.json +3 -1
- package/templates/backend/src/emails/fr.json +3 -1
- package/templates/backend/src/lib/active.ts +41 -0
- package/templates/backend/src/lib/email-config.ts +14 -0
- package/templates/backend/src/lib/emails.ts +9 -0
- package/templates/backend/src/lib/notify.ts +14 -8
- package/templates/backend/src/lib/payment-config.ts +66 -0
- package/templates/backend/src/lib/project.ts +38 -1
- package/templates/backend/src/lib/redirect-payment.ts +264 -0
- package/templates/backend/src/lib/review-token.ts +40 -0
- package/templates/backend/src/lib/reviews.ts +25 -0
- package/templates/backend/src/modules/naboopay/index.ts +6 -0
- package/templates/backend/src/modules/naboopay/service.ts +92 -0
- package/templates/backend/src/modules/paydunya/index.ts +6 -0
- package/templates/backend/src/modules/paydunya/service.ts +89 -0
- package/templates/backend/src/modules/paytech/index.ts +6 -0
- package/templates/backend/src/modules/paytech/service.ts +114 -0
- package/templates/backend/src/modules/product-review/index.ts +8 -0
- package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
- package/templates/backend/src/modules/product-review/models/review.ts +25 -0
- package/templates/backend/src/modules/product-review/service.ts +6 -0
- package/templates/backend/src/modules/product-review/validators.ts +27 -0
- package/templates/backend/src/modules/smtp/index.ts +6 -0
- package/templates/backend/src/modules/smtp/service.ts +73 -0
- package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
- package/templates/backend/src/scripts/seed.ts +60 -36
- package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
- package/templates/backend/src/workflows/reviews.ts +21 -0
- package/templates/backend/src/workflows/steps/create-review.ts +52 -0
- package/templates/backend/src/workflows/steps/update-review.ts +36 -0
- package/templates/storefront/PERSONNALISER.md +134 -0
- package/templates/storefront/locales/en.json +198 -11
- package/templates/storefront/locales/fr.json +198 -11
- package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
- package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
- package/templates/storefront/src/app/(main)/layout.tsx +8 -1
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
- package/templates/storefront/src/app/(main)/page.tsx +149 -4
- package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
- package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/(main)/template.tsx +17 -2
- package/templates/storefront/src/app/layout.tsx +13 -1
- package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
- package/templates/storefront/src/lib/boutique/config.ts +71 -23
- package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
- package/templates/storefront/src/lib/boutique/schema.ts +7 -2
- package/templates/storefront/src/lib/config.ts +37 -8
- package/templates/storefront/src/lib/constants.tsx +17 -0
- package/templates/storefront/src/lib/data/cart.ts +4 -1
- package/templates/storefront/src/lib/data/cookies.ts +14 -2
- package/templates/storefront/src/lib/data/review-actions.ts +28 -0
- package/templates/storefront/src/lib/data/reviews.ts +64 -0
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
- package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
- package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
- package/templates/storefront/src/modules/ub/album.tsx +2 -0
- package/templates/storefront/src/modules/ub/arena.tsx +182 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
- package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
- package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
- package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
- package/templates/storefront/src/modules/ub/gala.tsx +2 -0
- package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
- package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
- package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
- package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
- package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
- package/templates/storefront/src/modules/ub/locker.tsx +180 -0
- package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
- package/templates/storefront/src/modules/ub/nav.tsx +8 -5
- package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
- package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
- package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
- package/templates/storefront/src/modules/ub/souk.tsx +214 -0
- package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
- package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
- package/templates/storefront/src/modules/ub/tech.tsx +2 -2
- package/templates/storefront/src/modules/ub/training.tsx +196 -0
- package/templates/storefront/src/proxy.ts +4 -1
- package/templates/storefront/src/styles/custom.css +26 -0
- package/templates/storefront/src/styles/ub/album.css +1 -1
- package/templates/storefront/src/styles/ub/arena.css +120 -0
- package/templates/storefront/src/styles/ub/cinema.css +1 -1
- package/templates/storefront/src/styles/ub/gala.css +1 -1
- package/templates/storefront/src/styles/ub/grimoire.css +23 -7
- package/templates/storefront/src/styles/ub/harvest.css +7 -6
- package/templates/storefront/src/styles/ub/lantern.css +157 -0
- package/templates/storefront/src/styles/ub/locker.css +115 -0
- package/templates/storefront/src/styles/ub/media.css +10 -0
- package/templates/storefront/src/styles/ub/mosaic.css +118 -0
- package/templates/storefront/src/styles/ub/motion.css +0 -2
- package/templates/storefront/src/styles/ub/overrides.css +10 -0
- package/templates/storefront/src/styles/ub/pages.css +176 -0
- package/templates/storefront/src/styles/ub/reviews.css +85 -0
- package/templates/storefront/src/styles/ub/riviera.css +103 -0
- package/templates/storefront/src/styles/ub/souk.css +144 -0
- package/templates/storefront/src/styles/ub/sunset.css +123 -0
- package/templates/storefront/src/styles/ub/tech.css +5 -8
- package/templates/storefront/src/styles/ub/training.css +120 -0
- package/templates/storefront/themes/jeux/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux/theme.json +11 -0
- package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux-2/theme.json +11 -0
- package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak/theme.json +11 -0
- package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak-2/theme.json +11 -0
- package/templates/storefront/themes/sport/campaign.en.json +31 -0
- package/templates/storefront/themes/sport/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport/theme.json +11 -0
- package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
- package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport-2/theme.json +11 -0
- package/templates/storefront/themes/summer/campaign.en.json +31 -0
- package/templates/storefront/themes/summer/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer/theme.json +11 -0
- package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
- package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
- 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 {
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => [
|
|
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
|
-
|
|
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
|
|
144
|
-
*
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
34
|
-
return
|
|
62
|
+
clients.set(boutique.slug, medusa)
|
|
63
|
+
return medusa
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
export const sdk = new Proxy({} as Medusa, {
|