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