@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
@@ -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
  }
@@ -9,6 +9,7 @@ import {
9
9
  getAuthHeaders,
10
10
  getCacheOptions,
11
11
  getCacheTag,
12
+ cartFromOtherBoutique,
12
13
  getCartId,
13
14
  removeCartId,
14
15
  setCartId,
@@ -54,7 +55,9 @@ export async function retrieveCart(cartId?: string, fields?: string) {
54
55
  export async function getOrSetCart() {
55
56
  const region = await getRegion()
56
57
 
57
- let cart = await retrieveCart(undefined, "id,region_id")
58
+ // Mode single : un nouvel article ajouté après un changement de boutique démarre un panier dans la boutique en ligne
59
+ // (l'ancien panier appartient à l'autre canal de vente et ne peut pas recevoir ses produits).
60
+ let cart = (await cartFromOtherBoutique()) ? null : await retrieveCart(undefined, "id,region_id")
58
61
 
59
62
  const headers = {
60
63
  ...(await getAuthHeaders()),
@@ -1,5 +1,6 @@
1
1
  import "server-only"
2
- import { getBoutique } from "@lib/boutique/config"
2
+ import { getBoutique, isSingleSite } from "@lib/boutique/config"
3
+ import { CART_BOUTIQUE_COOKIE } from "@lib/config"
3
4
  import { cookies as nextCookies } from "next/headers"
4
5
 
5
6
  /**
@@ -7,9 +8,10 @@ import { cookies as nextCookies } from "next/headers"
7
8
  * toutes les boutiques du projet les partagent. Chaque nom est donc suffixé
8
9
  * par le slug, pour qu'un panier ou une session d'une boutique n'apparaisse
9
10
  * jamais dans une autre.
11
+ * Mode single : un seul site, noms fixes : le client reste connecté et garde son panier quand la boutique change.
10
12
  */
11
13
  export const cookieName = (base: "jwt" | "cart_id" | "cache_id" | "onboarding") =>
12
- `_medusa_${base}_${getBoutique().slug}`
14
+ `_medusa_${base}_${isSingleSite() ? "site" : getBoutique().slug}`
13
15
 
14
16
  export const getAuthHeaders = async (): Promise<
15
17
  { authorization: string } | {}
@@ -93,6 +95,15 @@ export const setCartId = async (cartId: string) => {
93
95
  ...secureCookie,
94
96
  maxAge: 60 * 60 * 24 * 7,
95
97
  })
98
+ // Boutique du panier (mode single) : le client finit son panier dans cette boutique même si le site change
99
+ cookies.set(CART_BOUTIQUE_COOKIE, getBoutique().slug, { ...secureCookie, maxAge: 60 * 60 * 24 * 7 })
100
+ }
101
+
102
+ /** Mode single : le panier en cours a été commencé dans une autre boutique que celle en ligne maintenant. */
103
+ export const cartFromOtherBoutique = async () => {
104
+ if (!isSingleSite()) return false
105
+ const slug = (await nextCookies()).get(CART_BOUTIQUE_COOKIE)?.value
106
+ return !!slug && slug !== getBoutique().slug
96
107
  }
97
108
 
98
109
  export const removeCartId = async () => {
@@ -100,4 +111,5 @@ export const removeCartId = async () => {
100
111
  cookies.set(cookieName("cart_id"), "", {
101
112
  maxAge: -1,
102
113
  })
114
+ cookies.set(CART_BOUTIQUE_COOKIE, "", { maxAge: -1 })
103
115
  }
@@ -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,3 +1,4 @@
1
+ import { useLocale } from "@lib/boutique/i18n-client"
1
2
  import { HttpTypes } from "@medusajs/types"
2
3
  import { Container } from "@medusajs/ui"
3
4
  import Checkbox from "@modules/common/components/checkbox"
@@ -19,6 +20,10 @@ const ShippingAddress = ({
19
20
  checked: boolean
20
21
  onChange: () => void
21
22
  }) => {
23
+ // Pays de la boutique (fin de la locale, ex. fr-FR → fr) proposé par défaut s'il est livré
24
+ const locale = useLocale()
25
+ const homeCountry = locale.split("-")[1]?.toLowerCase() ?? ""
26
+ const defaultCountry = cart?.region?.countries?.some((c) => c.iso_2 === homeCountry) ? homeCountry : ""
22
27
  const [formData, setFormData] = useState<Record<string, any>>({
23
28
  "shipping_address.first_name": cart?.shipping_address?.first_name || "",
24
29
  "shipping_address.last_name": cart?.shipping_address?.last_name || "",
@@ -26,7 +31,7 @@ const ShippingAddress = ({
26
31
  "shipping_address.company": cart?.shipping_address?.company || "",
27
32
  "shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
28
33
  "shipping_address.city": cart?.shipping_address?.city || "",
29
- "shipping_address.country_code": cart?.shipping_address?.country_code || "",
34
+ "shipping_address.country_code": cart?.shipping_address?.country_code || defaultCountry,
30
35
  "shipping_address.province": cart?.shipping_address?.province || "",
31
36
  "shipping_address.phone": cart?.shipping_address?.phone || "",
32
37
  email: cart?.email || "",
@@ -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
  </>
@@ -13,6 +13,7 @@ type PaginatedProductsParams = {
13
13
  category_id?: string[]
14
14
  id?: string[]
15
15
  order?: string
16
+ q?: string
16
17
  }
17
18
 
18
19
  export default async function PaginatedProducts({
@@ -21,6 +22,7 @@ export default async function PaginatedProducts({
21
22
  collectionId,
22
23
  categoryId,
23
24
  productsIds,
25
+ q,
24
26
  showCount,
25
27
  }: {
26
28
  sortBy?: SortOptions
@@ -28,6 +30,8 @@ export default async function PaginatedProducts({
28
30
  collectionId?: string
29
31
  categoryId?: string
30
32
  productsIds?: string[]
33
+ /** Recherche plein texte (Medusa) */
34
+ q?: string
31
35
  /** Affiche « N produits » au-dessus de la grille */
32
36
  showCount?: boolean
33
37
  }) {
@@ -43,6 +47,10 @@ export default async function PaginatedProducts({
43
47
  queryParams["category_id"] = [categoryId]
44
48
  }
45
49
 
50
+ if (q) {
51
+ queryParams["q"] = q
52
+ }
53
+
46
54
  if (productsIds) {
47
55
  queryParams["id"] = productsIds
48
56
  }
@@ -70,9 +78,13 @@ export default async function PaginatedProducts({
70
78
  return (
71
79
  <>
72
80
  {showCount && <p className="ub-shop__count">{t("shop.count", { count: String(count) })}</p>}
73
- <div data-testid="products-list">
74
- <ProductGrid products={products} />
75
- </div>
81
+ {q && !products.length ? (
82
+ <p className="ub-shop__empty">{t("shop.noResults", { q })}</p>
83
+ ) : (
84
+ <div data-testid="products-list">
85
+ <ProductGrid products={products} />
86
+ </div>
87
+ )}
76
88
  {totalPages > 1 && (
77
89
  <Pagination
78
90
  data-testid="product-pagination"
@@ -7,6 +7,7 @@ import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import SurpriseButton from "./surprise-button"
9
9
  import FadeImg from "./fade-img"
10
+ import HeroImage from "./hero-image"
10
11
 
11
12
  /**
12
13
  * Accueil « Album » (thèmes Noël, theme.json → "home": "album") : un album de Noël fait main.
@@ -23,6 +24,7 @@ export function AlbumHero({ campaign }: { campaign: Campaign }) {
23
24
  if (image) preload(image, { as: "image", fetchPriority: "high" })
24
25
  return (
25
26
  <section className="ub-al-hero" style={image ? { ["--al-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
27
+ {image && <HeroImage src={image} position="center" />}
26
28
  <span className="ub-al-hero__curtain" aria-hidden />
27
29
  <div className="ub-wrap ub-al-hero__in">
28
30
  {hero.eyebrow && <p className="ub-al-eyebrow">{hero.eyebrow}</p>}