@salifbiaye/create-boutique 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/dist/code.js +110 -0
- package/cli/dist/commands.js +241 -17
- package/cli/dist/create.js +108 -15
- package/cli/dist/generate.js +108 -39
- package/cli/dist/guide.js +12 -4
- package/cli/dist/index.js +38 -7
- package/cli/dist/payments.js +150 -0
- package/cli/dist/project.js +42 -3
- package/docs/fiche-client.en.html +2 -2
- package/docs/fiche-client.html +2 -2
- package/docs/guide-complet.fr.html +681 -256
- package/docs/guide-images/categories.jpg +0 -0
- package/docs/guide-images/clients.jpg +0 -0
- package/docs/guide-images/collections.jpg +0 -0
- package/docs/guide-images/commande-detail.jpg +0 -0
- package/docs/guide-images/commande-expedier.jpg +0 -0
- package/docs/guide-images/commande-menu.jpg +0 -0
- package/docs/guide-images/commande-paiement.jpg +0 -0
- package/docs/guide-images/commandes-liste.jpg +0 -0
- package/docs/guide-images/connexion.jpg +0 -0
- package/docs/guide-images/groupes-clients.jpg +0 -0
- package/docs/guide-images/inventaire.jpg +0 -0
- package/docs/guide-images/listes-prix.jpg +0 -0
- package/docs/guide-images/menu.jpg +0 -0
- package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
- package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
- package/docs/guide-images/mes-boutiques.jpg +0 -0
- package/docs/guide-images/parametres-boutique.jpg +0 -0
- package/docs/guide-images/parametres-canaux.jpg +0 -0
- package/docs/guide-images/parametres-emplacements.jpg +0 -0
- package/docs/guide-images/parametres-profil.jpg +0 -0
- package/docs/guide-images/parametres-regions.jpg +0 -0
- package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
- package/docs/guide-images/produit-creer-details.jpg +0 -0
- package/docs/guide-images/produit-detail.jpg +0 -0
- package/docs/guide-images/produit-variantes-menu.jpg +0 -0
- package/docs/guide-images/produit-variantes.jpg +0 -0
- package/docs/guide-images/produits-liste.jpg +0 -0
- package/docs/guide-images/promotion-details.jpg +0 -0
- package/docs/guide-images/promotion-type.jpg +0 -0
- package/docs/guide-images/promotions-liste.jpg +0 -0
- package/docs/guide-images/site-boutique.jpg +0 -0
- package/docs/guide-images/site-fermee.png +0 -0
- package/docs/guide-images/vue-ensemble.jpg +0 -0
- package/package.json +3 -2
- package/templates/backend/medusa-config.ts +31 -25
- package/templates/backend/package-lock.json +21 -0
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/admin/i18n/json/en.json +63 -5
- package/templates/backend/src/admin/i18n/json/fr.json +63 -5
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
- package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
- package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
- package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
- package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
- package/templates/backend/src/api/admin/reviews/route.ts +47 -0
- package/templates/backend/src/api/middlewares.ts +9 -0
- package/templates/backend/src/api/store/campaign/route.ts +5 -1
- package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
- package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
- package/templates/backend/src/api/store/reviews/route.ts +43 -0
- package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
- package/templates/backend/src/emails/en.json +3 -1
- package/templates/backend/src/emails/fr.json +3 -1
- package/templates/backend/src/lib/active.ts +41 -0
- package/templates/backend/src/lib/email-config.ts +14 -0
- package/templates/backend/src/lib/emails.ts +9 -0
- package/templates/backend/src/lib/notify.ts +14 -8
- package/templates/backend/src/lib/payment-config.ts +66 -0
- package/templates/backend/src/lib/project.ts +38 -1
- package/templates/backend/src/lib/redirect-payment.ts +264 -0
- package/templates/backend/src/lib/review-token.ts +40 -0
- package/templates/backend/src/lib/reviews.ts +25 -0
- package/templates/backend/src/modules/naboopay/index.ts +6 -0
- package/templates/backend/src/modules/naboopay/service.ts +92 -0
- package/templates/backend/src/modules/paydunya/index.ts +6 -0
- package/templates/backend/src/modules/paydunya/service.ts +89 -0
- package/templates/backend/src/modules/paytech/index.ts +6 -0
- package/templates/backend/src/modules/paytech/service.ts +114 -0
- package/templates/backend/src/modules/product-review/index.ts +8 -0
- package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
- package/templates/backend/src/modules/product-review/models/review.ts +25 -0
- package/templates/backend/src/modules/product-review/service.ts +6 -0
- package/templates/backend/src/modules/product-review/validators.ts +27 -0
- package/templates/backend/src/modules/smtp/index.ts +6 -0
- package/templates/backend/src/modules/smtp/service.ts +73 -0
- package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
- package/templates/backend/src/scripts/seed.ts +60 -36
- package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
- package/templates/backend/src/workflows/reviews.ts +21 -0
- package/templates/backend/src/workflows/steps/create-review.ts +52 -0
- package/templates/backend/src/workflows/steps/update-review.ts +36 -0
- package/templates/storefront/PERSONNALISER.md +134 -0
- package/templates/storefront/locales/en.json +198 -11
- package/templates/storefront/locales/fr.json +198 -11
- package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
- package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
- package/templates/storefront/src/app/(main)/layout.tsx +8 -1
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
- package/templates/storefront/src/app/(main)/page.tsx +149 -4
- package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
- package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/(main)/template.tsx +17 -2
- package/templates/storefront/src/app/layout.tsx +13 -1
- package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
- package/templates/storefront/src/lib/boutique/config.ts +71 -23
- package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
- package/templates/storefront/src/lib/boutique/schema.ts +7 -2
- package/templates/storefront/src/lib/config.ts +37 -8
- package/templates/storefront/src/lib/constants.tsx +17 -0
- package/templates/storefront/src/lib/data/cart.ts +4 -1
- package/templates/storefront/src/lib/data/cookies.ts +14 -2
- package/templates/storefront/src/lib/data/review-actions.ts +28 -0
- package/templates/storefront/src/lib/data/reviews.ts +64 -0
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
- package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
- package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
- package/templates/storefront/src/modules/ub/album.tsx +2 -0
- package/templates/storefront/src/modules/ub/arena.tsx +182 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
- package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
- package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
- package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
- package/templates/storefront/src/modules/ub/gala.tsx +2 -0
- package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
- package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
- package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
- package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
- package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
- package/templates/storefront/src/modules/ub/locker.tsx +180 -0
- package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
- package/templates/storefront/src/modules/ub/nav.tsx +8 -5
- package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
- package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
- package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
- package/templates/storefront/src/modules/ub/souk.tsx +214 -0
- package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
- package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
- package/templates/storefront/src/modules/ub/tech.tsx +2 -2
- package/templates/storefront/src/modules/ub/training.tsx +196 -0
- package/templates/storefront/src/proxy.ts +4 -1
- package/templates/storefront/src/styles/custom.css +26 -0
- package/templates/storefront/src/styles/ub/album.css +1 -1
- package/templates/storefront/src/styles/ub/arena.css +120 -0
- package/templates/storefront/src/styles/ub/cinema.css +1 -1
- package/templates/storefront/src/styles/ub/gala.css +1 -1
- package/templates/storefront/src/styles/ub/grimoire.css +23 -7
- package/templates/storefront/src/styles/ub/harvest.css +7 -6
- package/templates/storefront/src/styles/ub/lantern.css +157 -0
- package/templates/storefront/src/styles/ub/locker.css +115 -0
- package/templates/storefront/src/styles/ub/media.css +10 -0
- package/templates/storefront/src/styles/ub/mosaic.css +118 -0
- package/templates/storefront/src/styles/ub/motion.css +0 -2
- package/templates/storefront/src/styles/ub/overrides.css +10 -0
- package/templates/storefront/src/styles/ub/pages.css +176 -0
- package/templates/storefront/src/styles/ub/reviews.css +85 -0
- package/templates/storefront/src/styles/ub/riviera.css +103 -0
- package/templates/storefront/src/styles/ub/souk.css +144 -0
- package/templates/storefront/src/styles/ub/sunset.css +123 -0
- package/templates/storefront/src/styles/ub/tech.css +5 -8
- package/templates/storefront/src/styles/ub/training.css +120 -0
- package/templates/storefront/themes/jeux/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux/theme.json +11 -0
- package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux-2/theme.json +11 -0
- package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak/theme.json +11 -0
- package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak-2/theme.json +11 -0
- package/templates/storefront/themes/sport/campaign.en.json +31 -0
- package/templates/storefront/themes/sport/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport/theme.json +11 -0
- package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
- package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport-2/theme.json +11 -0
- package/templates/storefront/themes/summer/campaign.en.json +31 -0
- package/templates/storefront/themes/summer/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer/theme.json +11 -0
- package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
- package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer-2/theme.json +11 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MedusaStoreRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
|
|
3
|
+
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review"
|
|
4
|
+
import ProductReviewModuleService from "../../../../modules/product-review/service"
|
|
5
|
+
import { readReviewToken } from "../../../../lib/review-token"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /store/reviews/order?token=… : page « Laisser un avis » (lien de l'e-mail « commande livrée »).
|
|
9
|
+
* Renvoie les produits de la commande à noter (titre, image) et ceux déjà notés ; rien d'autre de la commande.
|
|
10
|
+
*/
|
|
11
|
+
export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
12
|
+
const orderId = readReviewToken(String(req.query.token ?? ""))
|
|
13
|
+
if (!orderId) throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Lien d'avis invalide ou expiré")
|
|
14
|
+
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
15
|
+
const { data } = await query.graph({
|
|
16
|
+
entity: "order",
|
|
17
|
+
fields: ["id", "display_id", "status", "shipping_address.first_name", "items.product_id", "items.product_title", "items.product_handle", "items.thumbnail"],
|
|
18
|
+
filters: { id: orderId },
|
|
19
|
+
})
|
|
20
|
+
const order = data[0]
|
|
21
|
+
if (!order || order.status === "canceled") throw new MedusaError(MedusaError.Types.NOT_FOUND, "Commande introuvable")
|
|
22
|
+
const service: ProductReviewModuleService = req.scope.resolve(PRODUCT_REVIEW_MODULE)
|
|
23
|
+
const done = new Set((await service.listReviews({ order_id: order.id }, { select: ["product_id"] })).map((r) => r.product_id))
|
|
24
|
+
const products = new Map<string, { product_id: string; title: string; handle?: string; thumbnail?: string; reviewed: boolean }>()
|
|
25
|
+
for (const i of order.items ?? []) {
|
|
26
|
+
if (!i?.product_id || products.has(i.product_id)) continue
|
|
27
|
+
products.set(i.product_id, {
|
|
28
|
+
product_id: i.product_id,
|
|
29
|
+
title: i.product_title ?? "",
|
|
30
|
+
handle: i.product_handle ?? undefined,
|
|
31
|
+
thumbnail: i.thumbnail ?? undefined,
|
|
32
|
+
reviewed: done.has(i.product_id),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
res.json({ number: order.display_id, first_name: order.shipping_address?.first_name ?? "", products: [...products.values()] })
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MedusaStoreRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { MedusaError } from "@medusajs/framework/utils"
|
|
3
|
+
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review"
|
|
4
|
+
import ProductReviewModuleService from "../../../modules/product-review/service"
|
|
5
|
+
import { ReviewInput } from "../../../modules/product-review/validators"
|
|
6
|
+
import { readReviewToken } from "../../../lib/review-token"
|
|
7
|
+
import { PUBLIC_FIELDS, reviewChannels, summarize } from "../../../lib/reviews"
|
|
8
|
+
import { createReviewWorkflow } from "../../../workflows/reviews"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /store/reviews?product_id=…&limit=10&offset=0 : avis validés d'un produit + résumé (moyenne, nombre, répartition).
|
|
12
|
+
* Portée (toutes les boutiques ou celle-ci) : project.json → defaults.reviews. Jamais l'e-mail du client.
|
|
13
|
+
*/
|
|
14
|
+
export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
15
|
+
const productId = String(req.query.product_id ?? "")
|
|
16
|
+
if (!productId) throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id requis")
|
|
17
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 50)
|
|
18
|
+
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
|
19
|
+
const channels = reviewChannels(req)
|
|
20
|
+
const filters = { product_id: productId, status: "approved" as const, ...(channels ? { sales_channel_id: channels } : {}) }
|
|
21
|
+
|
|
22
|
+
const service: ProductReviewModuleService = req.scope.resolve(PRODUCT_REVIEW_MODULE)
|
|
23
|
+
const [reviews, count] = await service.listAndCountReviews(filters, {
|
|
24
|
+
select: [...PUBLIC_FIELDS],
|
|
25
|
+
take: limit,
|
|
26
|
+
skip: offset,
|
|
27
|
+
order: { created_at: "DESC" },
|
|
28
|
+
})
|
|
29
|
+
const all = await service.listReviews(filters, { select: ["rating"], take: 5000 })
|
|
30
|
+
res.json({ reviews, count, limit, offset, summary: summarize(all.map((r) => r.rating)) })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* POST /store/reviews : avis d'un acheteur. Le jeton (lien de l'e-mail « commande livrée ») prouve la commande ;
|
|
35
|
+
* l'avis part « en attente » et n'apparaît qu'après validation dans l'admin. Limité en tentatives (middlewares.ts).
|
|
36
|
+
*/
|
|
37
|
+
export async function POST(req: MedusaStoreRequest<ReviewInput>, res: MedusaResponse) {
|
|
38
|
+
const { token, ...review } = req.validatedBody
|
|
39
|
+
const orderId = readReviewToken(token)
|
|
40
|
+
if (!orderId) throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Lien d'avis invalide ou expiré")
|
|
41
|
+
const { result } = await createReviewWorkflow(req.scope).run({ input: { ...review, order_id: orderId } })
|
|
42
|
+
res.status(201).json({ review: { id: result.id, status: result.status } })
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { MedusaStoreRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review"
|
|
3
|
+
import ProductReviewModuleService from "../../../../modules/product-review/service"
|
|
4
|
+
import { reviewChannels, summarize, type Summary } from "../../../../lib/reviews"
|
|
5
|
+
|
|
6
|
+
/** GET /store/reviews/summary?product_ids=a,b,c : note moyenne et nombre d'avis validés par produit (cartes produits). */
|
|
7
|
+
export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
8
|
+
const ids = String(req.query.product_ids ?? "")
|
|
9
|
+
.split(",")
|
|
10
|
+
.map((s) => s.trim())
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.slice(0, 100)
|
|
13
|
+
if (!ids.length) return res.json({ summaries: {} })
|
|
14
|
+
const channels = reviewChannels(req)
|
|
15
|
+
const service: ProductReviewModuleService = req.scope.resolve(PRODUCT_REVIEW_MODULE)
|
|
16
|
+
const reviews = await service.listReviews(
|
|
17
|
+
{ product_id: ids, status: "approved", ...(channels ? { sales_channel_id: channels } : {}) },
|
|
18
|
+
{ select: ["product_id", "rating"], take: 10000 }
|
|
19
|
+
)
|
|
20
|
+
const byProduct = new Map<string, number[]>()
|
|
21
|
+
for (const r of reviews) byProduct.set(r.product_id, [...(byProduct.get(r.product_id) ?? []), r.rating])
|
|
22
|
+
const summaries: Record<string, Pick<Summary, "average" | "count">> = {}
|
|
23
|
+
for (const [id, ratings] of byProduct) {
|
|
24
|
+
const { average, count } = summarize(ratings)
|
|
25
|
+
summaries[id] = { average, count }
|
|
26
|
+
}
|
|
27
|
+
res.json({ summaries })
|
|
28
|
+
}
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"order_delivered": {
|
|
15
15
|
"subject": "Your order #{number} has been delivered",
|
|
16
16
|
"title": "Your order has been delivered",
|
|
17
|
-
"intro": "Hi {name}, your order #{number} has been delivered. We hope you love it!"
|
|
17
|
+
"intro": "Hi {name}, your order #{number} has been delivered. We hope you love it!",
|
|
18
|
+
"review": "Leave a review",
|
|
19
|
+
"reviewText": "Your review helps other customers: rate your items in a minute."
|
|
18
20
|
},
|
|
19
21
|
"order_canceled": {
|
|
20
22
|
"subject": "Your order #{number} has been canceled",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"order_delivered": {
|
|
15
15
|
"subject": "Votre commande n° {number} est livrée",
|
|
16
16
|
"title": "Votre commande est livrée",
|
|
17
|
-
"intro": "Bonjour {name}, votre commande n° {number} a été livrée. Nous espérons qu'elle vous plaira !"
|
|
17
|
+
"intro": "Bonjour {name}, votre commande n° {number} a été livrée. Nous espérons qu'elle vous plaira !",
|
|
18
|
+
"review": "Laisser un avis",
|
|
19
|
+
"reviewText": "Votre avis aide les autres clients : notez vos articles en une minute."
|
|
18
20
|
},
|
|
19
21
|
"order_canceled": {
|
|
20
22
|
"subject": "Votre commande n° {number} est annulée",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { MedusaContainer } from "@medusajs/framework/types"
|
|
4
|
+
import { Modules } from "@medusajs/framework/utils"
|
|
5
|
+
import { updateStoresWorkflow } from "@medusajs/medusa/core-flows"
|
|
6
|
+
import { KEYS_DIR, readProject } from "./project"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mode single : une seule boutique en ligne à la fois.
|
|
10
|
+
* - Source de vérité : metadata.active_boutique du store Medusa (base de données).
|
|
11
|
+
* - Copie dans KEYS_DIR/_active : le site la lit à chaque requête (volume partagé, pas d'appel réseau).
|
|
12
|
+
* Il y a toujours exactement une boutique active : activer une boutique remplace la précédente,
|
|
13
|
+
* et une valeur absente ou inconnue retombe sur la première boutique du projet.
|
|
14
|
+
*/
|
|
15
|
+
const ACTIVE_FILE = path.join(KEYS_DIR, "_active")
|
|
16
|
+
|
|
17
|
+
export async function getActive(container: MedusaContainer): Promise<string> {
|
|
18
|
+
const project = readProject()
|
|
19
|
+
const slugs = project.boutiques.map((b) => b.slug)
|
|
20
|
+
const [store] = await container.resolve(Modules.STORE).listStores({}, { select: ["id", "metadata"] })
|
|
21
|
+
const saved = store?.metadata?.active_boutique
|
|
22
|
+
return typeof saved === "string" && slugs.includes(saved) ? saved : slugs[0]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setActive(container: MedusaContainer, slug: string): Promise<string> {
|
|
26
|
+
const project = readProject()
|
|
27
|
+
if (!project.boutiques.some((b) => b.slug === slug)) throw new Error(`boutique inconnue : ${slug}`)
|
|
28
|
+
const [store] = await container.resolve(Modules.STORE).listStores({}, { select: ["id", "metadata"] })
|
|
29
|
+
await updateStoresWorkflow(container).run({
|
|
30
|
+
input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), active_boutique: slug } } },
|
|
31
|
+
})
|
|
32
|
+
writeActiveFile(slug)
|
|
33
|
+
return slug
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeActiveFile(slug: string) {
|
|
37
|
+
fs.mkdirSync(KEYS_DIR, { recursive: true })
|
|
38
|
+
// Écriture atomique : le site ne lit jamais un fichier à moitié écrit
|
|
39
|
+
fs.writeFileSync(`${ACTIVE_FILE}.tmp`, slug)
|
|
40
|
+
fs.renameSync(`${ACTIVE_FILE}.tmp`, ACTIVE_FILE)
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choix de l'envoi des e-mails, d'après l'environnement (.env) :
|
|
3
|
+
* - Resend si RESEND_API_KEY est renseignée (mode plateforme, prioritaire) ;
|
|
4
|
+
* - sinon SMTP si SMTP_HOST, SMTP_USER et SMTP_PASS le sont (Gmail avec mot de passe d'application, OVH, Brevo…) ;
|
|
5
|
+
* - sinon aucun e-mail (la boutique fonctionne quand même).
|
|
6
|
+
* Utilisé par medusa-config.ts (quel provider charger) et src/lib/notify.ts (envoyer ou non).
|
|
7
|
+
*/
|
|
8
|
+
export type EmailMode = "resend" | "smtp" | null
|
|
9
|
+
|
|
10
|
+
export function emailMode(env: NodeJS.ProcessEnv = process.env): EmailMode {
|
|
11
|
+
if (env.RESEND_API_KEY) return "resend"
|
|
12
|
+
if (env.SMTP_HOST && env.SMTP_USER && env.SMTP_PASS) return "smtp"
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
@@ -35,6 +35,15 @@ const THEMES: Record<string, { primary: string; onPrimary: string }> = {
|
|
|
35
35
|
"cyber-monday-2": { primary: "#22d3ee", onPrimary: "#04111a" },
|
|
36
36
|
"noel-2": { primary: "#d4af37", onPrimary: "#0f1d17" },
|
|
37
37
|
"nouvel-an-2": { primary: "#f5b5c8", onPrimary: "#0c0f24" },
|
|
38
|
+
// Été et fêtes (thèmes ajoutés ensuite)
|
|
39
|
+
summer: { primary: "#1f3a30", onPrimary: "#f4f1ea" },
|
|
40
|
+
"summer-2": { primary: "#e8330f", onPrimary: "#ffffff" },
|
|
41
|
+
sport: { primary: "#d31c24", onPrimary: "#ffffff" },
|
|
42
|
+
"sport-2": { primary: "#0c0d0f", onPrimary: "#c6ff3d" },
|
|
43
|
+
jeux: { primary: "#1646d8", onPrimary: "#ffffff" },
|
|
44
|
+
"jeux-2": { primary: "#1d6ff2", onPrimary: "#ffffff" },
|
|
45
|
+
mubarak: { primary: "#07122b", onPrimary: "#f3d98a" },
|
|
46
|
+
"mubarak-2": { primary: "#a8431f", onPrimary: "#fff8ef" },
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export const escapeHtml = (s: unknown) =>
|
|
@@ -2,6 +2,8 @@ import { MedusaContainer } from "@medusajs/framework/types"
|
|
|
2
2
|
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
3
3
|
import { type EmailType, renderEmail, emailLabel } from "./emails"
|
|
4
4
|
import { readProject } from "./project"
|
|
5
|
+
import { emailMode } from "./email-config"
|
|
6
|
+
import { reviewToken } from "./review-token"
|
|
5
7
|
|
|
6
8
|
type Options = {
|
|
7
9
|
/** Texte en plus sous l'introduction (ex. numéro de suivi) */
|
|
@@ -12,6 +14,8 @@ type Options = {
|
|
|
12
14
|
vars?: Record<string, string>
|
|
13
15
|
/** Envoyer le récapitulatif des articles */
|
|
14
16
|
withItems?: boolean
|
|
17
|
+
/** Bouton « Laisser un avis » vers la boutique de la commande (lien signé, e-mail « commande livrée ») */
|
|
18
|
+
reviewLink?: boolean
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
let warned = false
|
|
@@ -19,13 +23,13 @@ let warned = false
|
|
|
19
23
|
/**
|
|
20
24
|
* E-mail au client d'une commande (et au marchand pour une nouvelle commande).
|
|
21
25
|
* Langue, nom et couleurs : ceux de la boutique d'où vient la commande (sales channel → project.json).
|
|
22
|
-
* Sans
|
|
26
|
+
* Sans envoi configuré (ni Resend ni SMTP, voir email-config.ts) : rien n'est envoyé (la boutique fonctionne quand même).
|
|
23
27
|
* Une erreur d'envoi est journalisée mais ne bloque jamais la commande.
|
|
24
28
|
*/
|
|
25
29
|
export async function sendOrderEmail(container: MedusaContainer, orderId: string, type: EmailType, opts: Options = {}) {
|
|
26
30
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
27
|
-
if (!
|
|
28
|
-
if (!warned) logger.warn("[emails] RESEND_API_KEY
|
|
31
|
+
if (!emailMode()) {
|
|
32
|
+
if (!warned) logger.warn("[emails] ni RESEND_API_KEY ni SMTP configuré : aucun e-mail n'est envoyé")
|
|
29
33
|
warned = true
|
|
30
34
|
return
|
|
31
35
|
}
|
|
@@ -79,8 +83,10 @@ export async function sendOrderEmail(container: MedusaContainer, orderId: string
|
|
|
79
83
|
items: opts.withItems ? items : undefined,
|
|
80
84
|
total: opts.withItems ? money(order.total) : undefined,
|
|
81
85
|
address: opts.withItems ? address : undefined,
|
|
82
|
-
extra: opts.extra,
|
|
83
|
-
button: opts.
|
|
86
|
+
extra: opts.reviewLink ? emailLabel(project.language, type, "reviewText") : opts.extra,
|
|
87
|
+
button: opts.reviewLink
|
|
88
|
+
? { label: emailLabel(project.language, type, "review"), url: `${boutique.url}/review?token=${encodeURIComponent(reviewToken(order.id))}` }
|
|
89
|
+
: opts.button,
|
|
84
90
|
})
|
|
85
91
|
|
|
86
92
|
const notifications = container.resolve(Modules.NOTIFICATION)
|
|
@@ -115,12 +121,12 @@ export const adminUrl = () => (process.env.ADMIN_URL || process.env.ADMIN_CORS?.
|
|
|
115
121
|
|
|
116
122
|
/**
|
|
117
123
|
* E-mails de compte admin (mot de passe oublié, invitation d'équipe) : un bouton vers l'admin.
|
|
118
|
-
* Sans
|
|
124
|
+
* Sans envoi configuré (ni Resend ni SMTP) : rien n'est envoyé (le lien reste visible dans les logs du backend en dev).
|
|
119
125
|
*/
|
|
120
126
|
export async function sendAccountEmail(container: MedusaContainer, to: string, type: "password_reset" | "team_invite", url: string) {
|
|
121
127
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
122
|
-
if (!
|
|
123
|
-
logger.warn(`[emails]
|
|
128
|
+
if (!emailMode()) {
|
|
129
|
+
logger.warn(`[emails] aucun envoi configuré (Resend ou SMTP) : e-mail « ${type} » non envoyé à ${to}`)
|
|
124
130
|
return
|
|
125
131
|
}
|
|
126
132
|
try {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moyens de paiement activés d'après l'environnement (.env). Un moyen sans ses clés n'est pas chargé.
|
|
3
|
+
* Utilisé par medusa-config.ts (providers à charger) et par le seed (providers de chaque région, selon sa devise).
|
|
4
|
+
*
|
|
5
|
+
* - Manuel (pp_system_default) : toujours.
|
|
6
|
+
* - Stripe (carte, Apple Pay, Google Pay) : STRIPE_API_KEY.
|
|
7
|
+
* - PayTech (Wave, Orange Money, Free Money, carte) : PAYTECH_API_KEY + PAYTECH_API_SECRET ; PAYTECH_ENV=test|prod.
|
|
8
|
+
* - PayDunya (Wave, Orange Money, carte) : PAYDUNYA_MASTER_KEY + PAYDUNYA_PRIVATE_KEY + PAYDUNYA_TOKEN ; PAYDUNYA_ENV=test|prod.
|
|
9
|
+
* - NabooPay (Wave, Orange Money) : NABOOPAY_API_KEY (+ NABOOPAY_WEBHOOK_SECRET) ; NABOOPAY_METHODS=wave,orange_money.
|
|
10
|
+
*/
|
|
11
|
+
type Provider = { resolve: string; id: string; options: Record<string, unknown>; providerId: string; currencies?: string[] }
|
|
12
|
+
|
|
13
|
+
export function paymentProviders(env: NodeJS.ProcessEnv = process.env): Provider[] {
|
|
14
|
+
const list: Provider[] = []
|
|
15
|
+
if (env.STRIPE_API_KEY) {
|
|
16
|
+
list.push({
|
|
17
|
+
resolve: "@medusajs/medusa/payment-stripe",
|
|
18
|
+
id: "stripe",
|
|
19
|
+
providerId: "pp_stripe_stripe",
|
|
20
|
+
options: { apiKey: env.STRIPE_API_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET || undefined, capture: true, automaticPaymentMethods: true },
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
if (env.PAYTECH_API_KEY && env.PAYTECH_API_SECRET) {
|
|
24
|
+
list.push({
|
|
25
|
+
resolve: "./src/modules/paytech",
|
|
26
|
+
id: "paytech",
|
|
27
|
+
providerId: "pp_paytech_paytech",
|
|
28
|
+
currencies: ["xof", "eur", "usd", "cad", "gbp", "mad"],
|
|
29
|
+
options: { api_key: env.PAYTECH_API_KEY, api_secret: env.PAYTECH_API_SECRET, env: env.PAYTECH_ENV === "prod" ? "prod" : "test" },
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
if (env.PAYDUNYA_MASTER_KEY && env.PAYDUNYA_PRIVATE_KEY && env.PAYDUNYA_TOKEN) {
|
|
33
|
+
list.push({
|
|
34
|
+
resolve: "./src/modules/paydunya",
|
|
35
|
+
id: "paydunya",
|
|
36
|
+
providerId: "pp_paydunya_paydunya",
|
|
37
|
+
currencies: ["xof"],
|
|
38
|
+
options: {
|
|
39
|
+
master_key: env.PAYDUNYA_MASTER_KEY,
|
|
40
|
+
private_key: env.PAYDUNYA_PRIVATE_KEY,
|
|
41
|
+
token: env.PAYDUNYA_TOKEN,
|
|
42
|
+
env: env.PAYDUNYA_ENV === "prod" ? "prod" : "test",
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
if (env.NABOOPAY_API_KEY) {
|
|
47
|
+
list.push({
|
|
48
|
+
resolve: "./src/modules/naboopay",
|
|
49
|
+
id: "naboopay",
|
|
50
|
+
providerId: "pp_naboopay_naboopay",
|
|
51
|
+
currencies: ["xof"],
|
|
52
|
+
options: {
|
|
53
|
+
api_key: env.NABOOPAY_API_KEY,
|
|
54
|
+
webhook_secret: env.NABOOPAY_WEBHOOK_SECRET || undefined,
|
|
55
|
+
methods: (env.NABOOPAY_METHODS || "wave,orange_money").split(",").map((m) => m.trim()).filter(Boolean),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return list
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Providers d'une région (devise) : manuel + ceux qui acceptent cette devise */
|
|
63
|
+
export function regionPaymentProviders(currency: string, env: NodeJS.ProcessEnv = process.env): string[] {
|
|
64
|
+
const c = currency.toLowerCase()
|
|
65
|
+
return ["pp_system_default", ...paymentProviders(env).filter((p) => !p.currencies || p.currencies.includes(c)).map((p) => p.providerId)]
|
|
66
|
+
}
|
|
@@ -16,6 +16,7 @@ const shipsTo = z.union([z.literal("all"), z.array(code2).min(1)])
|
|
|
16
16
|
|
|
17
17
|
const projectSchema = z.object({
|
|
18
18
|
project: z.string(),
|
|
19
|
+
mode: z.enum(["multi", "single"]).default("multi"),
|
|
19
20
|
defaults: z.looseObject({
|
|
20
21
|
storeName: z.string(),
|
|
21
22
|
currency: code3,
|
|
@@ -23,13 +24,17 @@ const projectSchema = z.object({
|
|
|
23
24
|
language: z.string().default("en"),
|
|
24
25
|
shipsTo: shipsTo.default("all"),
|
|
25
26
|
merchantEmail: z.email().optional(),
|
|
27
|
+
/** Avis : « shared » = un produit affiche les avis de toutes les boutiques (défaut) ; « boutique » = seulement les siens */
|
|
28
|
+
reviews: z.enum(["shared", "boutique"]).default("shared"),
|
|
26
29
|
}),
|
|
30
|
+
site: z.looseObject({ port: z.number().optional(), url: z.string().optional() }).optional(),
|
|
27
31
|
boutiques: z
|
|
28
32
|
.array(
|
|
29
33
|
z.looseObject({
|
|
30
34
|
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
31
35
|
theme: z.string(),
|
|
32
|
-
port: z.number(),
|
|
36
|
+
port: z.number().optional(),
|
|
37
|
+
url: z.string().optional(),
|
|
33
38
|
storeName: z.string().optional(),
|
|
34
39
|
currency: code3.optional(),
|
|
35
40
|
country: code2.optional(),
|
|
@@ -46,13 +51,18 @@ export type Boutique = {
|
|
|
46
51
|
currency: string
|
|
47
52
|
country: string
|
|
48
53
|
shipsTo: "all" | string[]
|
|
54
|
+
/** Adresse publique du site de la boutique (mode single : celle du site unique) */
|
|
55
|
+
url: string
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export type Project = {
|
|
52
59
|
project: string
|
|
60
|
+
/** single : un seul site public, qui affiche la boutique active (lib/active.ts) */
|
|
61
|
+
mode: "multi" | "single"
|
|
53
62
|
defaultCurrency: string
|
|
54
63
|
language: string
|
|
55
64
|
merchantEmail?: string
|
|
65
|
+
reviews: "shared" | "boutique"
|
|
56
66
|
boutiques: Boutique[]
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -66,9 +76,11 @@ export function readProject(): Project {
|
|
|
66
76
|
const { defaults, boutiques } = res.data
|
|
67
77
|
return {
|
|
68
78
|
project: res.data.project,
|
|
79
|
+
mode: res.data.mode,
|
|
69
80
|
defaultCurrency: defaults.currency,
|
|
70
81
|
language: defaults.language,
|
|
71
82
|
merchantEmail: defaults.merchantEmail,
|
|
83
|
+
reviews: defaults.reviews,
|
|
72
84
|
boutiques: boutiques.map((b) => ({
|
|
73
85
|
slug: b.slug,
|
|
74
86
|
theme: b.theme,
|
|
@@ -76,6 +88,10 @@ export function readProject(): Project {
|
|
|
76
88
|
currency: b.currency ?? defaults.currency,
|
|
77
89
|
country: b.country ?? defaults.country,
|
|
78
90
|
shipsTo: b.shipsTo ?? defaults.shipsTo,
|
|
91
|
+
url: (res.data.mode === "single"
|
|
92
|
+
? (res.data.site?.url ?? `http://localhost:${res.data.site?.port ?? 8000}`)
|
|
93
|
+
: (b.url ?? `http://localhost:${b.port ?? 8000}`)
|
|
94
|
+
).replace(/\/+$/, ""),
|
|
79
95
|
})),
|
|
80
96
|
}
|
|
81
97
|
}
|
|
@@ -144,3 +160,24 @@ export const SHIPPING_RATES: Record<string, [number, number]> = {
|
|
|
144
160
|
xaf: [2000, 5000],
|
|
145
161
|
}
|
|
146
162
|
export const shippingRates = (currency: string): [number, number] => SHIPPING_RATES[currency] ?? [0, 0]
|
|
163
|
+
|
|
164
|
+
export const THEMES_DIR = process.env.THEMES_DIR || "/app/themes"
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Textes par défaut du thème d'une boutique (themes/<thème>/campaign.<langue>.json, anglais à défaut),
|
|
168
|
+
* affichés pré-remplis dans l'admin → Campagnes. Variables {discount}, {endDate}, {freeShipping} laissées telles quelles.
|
|
169
|
+
*/
|
|
170
|
+
export function themeTexts(slug: string): Record<string, unknown> | undefined {
|
|
171
|
+
try {
|
|
172
|
+
const project = readProject()
|
|
173
|
+
const theme = project.boutiques.find((b) => b.slug === slug)?.theme
|
|
174
|
+
if (!theme) return undefined
|
|
175
|
+
for (const lang of [project.language, "en"]) {
|
|
176
|
+
const file = path.join(THEMES_DIR, theme, `campaign.${lang}.json`)
|
|
177
|
+
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Thèmes non montés (ancien projet) : l'admin affiche des champs vides, comme avant
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|