@salifbiaye/create-boutique 0.6.0 → 0.7.1

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 (182) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +145 -11
  3. package/cli/dist/create.js +54 -7
  4. package/cli/dist/generate.js +65 -4
  5. package/cli/dist/guide.js +10 -2
  6. package/cli/dist/index.js +20 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/docs/fiche-client.en.html +2 -2
  9. package/docs/fiche-client.html +2 -2
  10. package/docs/guide-complet.fr.html +681 -256
  11. package/docs/guide-images/categories.jpg +0 -0
  12. package/docs/guide-images/clients.jpg +0 -0
  13. package/docs/guide-images/collections.jpg +0 -0
  14. package/docs/guide-images/commande-detail.jpg +0 -0
  15. package/docs/guide-images/commande-expedier.jpg +0 -0
  16. package/docs/guide-images/commande-menu.jpg +0 -0
  17. package/docs/guide-images/commande-paiement.jpg +0 -0
  18. package/docs/guide-images/commandes-liste.jpg +0 -0
  19. package/docs/guide-images/connexion.jpg +0 -0
  20. package/docs/guide-images/groupes-clients.jpg +0 -0
  21. package/docs/guide-images/inventaire.jpg +0 -0
  22. package/docs/guide-images/listes-prix.jpg +0 -0
  23. package/docs/guide-images/menu.jpg +0 -0
  24. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques.jpg +0 -0
  27. package/docs/guide-images/parametres-boutique.jpg +0 -0
  28. package/docs/guide-images/parametres-canaux.jpg +0 -0
  29. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  30. package/docs/guide-images/parametres-profil.jpg +0 -0
  31. package/docs/guide-images/parametres-regions.jpg +0 -0
  32. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  33. package/docs/guide-images/produit-creer-details.jpg +0 -0
  34. package/docs/guide-images/produit-detail.jpg +0 -0
  35. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  36. package/docs/guide-images/produit-variantes.jpg +0 -0
  37. package/docs/guide-images/produits-liste.jpg +0 -0
  38. package/docs/guide-images/promotion-details.jpg +0 -0
  39. package/docs/guide-images/promotion-type.jpg +0 -0
  40. package/docs/guide-images/promotions-liste.jpg +0 -0
  41. package/docs/guide-images/site-boutique.jpg +0 -0
  42. package/docs/guide-images/site-fermee.png +0 -0
  43. package/docs/guide-images/vue-ensemble.jpg +0 -0
  44. package/package.json +3 -2
  45. package/templates/backend/medusa-config.ts +31 -25
  46. package/templates/backend/package-lock.json +21 -0
  47. package/templates/backend/package.json +2 -0
  48. package/templates/backend/src/admin/i18n/json/en.json +51 -3
  49. package/templates/backend/src/admin/i18n/json/fr.json +51 -3
  50. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
  51. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
  52. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  53. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
  54. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  55. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  56. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  57. package/templates/backend/src/api/middlewares.ts +7 -0
  58. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  59. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  60. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  61. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  62. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  63. package/templates/backend/src/emails/en.json +3 -1
  64. package/templates/backend/src/emails/fr.json +3 -1
  65. package/templates/backend/src/lib/email-config.ts +14 -0
  66. package/templates/backend/src/lib/emails.ts +9 -0
  67. package/templates/backend/src/lib/notify.ts +14 -8
  68. package/templates/backend/src/lib/payment-config.ts +66 -0
  69. package/templates/backend/src/lib/project.ts +12 -0
  70. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  71. package/templates/backend/src/lib/review-token.ts +40 -0
  72. package/templates/backend/src/lib/reviews.ts +25 -0
  73. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  74. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  75. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  76. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  77. package/templates/backend/src/modules/paytech/index.ts +6 -0
  78. package/templates/backend/src/modules/paytech/service.ts +114 -0
  79. package/templates/backend/src/modules/product-review/index.ts +8 -0
  80. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  81. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  82. package/templates/backend/src/modules/product-review/service.ts +6 -0
  83. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  84. package/templates/backend/src/modules/smtp/index.ts +6 -0
  85. package/templates/backend/src/modules/smtp/service.ts +73 -0
  86. package/templates/backend/src/scripts/seed.ts +52 -36
  87. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  88. package/templates/backend/src/workflows/reviews.ts +21 -0
  89. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  90. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  91. package/templates/storefront/PERSONNALISER.md +134 -0
  92. package/templates/storefront/locales/en.json +198 -11
  93. package/templates/storefront/locales/fr.json +198 -11
  94. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  95. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  96. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  97. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  98. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  99. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  100. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  101. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  102. package/templates/storefront/src/app/layout.tsx +13 -1
  103. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  104. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  105. package/templates/storefront/src/lib/boutique/schema.ts +3 -1
  106. package/templates/storefront/src/lib/constants.tsx +17 -0
  107. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  108. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  109. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  110. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  111. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  112. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  113. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  114. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  115. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  116. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  117. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  118. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  119. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  120. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  121. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  122. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  123. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  124. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  125. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  126. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  127. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  128. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  129. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  130. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  131. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  132. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  133. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  134. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  135. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  136. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  137. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  138. package/templates/storefront/src/proxy.ts +2 -0
  139. package/templates/storefront/src/styles/custom.css +26 -0
  140. package/templates/storefront/src/styles/ub/album.css +1 -1
  141. package/templates/storefront/src/styles/ub/arena.css +120 -0
  142. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  143. package/templates/storefront/src/styles/ub/gala.css +1 -1
  144. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  145. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  146. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  147. package/templates/storefront/src/styles/ub/locker.css +115 -0
  148. package/templates/storefront/src/styles/ub/media.css +10 -0
  149. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  150. package/templates/storefront/src/styles/ub/motion.css +0 -3
  151. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  152. package/templates/storefront/src/styles/ub/pages.css +176 -0
  153. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  154. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  155. package/templates/storefront/src/styles/ub/souk.css +144 -0
  156. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  157. package/templates/storefront/src/styles/ub/tech.css +2 -5
  158. package/templates/storefront/src/styles/ub/training.css +120 -0
  159. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  160. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  161. package/templates/storefront/themes/jeux/theme.json +11 -0
  162. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  163. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  164. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  165. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  166. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  167. package/templates/storefront/themes/mubarak/theme.json +11 -0
  168. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  169. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  170. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  171. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  172. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  173. package/templates/storefront/themes/sport/theme.json +11 -0
  174. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  175. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  176. package/templates/storefront/themes/sport-2/theme.json +11 -0
  177. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  178. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  179. package/templates/storefront/themes/summer/theme.json +11 -0
  180. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  181. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  182. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -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
 
@@ -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
+ }
@@ -74,7 +74,7 @@ export const themeSchema = z.object({
74
74
  /** Variante : reprend la mise en page (règles CSS) de ce thème, avec la palette de styles/ub/variants.css. */
75
75
  layout: z.string().regex(SLUG).optional(),
76
76
  /** Page d'accueil : « default » (sections standard) ou « harvest » (accueil éditorial des thèmes Thanksgiving) */
77
- 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(),
78
78
  fonts: z.object({
79
79
  head: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
80
80
  body: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
@@ -134,6 +134,8 @@ export const campaignFileSchema = z
134
134
  logo: z.url().max(500).optional(),
135
135
  /** undefined = selon le thème */
136
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(),
137
139
  })
138
140
  .refine((c) => new Date(c.startsAt) < new Date(c.endsAt), "startsAt doit précéder endsAt")
139
141
 
@@ -35,6 +35,19 @@ export const paymentInfoMap: Record<
35
35
  title: <T k="checkout.manualPayment" />,
36
36
  icon: <CreditCard />,
37
37
  },
38
+ // Agrégateurs d'Afrique de l'Ouest : le client paie sur leur page (Wave, Orange Money, carte…), puis revient
39
+ pp_paytech_paytech: {
40
+ title: <T k="checkout.payPaytech" />,
41
+ icon: <CreditCard />,
42
+ },
43
+ pp_paydunya_paydunya: {
44
+ title: <T k="checkout.payPaydunya" />,
45
+ icon: <CreditCard />,
46
+ },
47
+ pp_naboopay_naboopay: {
48
+ title: <T k="checkout.payNaboopay" />,
49
+ icon: <CreditCard />,
50
+ },
38
51
  // Add more payment providers here
39
52
  }
40
53
 
@@ -48,6 +61,10 @@ export const isStripeLike = (providerId?: string) => {
48
61
  export const isPaypal = (providerId?: string) => {
49
62
  return providerId?.startsWith("pp_paypal")
50
63
  }
64
+ /** Paiement sur la page de l'agrégateur (PayTech, PayDunya, NabooPay), puis retour sur la boutique */
65
+ export const isRedirectPay = (providerId?: string) =>
66
+ !!providerId && /^pp_(paytech|paydunya|naboopay)_/.test(providerId)
67
+
51
68
  export const isManual = (providerId?: string) => {
52
69
  return providerId?.startsWith("pp_system_default")
53
70
  }
@@ -0,0 +1,28 @@
1
+ "use server"
2
+
3
+ import { sdk } from "@lib/config"
4
+
5
+ export type ReviewFormState = { ok?: boolean; error?: "invalid" | "duplicate" | "expired" | "tooMany" | "failed" }
6
+
7
+ /** Envoi d'un avis depuis la page « Laisser un avis » : le backend vérifie le jeton, la commande et le produit. */
8
+ export async function submitReview(_prev: ReviewFormState, form: FormData): Promise<ReviewFormState> {
9
+ const rating = Number(form.get("rating"))
10
+ const body = {
11
+ token: String(form.get("token") ?? ""),
12
+ product_id: String(form.get("product_id") ?? ""),
13
+ rating,
14
+ title: String(form.get("title") ?? "").trim() || undefined,
15
+ comment: String(form.get("comment") ?? "").trim(),
16
+ author_name: String(form.get("author_name") ?? "").trim(),
17
+ }
18
+ if (!(rating >= 1 && rating <= 5) || body.comment.length < 10 || !body.author_name || /[<>]/.test(body.comment + body.author_name + (body.title ?? ""))) {
19
+ return { error: "invalid" }
20
+ }
21
+ try {
22
+ await sdk.client.fetch("/store/reviews", { method: "POST", body, cache: "no-store" })
23
+ return { ok: true }
24
+ } catch (e) {
25
+ const status = (e as { status?: number }).status
26
+ return { error: status === 429 ? "tooMany" : status === 409 || status === 422 ? "duplicate" : status === 400 || status === 401 || status === 403 || status === 404 ? "expired" : "failed" }
27
+ }
28
+ }
@@ -0,0 +1,64 @@
1
+ import "server-only"
2
+
3
+ import { cache } from "react"
4
+ import { sdk } from "@lib/config"
5
+
6
+ /**
7
+ * Avis clients (backend : module productReview). Lecture en cache 1 min : un avis validé dans l'admin
8
+ * apparaît sur le site en une minute. Toute erreur = « pas d'avis » (jamais une page cassée).
9
+ */
10
+ export type Review = { id: string; product_id: string; author_name: string; rating: number; title: string | null; comment: string; created_at: string }
11
+ export type ReviewSummary = { average: number; count: number; distribution: [number, number, number, number, number] }
12
+ export type LatestReview = Review & { product: { title: string; handle: string; thumbnail: string | null } }
13
+ export type ReviewOrder = {
14
+ number: number
15
+ first_name: string
16
+ products: { product_id: string; title: string; handle?: string; thumbnail?: string; reviewed: boolean }[]
17
+ }
18
+
19
+ const CACHED = { method: "GET" as const, next: { revalidate: 60 }, cache: "force-cache" as const }
20
+ const EMPTY: ReviewSummary = { average: 0, count: 0, distribution: [0, 0, 0, 0, 0] }
21
+
22
+ export const getProductReviews = cache(async (productId: string, limit = 6) => {
23
+ try {
24
+ return await sdk.client.fetch<{ reviews: Review[]; count: number; summary: ReviewSummary }>(
25
+ `/store/reviews?product_id=${encodeURIComponent(productId)}&limit=${limit}`,
26
+ CACHED
27
+ )
28
+ } catch {
29
+ return { reviews: [] as Review[], count: 0, summary: EMPTY }
30
+ }
31
+ })
32
+
33
+ /** Note moyenne et nombre d'avis par produit (cartes produits) */
34
+ export async function getReviewSummaries(productIds: string[]): Promise<Record<string, { average: number; count: number }>> {
35
+ const ids = [...new Set(productIds)].filter(Boolean).sort()
36
+ if (!ids.length) return {}
37
+ try {
38
+ const { summaries } = await sdk.client.fetch<{ summaries: Record<string, { average: number; count: number }> }>(
39
+ `/store/reviews/summary?product_ids=${ids.map(encodeURIComponent).join(",")}`,
40
+ CACHED
41
+ )
42
+ return summaries
43
+ } catch {
44
+ return {}
45
+ }
46
+ }
47
+
48
+ export const getLatestReviews = cache(async (limit = 6): Promise<LatestReview[]> => {
49
+ try {
50
+ const { reviews } = await sdk.client.fetch<{ reviews: LatestReview[] }>(`/store/reviews/latest?limit=${limit}`, CACHED)
51
+ return reviews
52
+ } catch {
53
+ return []
54
+ }
55
+ })
56
+
57
+ /** Page « Laisser un avis » : produits de la commande du lien (jamais en cache : dépend du jeton) */
58
+ export async function getReviewOrder(token: string): Promise<ReviewOrder | null> {
59
+ try {
60
+ return await sdk.client.fetch<ReviewOrder>(`/store/reviews/order?token=${encodeURIComponent(token)}`, { method: "GET", cache: "no-store" })
61
+ } catch {
62
+ return null
63
+ }
64
+ }
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
3
 
4
- import { T } from "@lib/boutique/i18n-client"
4
+ import { T, useT } from "@lib/boutique/i18n-client"
5
5
  import { RadioGroup } from "@headlessui/react"
6
- import { isStripeLike, paymentInfoMap } from "@lib/constants"
6
+ import { isRedirectPay, isStripeLike, paymentInfoMap } from "@lib/constants"
7
7
  import { initiatePaymentSession } from "@lib/data/cart"
8
8
  import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
9
9
  import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
@@ -43,6 +43,25 @@ const Payment = ({
43
43
  const pathname = usePathname()
44
44
 
45
45
  const isOpen = searchParams.get("step") === "payment"
46
+ const t = useT()
47
+
48
+ /**
49
+ * Paiement sur la page d'un agrégateur (PayTech, PayDunya, NabooPay) : adresse de retour sur cette boutique
50
+ * (vérifiée par le backend) et client (NabooPay l'exige). Le montant, lui, est toujours calculé par Medusa.
51
+ */
52
+ const redirectData = () => {
53
+ const a = cart.shipping_address ?? cart.billing_address
54
+ const base = `${window.location.origin}/checkout?step=review`
55
+ return {
56
+ return_url: `${base}&pay_return=1`,
57
+ cancel_url: `${base}&pay_return=cancel`,
58
+ label: t("checkout.orderLabel"),
59
+ first_name: a?.first_name ?? undefined,
60
+ last_name: a?.last_name ?? undefined,
61
+ phone: a?.phone ?? undefined,
62
+ email: cart.email ?? undefined,
63
+ }
64
+ }
46
65
 
47
66
  const setPaymentMethod = async (method: string) => {
48
67
  setError(null)
@@ -85,9 +104,11 @@ const Payment = ({
85
104
  const checkActiveSession =
86
105
  activeSession?.provider_id === selectedPaymentMethod
87
106
 
88
- if (!checkActiveSession) {
107
+ // Agrégateur : toujours un paiement neuf (montant et client à jour)
108
+ if (!checkActiveSession || isRedirectPay(selectedPaymentMethod)) {
89
109
  await initiatePaymentSession(cart, {
90
110
  provider_id: selectedPaymentMethod,
111
+ ...(isRedirectPay(selectedPaymentMethod) ? { data: redirectData() } : {}),
91
112
  })
92
113
  }
93
114
 
@@ -100,7 +121,8 @@ const Payment = ({
100
121
  )
101
122
  }
102
123
  } catch (err: any) {
103
- setError(err.message)
124
+ // Agrégateur (clés, montant, service indisponible) : message clair, le détail est dans les logs du backend
125
+ setError(isRedirectPay(selectedPaymentMethod) ? t("checkout.payInitError") : err.message)
104
126
  } finally {
105
127
  setIsLoading(false)
106
128
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
 
4
4
  import { T } from "@lib/boutique/i18n-client"
5
- import { isManual, isStripeLike } from "@lib/constants"
5
+ import { isManual, isRedirectPay, isStripeLike } from "@lib/constants"
6
6
  import { placeOrder } from "@lib/data/cart"
7
7
  import { HttpTypes } from "@medusajs/types"
8
8
  import { Button } from "@medusajs/ui"
@@ -28,7 +28,9 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
28
28
  (cart.shipping_methods?.length ?? 0) < 1
29
29
 
30
30
  const paymentSession = cart.payment_collection?.payment_sessions?.[0]
31
- const redirectStatus = useSearchParams().get("redirect_status")
31
+ const params = useSearchParams()
32
+ const redirectStatus = params.get("redirect_status")
33
+ const payReturn = params.get("pay_return")
32
34
 
33
35
  switch (true) {
34
36
  case isStripeLike(paymentSession?.provider_id) && !!redirectStatus:
@@ -45,6 +47,15 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
45
47
  data-testid={dataTestId}
46
48
  />
47
49
  )
50
+ case isRedirectPay(paymentSession?.provider_id):
51
+ return (
52
+ <RedirectPayButton
53
+ notReady={notReady}
54
+ redirectUrl={typeof paymentSession?.data?.redirect_url === "string" ? paymentSession.data.redirect_url : undefined}
55
+ payReturn={payReturn}
56
+ data-testid={dataTestId}
57
+ />
58
+ )
48
59
  case isManual(paymentSession?.provider_id):
49
60
  return (
50
61
  <ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
@@ -54,6 +65,78 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
54
65
  }
55
66
  }
56
67
 
68
+ /**
69
+ * Agrégateur (PayTech, PayDunya, NabooPay) : « Payer maintenant » envoie le client sur la page de paiement.
70
+ * Au retour (?pay_return=1), la commande est validée : le backend demande le statut à l'agrégateur (on ne croit jamais
71
+ * l'URL de retour). Paiement encore en cours (Wave peut prendre un moment) : message + « Vérifier à nouveau » ;
72
+ * de toute façon la notification de l'agrégateur validera la commande côté serveur.
73
+ */
74
+ const RedirectPayButton = ({
75
+ notReady,
76
+ redirectUrl,
77
+ payReturn,
78
+ "data-testid": dataTestId,
79
+ }: {
80
+ notReady: boolean
81
+ redirectUrl?: string
82
+ payReturn: string | null
83
+ "data-testid"?: string
84
+ }) => {
85
+ const [busy, setBusy] = useState(payReturn === "1")
86
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
87
+ const started = useRef(false)
88
+
89
+ const confirm = () => {
90
+ setBusy(true)
91
+ setErrorMessage(null)
92
+ placeOrder()
93
+ .catch((err) => setErrorMessage(err.message))
94
+ .finally(() => setBusy(false))
95
+ }
96
+
97
+ useEffect(() => {
98
+ if (payReturn !== "1" || started.current) return
99
+ started.current = true
100
+ confirm()
101
+ }, [payReturn])
102
+
103
+ if (payReturn === "1") {
104
+ return (
105
+ <>
106
+ <Button size="large" isLoading={busy} disabled={busy} onClick={confirm} data-testid={dataTestId}>
107
+ <T k={busy ? "checkout.payChecking" : "checkout.payRetry"} />
108
+ </Button>
109
+ {errorMessage && (
110
+ <p className="text-small-regular text-ui-fg-subtle mt-2" role="status">
111
+ <T k="checkout.paymentPending" />
112
+ </p>
113
+ )}
114
+ </>
115
+ )
116
+ }
117
+
118
+ return (
119
+ <>
120
+ <Button
121
+ size="large"
122
+ disabled={notReady || !redirectUrl}
123
+ isLoading={busy}
124
+ onClick={() => {
125
+ if (!redirectUrl) return
126
+ setBusy(true)
127
+ window.location.assign(redirectUrl)
128
+ }}
129
+ data-testid={dataTestId}
130
+ >
131
+ <T k="checkout.payRedirect" />
132
+ </Button>
133
+ <p className="text-small-regular text-ui-fg-subtle mt-2" role="status">
134
+ <T k={payReturn === "cancel" ? "checkout.payCancelled" : "checkout.payRedirectHint"} />
135
+ </p>
136
+ </>
137
+ )
138
+ }
139
+
57
140
  /** Retour d'un moyen de paiement à redirection (Klarna, banque…) : Stripe renvoie sur /checkout?step=review&redirect_status=… */
58
141
  const StripeRedirectReturn = ({ status, children }: { status: string; children: React.ReactNode }) => {
59
142
  const [errorMessage, setErrorMessage] = useState<string | null>(null)
@@ -1,4 +1,5 @@
1
1
  import { Suspense } from "react"
2
+ import { ProductReviews } from "@modules/ub/reviews"
2
3
  import { notFound } from "next/navigation"
3
4
  import { HttpTypes } from "@medusajs/types"
4
5
 
@@ -110,6 +111,10 @@ export default function ProductTemplate({ product, region, images }: ProductTemp
110
111
  </div>
111
112
  </section>
112
113
 
114
+ <Suspense fallback={null}>
115
+ <ProductReviews productId={product.id} />
116
+ </Suspense>
117
+
113
118
  <Suspense fallback={null}>
114
119
  <Related product={product} />
115
120
  </Suspense>
@@ -0,0 +1,85 @@
1
+ "use client"
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
4
+ import { useEffect, useRef, useState, useTransition } from "react"
5
+
6
+ /**
7
+ * Recherche de la page « Boutique » : résultats pendant la frappe (pause de 350 ms), en gardant catégorie et tri.
8
+ * Formulaire GET classique : fonctionne aussi sans JavaScript (Entrée). Focus automatique si l'URL finit par #recherche
9
+ * (icône loupe de l'en-tête sur mobile).
10
+ */
11
+ export default function ShopSearch({ placeholder, clearLabel }: { placeholder: string; clearLabel: string }) {
12
+ const router = useRouter()
13
+ const pathname = usePathname()
14
+ const params = useSearchParams()
15
+ const [value, setValue] = useState(params.get("q") ?? "")
16
+ const [pending, start] = useTransition()
17
+ const input = useRef<HTMLInputElement>(null)
18
+ const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
19
+
20
+ useEffect(() => {
21
+ if (window.location.hash === "#recherche") input.current?.focus()
22
+ }, [])
23
+
24
+ const go = (q: string) => {
25
+ const next = new URLSearchParams(params)
26
+ if (q.trim()) next.set("q", q.trim())
27
+ else next.delete("q")
28
+ next.delete("page")
29
+ const s = next.toString()
30
+ start(() => router.replace(s ? `${pathname}?${s}` : pathname, { scroll: false }))
31
+ }
32
+
33
+ return (
34
+ <form
35
+ className="ub-shopsearch"
36
+ role="search"
37
+ action="/store"
38
+ aria-busy={pending}
39
+ onSubmit={(e) => {
40
+ e.preventDefault()
41
+ clearTimeout(timer.current)
42
+ go(value)
43
+ }}
44
+ >
45
+ <svg viewBox="0 0 24 24" aria-hidden>
46
+ <circle cx="11" cy="11" r="6.5" />
47
+ <path d="M20 20l-4.2-4.2" />
48
+ </svg>
49
+ <input
50
+ ref={input}
51
+ id="recherche"
52
+ type="search"
53
+ name="q"
54
+ value={value}
55
+ placeholder={placeholder}
56
+ aria-label={placeholder}
57
+ autoComplete="off"
58
+ onChange={(e) => {
59
+ const q = e.target.value
60
+ setValue(q)
61
+ clearTimeout(timer.current)
62
+ timer.current = setTimeout(() => go(q), 350)
63
+ }}
64
+ />
65
+ {/* Filtres gardés quand le formulaire est envoyé sans JavaScript */}
66
+ {params.get("sortBy") && <input type="hidden" name="sortBy" value={params.get("sortBy") ?? ""} />}
67
+ {params.get("category") && <input type="hidden" name="category" value={params.get("category") ?? ""} />}
68
+ {value && (
69
+ <button
70
+ type="button"
71
+ className="ub-shopsearch__clear"
72
+ aria-label={clearLabel}
73
+ onClick={() => {
74
+ setValue("")
75
+ clearTimeout(timer.current)
76
+ go("")
77
+ input.current?.focus()
78
+ }}
79
+ >
80
+ ×
81
+ </button>
82
+ )}
83
+ </form>
84
+ )
85
+ }
@@ -8,6 +8,8 @@ import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-g
8
8
  import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
9
9
  import { CampaignCountdown } from "@modules/ub/campaign-blocks"
10
10
 
11
+ import ShopSearch from "@modules/store/components/shop-search"
12
+
11
13
  import PaginatedProducts from "./paginated-products"
12
14
 
13
15
  const SORTS: [SortOptions, string][] = [
@@ -17,21 +19,24 @@ const SORTS: [SortOptions, string][] = [
17
19
  ]
18
20
 
19
21
  /** Lien de la page boutique en gardant les autres filtres (serveur, sans JavaScript). */
20
- const shopHref = (q: { sortBy?: string; category?: string }) => {
22
+ const shopHref = (q: { sortBy?: string; category?: string; q?: string }) => {
21
23
  const params = new URLSearchParams(Object.entries(q).filter(([, v]) => v) as [string, string][])
22
24
  const s = params.toString()
23
25
  return s ? `/store?${s}` : "/store"
24
26
  }
25
27
 
26
- /** Page « Boutique » : bandeau de campagne, catégories en pastilles, tri en boutons, grille. */
28
+ /** Page « Boutique » : bandeau de campagne, catégories en pastilles, recherche, tri en boutons, grille. */
27
29
  const StoreTemplate = async ({
28
30
  sortBy,
29
31
  page,
30
32
  category,
33
+ q,
31
34
  }: {
32
35
  sortBy?: SortOptions
33
36
  page?: string
34
37
  category?: string
38
+ /** Recherche (titre, description… : recherche plein texte de Medusa, limitée au canal de vente de la boutique) */
39
+ q?: string
35
40
  }) => {
36
41
  const pageNumber = page ? parseInt(page) : 1
37
42
  const sort = sortBy || "created_at"
@@ -52,7 +57,7 @@ const StoreTemplate = async ({
52
57
  </span>
53
58
  )}
54
59
  <h1 className="ub-head ub-shophero__title" data-testid="store-page-title">
55
- {active?.name ?? campaign.sections?.products ?? t("footer.all")}
60
+ {q ? t("shop.results", { q }) : (active?.name ?? campaign.sections?.products ?? t("footer.all"))}
56
61
  </h1>
57
62
  {campaign.promo?.text && <p className="ub-shophero__sub">{campaign.promo.text}</p>}
58
63
  </div>
@@ -68,13 +73,13 @@ const StoreTemplate = async ({
68
73
  <div className="ub-wrap ub-shop" data-testid="category-container">
69
74
  <div className="ub-shop__bar">
70
75
  <nav className="ub-chips" aria-label={t("section.categories")}>
71
- <Link href={shopHref({ sortBy })} className="ub-chip" aria-current={!active ? "page" : undefined}>
76
+ <Link href={shopHref({ sortBy, q })} className="ub-chip" aria-current={!active ? "page" : undefined}>
72
77
  {t("shop.all")}
73
78
  </Link>
74
79
  {topLevel.map((c) => (
75
80
  <Link
76
81
  key={c.id}
77
- href={shopHref({ sortBy, category: c.handle })}
82
+ href={shopHref({ sortBy, category: c.handle, q })}
78
83
  className="ub-chip"
79
84
  aria-current={active?.id === c.id ? "page" : undefined}
80
85
  >
@@ -82,11 +87,14 @@ const StoreTemplate = async ({
82
87
  </Link>
83
88
  ))}
84
89
  </nav>
90
+ <Suspense fallback={<div className="ub-shopsearch" />}>
91
+ <ShopSearch placeholder={t("nav.search")} clearLabel={t("shop.clear")} />
92
+ </Suspense>
85
93
  <nav className="ub-sort" aria-label={t("store.sortBy")}>
86
94
  {SORTS.map(([value, key]) => (
87
95
  <Link
88
96
  key={value}
89
- href={shopHref({ sortBy: value === "created_at" ? undefined : value, category })}
97
+ href={shopHref({ sortBy: value === "created_at" ? undefined : value, category, q })}
90
98
  className="ub-sort__opt"
91
99
  aria-current={sort === value ? "true" : undefined}
92
100
  scroll={false}
@@ -96,8 +104,8 @@ const StoreTemplate = async ({
96
104
  ))}
97
105
  </nav>
98
106
  </div>
99
- <Suspense key={`${sort}-${category}-${pageNumber}`} fallback={<SkeletonProductGrid />}>
100
- <PaginatedProducts sortBy={sort} page={pageNumber} categoryId={active?.id} showCount />
107
+ <Suspense key={`${sort}-${category}-${pageNumber}-${q}`} fallback={<SkeletonProductGrid />}>
108
+ <PaginatedProducts sortBy={sort} page={pageNumber} categoryId={active?.id} q={q} showCount />
101
109
  </Suspense>
102
110
  </div>
103
111
  </>