@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
@@ -0,0 +1,89 @@
1
+ import { MedusaError } from "@medusajs/framework/utils"
2
+ import { type Checkout, type PayStatus, RedirectPaymentProvider, sameHex, sha } from "../../lib/redirect-payment"
3
+
4
+ type Options = {
5
+ master_key: string
6
+ private_key: string
7
+ token: string
8
+ /** « test » = sandbox PayDunya, « prod » = paiements réels */
9
+ env: "test" | "prod"
10
+ /** Nom affiché sur la page de paiement */
11
+ store_name?: string
12
+ }
13
+
14
+ /**
15
+ * PayDunya (paydunya.com) : Wave, Orange Money, Free Money, carte… sur une facture PayDunya (checkout-invoice).
16
+ * Doc : https://developers.paydunya.com/doc/FR/http_json — création POST /checkout-invoice/create,
17
+ * statut GET /checkout-invoice/confirm/{token}, notification (callback_url) en formulaire, champ data[…],
18
+ * authentifiée par data[hash] = SHA-512 de la clé principale. La session Medusa voyage dans custom_data.
19
+ * Devise : FCFA (XOF). Remboursement : dans le tableau de bord PayDunya.
20
+ */
21
+ class PaydunyaProviderService extends RedirectPaymentProvider<Options> {
22
+ static identifier = "paydunya"
23
+ protected currencies = ["xof"]
24
+ protected hookId = "paydunya_paydunya"
25
+
26
+ static validateOptions(options: Record<string, unknown>) {
27
+ if (!options.master_key || !options.private_key || !options.token) {
28
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, "PayDunya : PAYDUNYA_MASTER_KEY, PAYDUNYA_PRIVATE_KEY et PAYDUNYA_TOKEN requis")
29
+ }
30
+ }
31
+
32
+ private get api() {
33
+ return this.options.env === "prod" ? "https://app.paydunya.com/api/v1" : "https://app.paydunya.com/sandbox-api/v1"
34
+ }
35
+
36
+ private get headers() {
37
+ return {
38
+ "PAYDUNYA-MASTER-KEY": this.options.master_key,
39
+ "PAYDUNYA-PRIVATE-KEY": this.options.private_key,
40
+ "PAYDUNYA-TOKEN": this.options.token,
41
+ }
42
+ }
43
+
44
+ protected async createCheckout(c: Checkout) {
45
+ const name = [c.customer.first_name, c.customer.last_name].filter(Boolean).join(" ")
46
+ const res = await this.http<{ response_code?: string; response_text?: string; token?: string; description?: string }>(
47
+ `${this.api}/checkout-invoice/create`,
48
+ {
49
+ method: "POST",
50
+ headers: this.headers,
51
+ json: {
52
+ invoice: {
53
+ total_amount: c.amount,
54
+ description: c.label,
55
+ ...(name || c.customer.email || c.customer.phone ? { customer: { name, email: c.customer.email, phone: c.customer.phone } } : {}),
56
+ },
57
+ store: { name: this.options.store_name || c.label },
58
+ custom_data: { session_id: c.sessionId },
59
+ actions: { return_url: c.returnUrl, cancel_url: c.cancelUrl, ...(c.notifyUrl ? { callback_url: c.notifyUrl } : {}) },
60
+ },
61
+ }
62
+ )
63
+ if (res.response_code !== "00" || !res.token || !res.response_text) {
64
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayDunya : facture non créée (${res.description ?? res.response_text ?? "réponse inattendue"})`)
65
+ }
66
+ return { ref: res.token, redirect_url: res.response_text }
67
+ }
68
+
69
+ protected async fetchStatus(token: string): Promise<PayStatus> {
70
+ const res = await this.http<{ status?: string }>(`${this.api}/checkout-invoice/confirm/${encodeURIComponent(token)}`, { headers: this.headers })
71
+ const s = String(res.status ?? "").toLowerCase()
72
+ if (s === "completed") return "paid"
73
+ if (s === "cancelled" || s === "failed") return "failed"
74
+ return "pending"
75
+ }
76
+
77
+ protected parseWebhook(body: Record<string, unknown>) {
78
+ const data = (body.data ?? body) as Record<string, any>
79
+ if (!data?.hash || !sameHex(sha("sha512", this.options.master_key), String(data.hash))) return null
80
+ const custom = (data.custom_data ?? {}) as Record<string, unknown>
81
+ return {
82
+ ref: String(data.invoice?.token ?? ""),
83
+ sessionId: String(custom.session_id ?? ""),
84
+ amount: Number(data.invoice?.total_amount) || 0,
85
+ }
86
+ }
87
+ }
88
+
89
+ export default PaydunyaProviderService
@@ -0,0 +1,6 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+ import PaytechProviderService from "./service"
3
+
4
+ export default ModuleProvider(Modules.PAYMENT, {
5
+ services: [PaytechProviderService],
6
+ })
@@ -0,0 +1,114 @@
1
+ import { MedusaError } from "@medusajs/framework/utils"
2
+ import { type Checkout, type PayStatus, RedirectPaymentProvider, hmac256, sameHex, sha } from "../../lib/redirect-payment"
3
+
4
+ type Options = {
5
+ api_key: string
6
+ api_secret: string
7
+ /** « test » (débite 100 à 150 FCFA quel que soit le montant) ou « prod » (compte activé chez PayTech) */
8
+ env: "test" | "prod"
9
+ }
10
+
11
+ const API = "https://paytech.sn/api"
12
+
13
+ /**
14
+ * PayTech (paytech.sn) : Wave, Orange Money, Free Money, carte bancaire… sur une page de paiement PayTech.
15
+ * Doc : https://docs.intech.sn/doc_paytech.php — création POST /payment/request-payment, statut GET /payment/get-status,
16
+ * IPN signée (HMAC-SHA256 « montant|ref_command|api_key » avec api_secret, ou SHA-256 des clés), remboursement.
17
+ * La référence du paiement Medusa (session) voyage dans custom_field.
18
+ */
19
+ class PaytechProviderService extends RedirectPaymentProvider<Options> {
20
+ static identifier = "paytech"
21
+ protected currencies = ["xof", "eur", "usd", "cad", "gbp", "mad"]
22
+ protected hookId = "paytech_paytech"
23
+
24
+ static validateOptions(options: Record<string, unknown>) {
25
+ if (!options.api_key || !options.api_secret) {
26
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, "PayTech : PAYTECH_API_KEY et PAYTECH_API_SECRET requis")
27
+ }
28
+ }
29
+
30
+ private get headers() {
31
+ return { API_KEY: this.options.api_key, API_SECRET: this.options.api_secret }
32
+ }
33
+
34
+ protected async createCheckout(c: Checkout) {
35
+ // Référence unique par tentative (la session peut créer plusieurs paiements si le panier change)
36
+ const ref = `${c.sessionId}_${Date.now().toString(36)}`
37
+ const res = await this.http<{ success?: number; token?: string; redirect_url?: string; redirectUrl?: string; message?: string }>(
38
+ `${API}/payment/request-payment`,
39
+ {
40
+ method: "POST",
41
+ headers: this.headers,
42
+ json: {
43
+ item_name: c.label,
44
+ item_price: c.amount,
45
+ currency: c.currency.toUpperCase(),
46
+ ref_command: ref,
47
+ command_name: c.label,
48
+ env: this.options.env === "prod" ? "prod" : "test",
49
+ ...(c.notifyUrl ? { ipn_url: c.notifyUrl } : {}),
50
+ success_url: c.returnUrl,
51
+ cancel_url: c.cancelUrl,
52
+ custom_field: JSON.stringify({ session_id: c.sessionId }),
53
+ },
54
+ }
55
+ )
56
+ const url = res.redirect_url ?? res.redirectUrl
57
+ if (res.success !== 1 || !res.token || !url) {
58
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayTech : paiement non créé (${res.message ?? "réponse inattendue"})`)
59
+ }
60
+ // ref = jeton PayTech (sert au statut) ; ref_command gardé pour le remboursement
61
+ return { ref: res.token, redirect_url: url, extra: { ref_command: ref } }
62
+ }
63
+
64
+ /**
65
+ * Le format exact de la réponse de get-status n'est pas documenté : lecture tolérante des champs usuels.
66
+ * L'IPN signée reste la voie sûre de confirmation (voir getWebhookActionAndData).
67
+ */
68
+ protected async fetchStatus(token: string): Promise<PayStatus> {
69
+ const res = await this.http<Record<string, any>>(`${API}/payment/get-status?token_payment=${encodeURIComponent(token)}`, { headers: this.headers })
70
+ const s = String(res.payment?.state ?? res.payment?.status ?? res.state ?? res.status ?? res.type_event ?? res.transaction?.status ?? "").toLowerCase()
71
+ if (/complet|success|paid|sale_complete|accepted/.test(s)) return "paid"
72
+ if (/cancel|fail|refus|expired|sale_canceled/.test(s)) return "failed"
73
+ return "pending"
74
+ }
75
+
76
+ protected parseWebhook(body: Record<string, unknown>) {
77
+ const f = (k: string) => (body[k] == null ? "" : String(body[k]))
78
+ const ref = f("token")
79
+ const refCommand = f("ref_command")
80
+ // Authenticité : HMAC (recommandé) sur le montant final ou initial, sinon empreintes SHA-256 des clés
81
+ const received = f("hmac_compute")
82
+ const okHmac =
83
+ !!received &&
84
+ [f("final_item_price"), f("item_price")].filter(Boolean).some((amount) => sameHex(hmac256(this.options.api_secret, `${amount}|${refCommand}|${this.options.api_key}`), received))
85
+ const okSha =
86
+ !!f("api_key_sha256") &&
87
+ sameHex(sha("sha256", this.options.api_key), f("api_key_sha256")) &&
88
+ sameHex(sha("sha256", this.options.api_secret), f("api_secret_sha256"))
89
+ if (!okHmac && !okSha) return null
90
+
91
+ let sessionId = ""
92
+ try {
93
+ const custom = f("custom_field")
94
+ const json = custom.trim().startsWith("{") ? custom : Buffer.from(custom, "base64").toString("utf8")
95
+ sessionId = String(JSON.parse(json).session_id ?? "")
96
+ } catch {
97
+ // repli : la référence commence par le numéro de session
98
+ sessionId = refCommand.slice(0, refCommand.lastIndexOf("_"))
99
+ }
100
+ return { ref, sessionId, amount: Number(f("final_item_price") || f("item_price")) || 0 }
101
+ }
102
+
103
+ /** Remboursement de la transaction (POST /payment/refund-payment avec ref_command) */
104
+ protected async refund(data: Record<string, unknown>) {
105
+ const res = await this.http<{ success?: number; message?: string }>(`${API}/payment/refund-payment`, {
106
+ method: "POST",
107
+ headers: this.headers,
108
+ json: { ref_command: String(data.ref_command ?? "") },
109
+ })
110
+ if (res.success !== 1) throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayTech : remboursement refusé (${res.message ?? "réponse inattendue"})`)
111
+ }
112
+ }
113
+
114
+ export default PaytechProviderService
@@ -0,0 +1,8 @@
1
+ import { Module } from "@medusajs/framework/utils"
2
+ import ProductReviewModuleService from "./service"
3
+
4
+ export const PRODUCT_REVIEW_MODULE = "productReview"
5
+
6
+ export default Module(PRODUCT_REVIEW_MODULE, {
7
+ service: ProductReviewModuleService,
8
+ })
@@ -0,0 +1,18 @@
1
+ import { Migration } from "@medusajs/framework/mikro-orm/migrations";
2
+
3
+ // Écrite à la main (même forme que celles générées par `medusa db:generate productReview`) : table des avis.
4
+ export class Migration20261001090000 extends Migration {
5
+
6
+ override async up(): Promise<void> {
7
+ this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "sales_channel_id" text not null, "order_id" text not null, "customer_email" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "comment" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
8
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id" ON "product_review" ("product_id") WHERE deleted_at IS NULL;`);
9
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_sales_channel_id" ON "product_review" ("sales_channel_id") WHERE deleted_at IS NULL;`);
10
+ this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_order_id_product_id_unique" ON "product_review" ("order_id", "product_id") WHERE deleted_at IS NULL;`);
11
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
12
+ }
13
+
14
+ override async down(): Promise<void> {
15
+ this.addSql(`drop table if exists "product_review" cascade;`);
16
+ }
17
+
18
+ }
@@ -0,0 +1,25 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ /**
4
+ * Avis client sur un produit. Seul un acheteur peut en laisser un (lien signé de l'e-mail « commande livrée »),
5
+ * une fois par produit et par commande ; il n'est affiché qu'après validation dans l'admin (page Avis).
6
+ * sales_channel_id = boutique d'où vient la commande : l'admin filtre par boutique, et le site affiche les avis
7
+ * de toutes les boutiques (réglage « shared », défaut) ou seulement les siens (« boutique », project.json → defaults.reviews).
8
+ * customer_email n'est jamais renvoyé par les routes publiques.
9
+ */
10
+ const Review = model
11
+ .define("product_review", {
12
+ id: model.id({ prefix: "prev" }).primaryKey(),
13
+ product_id: model.text().index(),
14
+ sales_channel_id: model.text().index(),
15
+ order_id: model.text(),
16
+ customer_email: model.text(),
17
+ author_name: model.text(),
18
+ rating: model.number(),
19
+ title: model.text().nullable(),
20
+ comment: model.text(),
21
+ status: model.enum(["pending", "approved", "rejected"]).default("pending"),
22
+ })
23
+ .indexes([{ on: ["order_id", "product_id"], unique: true }])
24
+
25
+ export default Review
@@ -0,0 +1,6 @@
1
+ import { MedusaService } from "@medusajs/framework/utils"
2
+ import Review from "./models/review"
3
+
4
+ class ProductReviewModuleService extends MedusaService({ Review }) {}
5
+
6
+ export default ProductReviewModuleService
@@ -0,0 +1,27 @@
1
+ import { z } from "@medusajs/framework/zod"
2
+
3
+ /** Texte simple uniquement (aucun < ou >), espaces superflus retirés, longueur bornée */
4
+ const plain = (min: number, max: number) =>
5
+ z
6
+ .string()
7
+ .trim()
8
+ .min(min)
9
+ .max(max)
10
+ .refine((v) => !/[<>]/.test(v), "Texte simple uniquement (pas de HTML)")
11
+
12
+ /** POST /store/reviews : avis d'un acheteur (jeton du lien de l'e-mail « commande livrée ») */
13
+ export const ReviewInput = z.object({
14
+ token: z.string().min(10).max(300),
15
+ product_id: z.string().min(1).max(100),
16
+ rating: z.number().int().min(1).max(5),
17
+ title: plain(0, 80).optional(),
18
+ comment: plain(10, 1000),
19
+ author_name: plain(1, 40),
20
+ })
21
+ export type ReviewInput = z.infer<typeof ReviewInput>
22
+
23
+ /** POST /admin/reviews/:id : valider, refuser, remettre en attente */
24
+ export const ReviewStatusInput = z.object({
25
+ status: z.enum(["pending", "approved", "rejected"]),
26
+ })
27
+ export type ReviewStatusInput = z.infer<typeof ReviewStatusInput>
@@ -0,0 +1,6 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+ import SmtpNotificationProviderService from "./service"
3
+
4
+ export default ModuleProvider(Modules.NOTIFICATION, {
5
+ services: [SmtpNotificationProviderService],
6
+ })
@@ -0,0 +1,73 @@
1
+ import { Logger, NotificationTypes } from "@medusajs/framework/types"
2
+ import { AbstractNotificationProviderService, MedusaError } from "@medusajs/framework/utils"
3
+ import nodemailer, { type Transporter } from "nodemailer"
4
+
5
+ type Options = {
6
+ host: string
7
+ /** 465 = SSL direct (Gmail), 587 = STARTTLS */
8
+ port: number
9
+ user: string
10
+ /** Mot de passe SMTP ; pour Gmail : mot de passe d'application (16 caractères), jamais le mot de passe du compte */
11
+ pass: string
12
+ /** Adresse « De : » ; pour Gmail, la même adresse que user (sinon Gmail la remplace ou le message est rejeté) */
13
+ from: string
14
+ }
15
+
16
+ type InjectedDependencies = { logger: Logger }
17
+
18
+ /**
19
+ * Envoi des e-mails par un serveur SMTP (Gmail avec mot de passe d'application, OVH, Brevo, Mailjet…),
20
+ * alternative à Resend. Même contrat que le provider Resend : le contenu est préparé par src/lib/emails.ts,
21
+ * data.fromName = nom de la boutique (nom affiché), data.replyTo = e-mail du marchand.
22
+ */
23
+ class SmtpNotificationProviderService extends AbstractNotificationProviderService {
24
+ static identifier = "notification-smtp"
25
+
26
+ private transporter: Transporter
27
+ private options: Options
28
+ private logger: Logger
29
+
30
+ static validateOptions(options: Record<string, unknown>) {
31
+ for (const k of ["host", "user", "pass", "from"]) {
32
+ if (!options[k]) throw new MedusaError(MedusaError.Types.INVALID_DATA, `SMTP : option ${k} manquante`)
33
+ }
34
+ }
35
+
36
+ constructor({ logger }: InjectedDependencies, options: Options) {
37
+ super()
38
+ this.options = options
39
+ this.logger = logger
40
+ this.transporter = nodemailer.createTransport({
41
+ host: options.host,
42
+ port: options.port,
43
+ secure: options.port === 465,
44
+ auth: { user: options.user, pass: options.pass },
45
+ })
46
+ }
47
+
48
+ async send(notification: NotificationTypes.ProviderSendNotificationDTO): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> {
49
+ const { content, data } = notification
50
+ if (!content?.subject || !content.html) {
51
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, `SMTP : contenu manquant pour ${notification.template}`)
52
+ }
53
+ const fromName = typeof data?.fromName === "string" ? data.fromName.replace(/[<>"]/g, "") : undefined
54
+ const replyTo = typeof data?.replyTo === "string" ? data.replyTo : undefined
55
+ try {
56
+ const info = await this.transporter.sendMail({
57
+ from: fromName ? { name: fromName, address: this.options.from } : this.options.from,
58
+ to: notification.to,
59
+ subject: content.subject,
60
+ html: content.html,
61
+ text: content.text,
62
+ ...(replyTo ? { replyTo } : {}),
63
+ })
64
+ return { id: info.messageId }
65
+ } catch (e) {
66
+ const message = (e as Error).message
67
+ this.logger.error(`[smtp] ${notification.template} → ${notification.to} : ${message}`)
68
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message)
69
+ }
70
+ }
71
+ }
72
+
73
+ export default SmtpNotificationProviderService
@@ -2,7 +2,7 @@
2
2
  * Seed idempotent, lancé à chaque démarrage du container backend (docker/entrypoint.sh).
3
3
  * Lit config/project.json et crée seulement ce qui manque :
4
4
  * - devises du store, une région par devise (un pays = une devise), régions de taxe ;
5
- * - entrepôt, zone de livraison « Monde », options Standard / Express par devise ;
5
+ * - entrepôt, zone de livraison « Monde », options Standard / Express créées au 1er démarrage seulement (ensuite : admin) ;
6
6
  * - 1 sales channel + 1 publishable key par boutique, clé écrite dans KEYS_DIR/<slug> ;
7
7
  * - produits de seed/products.csv (par handle) et images de seed/images.
8
8
  * Aucun SQL : uniquement les workflows et services Medusa.
@@ -38,6 +38,7 @@ import { CAMPAIGN_MODULE } from "../modules/campaign"
38
38
  import CampaignModuleService from "../modules/campaign/service"
39
39
  import { CampaignFile, CampaignInput } from "../modules/campaign/validators"
40
40
  import { upsertCampaignWorkflow } from "../workflows/upsert-campaign"
41
+ import { regionPaymentProviders } from "../lib/payment-config"
41
42
 
42
43
  const LOCATION_NAME = "Entrepôt principal"
43
44
  const FULFILLMENT_SET_NAME = "Livraison"
@@ -63,6 +64,10 @@ export default async function seed({ container }: ExecArgs) {
63
64
  // ---------- Store : devises ----------
64
65
  const [store] = await storeService.listStores({}, { relations: ["supported_currencies"] })
65
66
  const knownCurrencies = new Set(store.supported_currencies?.map((c) => c.currency_code))
67
+ // Nom affiché en haut de l'admin : celui du projet (pas « Medusa Store »)
68
+ if (store.name === "Medusa Store" || !store.name) {
69
+ await updateStoresWorkflow(container).run({ input: { selector: { id: store.id }, update: { name: project.boutiques[0].storeName } } })
70
+ }
66
71
  if (currencies.some((c) => !knownCurrencies.has(c)) || !store.supported_currencies?.find((c) => c.is_default && c.currency_code === project.defaultCurrency)) {
67
72
  await updateStoresWorkflow(container).run({
68
73
  input: {
@@ -78,7 +83,6 @@ export default async function seed({ container }: ExecArgs) {
78
83
  // ---------- Régions : une par devise ----------
79
84
  const allCountries = (await regionService.listCountries({}, { take: 500 })).map((c) => c.iso_2)
80
85
  const target = countriesByCurrency(project, allCountries)
81
- const paymentProviders = ["pp_system_default", ...(process.env.STRIPE_API_KEY ? ["pp_stripe_stripe"] : [])]
82
86
  const existing = await regionService.listRegions({}, { relations: ["countries"] })
83
87
  const managed = new Map(existing.filter((r) => currencies.includes(r.currency_code) && r.name === r.currency_code.toUpperCase()).map((r) => [r.currency_code, r]))
84
88
 
@@ -102,11 +106,11 @@ export default async function seed({ container }: ExecArgs) {
102
106
  const region = managed.get(currency)
103
107
  if (region) {
104
108
  await updateRegionsWorkflow(container).run({
105
- input: { selector: { id: region.id }, update: { countries: [...countries], payment_providers: paymentProviders } },
109
+ input: { selector: { id: region.id }, update: { countries: [...countries], payment_providers: regionPaymentProviders(currency) } },
106
110
  })
107
111
  } else {
108
112
  await createRegionsWorkflow(container).run({
109
- input: { regions: [{ name: currency.toUpperCase(), currency_code: currency, countries: [...countries], payment_providers: paymentProviders }] },
113
+ input: { regions: [{ name: currency.toUpperCase(), currency_code: currency, countries: [...countries], payment_providers: regionPaymentProviders(currency) }] },
110
114
  })
111
115
  }
112
116
  logger.info(`[seed] région ${currency.toUpperCase()} : ${countries.size} pays`)
@@ -159,45 +163,57 @@ export default async function seed({ container }: ExecArgs) {
159
163
  { attribute: "enabled_in_store", value: "true", operator: "eq" as const },
160
164
  { attribute: "is_return", value: "false", operator: "eq" as const },
161
165
  ]
162
- for (const [i, name, code, description] of [
166
+ const DEFAULT_OPTIONS = [
163
167
  [0, "Standard", "standard", "3-7 business days"],
164
168
  [1, "Express", "express", "1-3 business days"],
165
- ] as const) {
166
- // Prix liés via le module Pricing : demandés par query.graph mais absents du type généré.
167
- const option = options.find((o) => o.name === name) as
168
- | { id: string; prices?: { id: string; amount: number; currency_code: string }[] }
169
- | undefined
170
- if (!option) {
171
- await createShippingOptionsWorkflow(container).run({
169
+ ] as const
170
+ // Prix liés via le module Pricing : demandés par query.graph mais absents du type généré.
171
+ type PricedOption = { id: string; name: string; prices?: { id: string; amount: number; currency_code: string }[] }
172
+
173
+ // Standard et Express ne sont créées qu'une fois (1er démarrage). Ensuite, c'est l'admin qui décide : le marchand peut
174
+ // les renommer, changer les prix, en supprimer une, en ajouter d'autres ; le seed ne les recrée jamais.
175
+ // (Projet existant : des options déjà présentes valent « déjà fait ».)
176
+ const shippingSeeded = store.metadata?.ub_shipping_seeded === true || options.length > 0
177
+ if (!shippingSeeded) {
178
+ await createShippingOptionsWorkflow(container).run({
179
+ input: DEFAULT_OPTIONS.map(([i, name, code, description]) => ({
180
+ name,
181
+ price_type: "flat" as const,
182
+ provider_id: "manual_manual",
183
+ service_zone_id: zone.id,
184
+ shipping_profile_id: profile.id,
185
+ type: { label: name, description, code },
186
+ prices: currencies.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
187
+ rules,
188
+ })),
189
+ })
190
+ await updateStoresWorkflow(container).run({
191
+ input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), ub_shipping_seeded: true } } },
192
+ })
193
+ } else {
194
+ if (store.metadata?.ub_shipping_seeded !== true) {
195
+ await updateStoresWorkflow(container).run({
196
+ input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), ub_shipping_seeded: true } } },
197
+ })
198
+ }
199
+ // Nouvelle devise (boutique ajoutée) : prix ajouté aux options existantes, quel que soit leur nom (tarif Express
200
+ // pour une option nommée « Express », tarif Standard sinon). Les prix déjà fixés dans l'admin ne sont pas touchés.
201
+ for (const option of options as PricedOption[]) {
202
+ const tier = /express/i.test(option.name) ? 1 : 0
203
+ const priced = new Set((option.prices ?? []).map((p) => p.currency_code))
204
+ const add = currencies.filter((c) => !priced.has(c))
205
+ if (!add.length) continue
206
+ await updateShippingOptionsWorkflow(container).run({
172
207
  input: [
173
208
  {
174
- name,
175
- price_type: "flat",
176
- provider_id: "manual_manual",
177
- service_zone_id: zone.id,
178
- shipping_profile_id: profile.id,
179
- type: { label: name, description, code },
180
- prices: currencies.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
181
- rules,
209
+ id: option.id,
210
+ prices: [
211
+ ...(option.prices ?? []).map((p) => ({ id: p.id, amount: p.amount })),
212
+ ...add.map((c) => ({ currency_code: c, amount: shippingRates(c)[tier] })),
213
+ ],
182
214
  },
183
215
  ],
184
216
  })
185
- } else {
186
- const priced = new Set((option.prices ?? []).map((p) => p.currency_code))
187
- const add = currencies.filter((c) => !priced.has(c))
188
- if (add.length) {
189
- await updateShippingOptionsWorkflow(container).run({
190
- input: [
191
- {
192
- id: option.id,
193
- prices: [
194
- ...(option.prices ?? []).map((p) => ({ id: p.id, amount: p.amount })),
195
- ...add.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
196
- ],
197
- },
198
- ],
199
- })
200
- }
201
217
  }
202
218
  }
203
219
 
@@ -24,7 +24,7 @@ export default async function fulfillmentEmails({
24
24
  if (!orderId) return
25
25
 
26
26
  if (name === "delivery.created") {
27
- await sendOrderEmail(container, orderId, "order_delivered")
27
+ await sendOrderEmail(container, orderId, "order_delivered", { reviewLink: true })
28
28
  return
29
29
  }
30
30
 
@@ -0,0 +1,21 @@
1
+ import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
2
+ import { type CreateReviewData, createReviewStep } from "./steps/create-review"
3
+ import { deleteReviewStep, updateReviewStatusStep } from "./steps/update-review"
4
+
5
+ /** Avis d'un acheteur (lien signé de l'e-mail « commande livrée ») : en attente de validation. */
6
+ export const createReviewWorkflow = createWorkflow("create-review", function (input: CreateReviewData) {
7
+ return new WorkflowResponse(createReviewStep(input))
8
+ })
9
+
10
+ /** Admin → Avis : valider, refuser, remettre en attente. */
11
+ export const updateReviewStatusWorkflow = createWorkflow(
12
+ "update-review-status",
13
+ function (input: { id: string; status: "pending" | "approved" | "rejected" }) {
14
+ return new WorkflowResponse(updateReviewStatusStep(input))
15
+ }
16
+ )
17
+
18
+ /** Admin → Avis : supprimer. */
19
+ export const deleteReviewWorkflow = createWorkflow("delete-review", function (input: { id: string }) {
20
+ return new WorkflowResponse(deleteReviewStep(input.id))
21
+ })
@@ -0,0 +1,52 @@
1
+ import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
2
+ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
3
+ import { PRODUCT_REVIEW_MODULE } from "../../modules/product-review"
4
+ import ProductReviewModuleService from "../../modules/product-review/service"
5
+
6
+ export type CreateReviewData = {
7
+ order_id: string
8
+ product_id: string
9
+ rating: number
10
+ title?: string
11
+ comment: string
12
+ author_name: string
13
+ }
14
+
15
+ /**
16
+ * Crée un avis « en attente » après vérifications : la commande existe, le produit en fait partie,
17
+ * pas déjà d'avis pour ce produit dans cette commande. Boutique et e-mail pris dans la commande (jamais du client).
18
+ * Rollback : supprime l'avis créé.
19
+ */
20
+ export const createReviewStep = createStep(
21
+ "create-review",
22
+ async (input: CreateReviewData, { container }) => {
23
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
24
+ const { data } = await query.graph({
25
+ entity: "order",
26
+ fields: ["id", "email", "sales_channel_id", "status", "items.product_id"],
27
+ filters: { id: input.order_id },
28
+ })
29
+ const order = data[0]
30
+ if (!order || order.status === "canceled") throw new MedusaError(MedusaError.Types.NOT_FOUND, "Commande introuvable")
31
+ if (!(order.items ?? []).some((i) => i?.product_id === input.product_id)) {
32
+ throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Ce produit ne fait pas partie de la commande")
33
+ }
34
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
35
+ const [already] = await service.listReviews({ order_id: order.id, product_id: input.product_id })
36
+ if (already) throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Un avis existe déjà pour ce produit")
37
+
38
+ const review = await service.createReviews({
39
+ ...input,
40
+ title: input.title || null,
41
+ sales_channel_id: order.sales_channel_id ?? "",
42
+ customer_email: order.email ?? "",
43
+ status: "pending",
44
+ })
45
+ return new StepResponse(review, review.id)
46
+ },
47
+ async (id, { container }) => {
48
+ if (!id) return
49
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
50
+ await service.deleteReviews(id)
51
+ }
52
+ )
@@ -0,0 +1,36 @@
1
+ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
2
+ import { PRODUCT_REVIEW_MODULE } from "../../modules/product-review"
3
+ import ProductReviewModuleService from "../../modules/product-review/service"
4
+
5
+ type Status = "pending" | "approved" | "rejected"
6
+
7
+ /** Change le statut d'un avis (valider, refuser) ; rollback : statut précédent. */
8
+ export const updateReviewStatusStep = createStep(
9
+ "update-review-status",
10
+ async (input: { id: string; status: Status }, { container }) => {
11
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
12
+ const previous = await service.retrieveReview(input.id)
13
+ const updated = await service.updateReviews({ id: input.id, status: input.status })
14
+ return new StepResponse(updated, { id: input.id, status: previous.status as Status })
15
+ },
16
+ async (prev, { container }) => {
17
+ if (!prev) return
18
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
19
+ await service.updateReviews(prev)
20
+ }
21
+ )
22
+
23
+ /** Supprime un avis (suppression douce) ; rollback : restauration. */
24
+ export const deleteReviewStep = createStep(
25
+ "delete-review",
26
+ async (id: string, { container }) => {
27
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
28
+ await service.softDeleteReviews(id)
29
+ return new StepResponse(id, id)
30
+ },
31
+ async (id, { container }) => {
32
+ if (!id) return
33
+ const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
34
+ await service.restoreReviews(id)
35
+ }
36
+ )