@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
|
2
2
|
import { PencilSquare, Sparkles, Spinner } from "@medusajs/icons"
|
|
3
|
-
import { Badge, Button, Container, Heading, Table, Text } from "@medusajs/ui"
|
|
4
|
-
import { useQuery } from "@tanstack/react-query"
|
|
3
|
+
import { Badge, Button, Container, Heading, Table, Text, toast, usePrompt } from "@medusajs/ui"
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
5
5
|
import { useState } from "react"
|
|
6
6
|
import { useTranslation } from "react-i18next"
|
|
7
7
|
import { sdk } from "../../lib/client"
|
|
@@ -23,11 +23,11 @@ const COLORS = { upcoming: "blue", live: "green", ended: "grey" } as const
|
|
|
23
23
|
* Ouvre le panneau avec la campagne relue sur le serveur (jamais la copie du tableau) : sinon, rouvrir juste après
|
|
24
24
|
* un enregistrement pouvait repartir d'anciennes valeurs et réenregistrer l'ancienne image.
|
|
25
25
|
*/
|
|
26
|
-
function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "boutique"> & { boutique: Boutique }) {
|
|
26
|
+
function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "boutique" | "defaults"> & { boutique: Boutique }) {
|
|
27
27
|
const { boutique, ...rest } = props
|
|
28
28
|
const { data, isFetching } = useQuery({
|
|
29
29
|
queryKey: [...QUERY_KEY, "one", boutique.sales_channel_id],
|
|
30
|
-
queryFn: () => sdk.client.fetch<{ campaign: Boutique["campaign"] }>(`/admin/boutique-campaigns/${boutique.sales_channel_id}`),
|
|
30
|
+
queryFn: () => sdk.client.fetch<{ campaign: Boutique["campaign"]; defaults?: Parameters<typeof CampaignDrawer>[0]["defaults"] }>(`/admin/boutique-campaigns/${boutique.sales_channel_id}`),
|
|
31
31
|
staleTime: 0,
|
|
32
32
|
gcTime: 0,
|
|
33
33
|
})
|
|
@@ -38,18 +38,65 @@ function FreshCampaignDrawer(props: Omit<Parameters<typeof CampaignDrawer>[0], "
|
|
|
38
38
|
</div>
|
|
39
39
|
)
|
|
40
40
|
}
|
|
41
|
-
return <CampaignDrawer {...rest} boutique={{ ...boutique, campaign: data.campaign ?? null }} />
|
|
41
|
+
return <CampaignDrawer {...rest} boutique={{ ...boutique, campaign: data.campaign ?? null }} defaults={data.defaults ?? null} />
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Page « Campagnes » : une ligne par boutique du projet, édition dans un panneau latéral. */
|
|
45
45
|
const CampaignsPage = () => {
|
|
46
46
|
const { t, i18n } = useTranslation()
|
|
47
47
|
const [editing, setEditing] = useState<Boutique | null>(null)
|
|
48
|
+
const queryClient = useQueryClient()
|
|
49
|
+
const prompt = usePrompt()
|
|
48
50
|
|
|
49
51
|
const { data, isLoading } = useQuery({
|
|
50
52
|
queryKey: QUERY_KEY,
|
|
51
|
-
queryFn: () =>
|
|
53
|
+
queryFn: () =>
|
|
54
|
+
sdk.client.fetch<{ mode?: "multi" | "single"; active?: string | null; boutiques: Boutique[] }>("/admin/boutique-campaigns"),
|
|
52
55
|
})
|
|
56
|
+
const single = data?.mode === "single"
|
|
57
|
+
const multi = !!data && !single
|
|
58
|
+
|
|
59
|
+
// Mode multi : ouvrir / fermer le site d'une boutique (manuel, indépendant des dates de campagne)
|
|
60
|
+
const setOnline = useMutation({
|
|
61
|
+
mutationFn: ({ id, online }: { id: string; online: boolean }) =>
|
|
62
|
+
sdk.client.fetch(`/admin/boutique-online/${id}`, { method: "POST", body: { online } }),
|
|
63
|
+
onSuccess: (_d, v) => {
|
|
64
|
+
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
|
65
|
+
toast.success(t(v.online ? "campaigns.site.opened" : "campaigns.site.closed"))
|
|
66
|
+
},
|
|
67
|
+
onError: (e: Error) => toast.error(e.message),
|
|
68
|
+
})
|
|
69
|
+
const toggleSite = async (b: Boutique) => {
|
|
70
|
+
const online = b.online === false
|
|
71
|
+
const ok = await prompt({
|
|
72
|
+
title: t(online ? "campaigns.site.openTitle" : "campaigns.site.closeTitle", { slug: b.slug }),
|
|
73
|
+
description: t(online ? "campaigns.site.openText" : "campaigns.site.closeText"),
|
|
74
|
+
confirmText: t(online ? "campaigns.site.open" : "campaigns.site.close"),
|
|
75
|
+
cancelText: t("campaigns.cancel"),
|
|
76
|
+
variant: online ? "confirmation" : "danger",
|
|
77
|
+
})
|
|
78
|
+
if (ok) setOnline.mutate({ id: b.sales_channel_id, online })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Mode single : met une boutique en ligne (la précédente est retirée ; il y en a toujours exactement une)
|
|
82
|
+
const activate = useMutation({
|
|
83
|
+
mutationFn: (slug: string) => sdk.client.fetch("/admin/active-boutique", { method: "POST", body: { slug } }),
|
|
84
|
+
onSuccess: () => {
|
|
85
|
+
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
|
86
|
+
toast.success(t("campaigns.online.done"))
|
|
87
|
+
},
|
|
88
|
+
onError: (e: Error) => toast.error(e.message),
|
|
89
|
+
})
|
|
90
|
+
const goLive = async (slug: string) => {
|
|
91
|
+
const ok = await prompt({
|
|
92
|
+
title: t("campaigns.online.confirmTitle"),
|
|
93
|
+
description: t("campaigns.online.confirmText", { from: data?.active ?? "", to: slug }),
|
|
94
|
+
confirmText: t("campaigns.online.confirm"),
|
|
95
|
+
cancelText: t("campaigns.cancel"),
|
|
96
|
+
variant: "confirmation",
|
|
97
|
+
})
|
|
98
|
+
if (ok) activate.mutate(slug)
|
|
99
|
+
}
|
|
53
100
|
|
|
54
101
|
const date = (iso: string) => new Date(iso).toLocaleDateString(i18n.language, { dateStyle: "medium" })
|
|
55
102
|
|
|
@@ -60,6 +107,11 @@ const CampaignsPage = () => {
|
|
|
60
107
|
<Text size="small" className="text-ui-fg-subtle mt-1">
|
|
61
108
|
{t("campaigns.description")}
|
|
62
109
|
</Text>
|
|
110
|
+
{single && (
|
|
111
|
+
<Text size="small" className="text-ui-fg-subtle mt-1">
|
|
112
|
+
{t("campaigns.online.hint")}
|
|
113
|
+
</Text>
|
|
114
|
+
)}
|
|
63
115
|
</div>
|
|
64
116
|
|
|
65
117
|
{isLoading ? (
|
|
@@ -73,6 +125,7 @@ const CampaignsPage = () => {
|
|
|
73
125
|
<Table.HeaderCell>{t("campaigns.store")}</Table.HeaderCell>
|
|
74
126
|
<Table.HeaderCell>{t("campaigns.dates")}</Table.HeaderCell>
|
|
75
127
|
<Table.HeaderCell>{t("campaigns.status")}</Table.HeaderCell>
|
|
128
|
+
{(single || multi) && <Table.HeaderCell>{t("campaigns.online.column")}</Table.HeaderCell>}
|
|
76
129
|
<Table.HeaderCell />
|
|
77
130
|
</Table.Row>
|
|
78
131
|
</Table.Header>
|
|
@@ -101,6 +154,31 @@ const CampaignsPage = () => {
|
|
|
101
154
|
</Badge>
|
|
102
155
|
)}
|
|
103
156
|
</Table.Cell>
|
|
157
|
+
{single && (
|
|
158
|
+
<Table.Cell>
|
|
159
|
+
{data?.active === b.slug ? (
|
|
160
|
+
<Badge size="2xsmall" color="green">
|
|
161
|
+
{t("campaigns.online.live")}
|
|
162
|
+
</Badge>
|
|
163
|
+
) : (
|
|
164
|
+
<Button size="small" variant="secondary" onClick={() => goLive(b.slug)} disabled={activate.isPending}>
|
|
165
|
+
{t("campaigns.online.action")}
|
|
166
|
+
</Button>
|
|
167
|
+
)}
|
|
168
|
+
</Table.Cell>
|
|
169
|
+
)}
|
|
170
|
+
{multi && (
|
|
171
|
+
<Table.Cell>
|
|
172
|
+
<div className="flex items-center gap-x-2">
|
|
173
|
+
<Badge size="2xsmall" color={b.online === false ? "grey" : "green"}>
|
|
174
|
+
{t(b.online === false ? "campaigns.site.offline" : "campaigns.online.live")}
|
|
175
|
+
</Badge>
|
|
176
|
+
<Button size="small" variant="transparent" onClick={() => toggleSite(b)} disabled={setOnline.isPending}>
|
|
177
|
+
{t(b.online === false ? "campaigns.site.open" : "campaigns.site.close")}
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
</Table.Cell>
|
|
181
|
+
)}
|
|
104
182
|
<Table.Cell className="text-right">
|
|
105
183
|
<Button size="small" variant="secondary" onClick={() => setEditing(b)}>
|
|
106
184
|
<PencilSquare />
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
|
2
|
+
import { Check, ChatBubbleLeftRight, StarSolid, Trash, XMark } from "@medusajs/icons"
|
|
3
|
+
import { Badge, Button, Container, Heading, Select, Table, Text, toast, usePrompt } from "@medusajs/ui"
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
5
|
+
import { useState } from "react"
|
|
6
|
+
import { useTranslation } from "react-i18next"
|
|
7
|
+
import { sdk } from "../../lib/client"
|
|
8
|
+
|
|
9
|
+
type Status = "pending" | "approved" | "rejected"
|
|
10
|
+
type Review = {
|
|
11
|
+
id: string
|
|
12
|
+
rating: number
|
|
13
|
+
title: string | null
|
|
14
|
+
comment: string
|
|
15
|
+
author_name: string
|
|
16
|
+
customer_email: string
|
|
17
|
+
status: Status
|
|
18
|
+
created_at: string
|
|
19
|
+
product: { id: string; title: string; thumbnail: string | null } | null
|
|
20
|
+
boutique: string | null
|
|
21
|
+
sales_channel_id: string
|
|
22
|
+
}
|
|
23
|
+
type ListResponse = { reviews: Review[]; count: number; pending: number; limit: number; offset: number }
|
|
24
|
+
type Boutiques = { boutiques: { slug: string; sales_channel_id: string }[] }
|
|
25
|
+
|
|
26
|
+
const QUERY_KEY = ["product-reviews"]
|
|
27
|
+
const PAGE = 20
|
|
28
|
+
const COLORS: Record<Status, "orange" | "green" | "grey"> = { pending: "orange", approved: "green", rejected: "grey" }
|
|
29
|
+
|
|
30
|
+
function Stars({ value }: { value: number }) {
|
|
31
|
+
return (
|
|
32
|
+
<span className="inline-flex" aria-label={`${value}/5`}>
|
|
33
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
34
|
+
<StarSolid key={i} className={i <= value ? "text-ui-tag-orange-icon" : "text-ui-fg-disabled"} />
|
|
35
|
+
))}
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Page « Avis » : avis des acheteurs (lien de l'e-mail « commande livrée »). Un avis n'apparaît sur les boutiques
|
|
42
|
+
* qu'une fois validé. Filtre par statut et par boutique (la boutique de la commande).
|
|
43
|
+
*/
|
|
44
|
+
const ReviewsPage = () => {
|
|
45
|
+
const { t, i18n } = useTranslation()
|
|
46
|
+
const queryClient = useQueryClient()
|
|
47
|
+
const prompt = usePrompt()
|
|
48
|
+
const [status, setStatus] = useState<Status | "all">("pending")
|
|
49
|
+
const [channel, setChannel] = useState<string>("all")
|
|
50
|
+
const [offset, setOffset] = useState(0)
|
|
51
|
+
|
|
52
|
+
const params = new URLSearchParams({ limit: String(PAGE), offset: String(offset) })
|
|
53
|
+
if (status !== "all") params.set("status", status)
|
|
54
|
+
if (channel !== "all") params.set("sales_channel_id", channel)
|
|
55
|
+
|
|
56
|
+
const { data, isLoading } = useQuery({
|
|
57
|
+
queryKey: [...QUERY_KEY, status, channel, offset],
|
|
58
|
+
queryFn: () => sdk.client.fetch<ListResponse>(`/admin/reviews?${params.toString()}`),
|
|
59
|
+
})
|
|
60
|
+
const { data: shops } = useQuery({
|
|
61
|
+
queryKey: ["boutique-campaigns"],
|
|
62
|
+
queryFn: () => sdk.client.fetch<Boutiques>("/admin/boutique-campaigns"),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const done = () => queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
|
66
|
+
const setReviewStatus = useMutation({
|
|
67
|
+
mutationFn: ({ id, status }: { id: string; status: Status }) => sdk.client.fetch(`/admin/reviews/${id}`, { method: "POST", body: { status } }),
|
|
68
|
+
onSuccess: (_d, v) => {
|
|
69
|
+
done()
|
|
70
|
+
toast.success(t(`reviews.toast.${v.status}`))
|
|
71
|
+
},
|
|
72
|
+
onError: (e: Error) => toast.error(e.message),
|
|
73
|
+
})
|
|
74
|
+
const remove = useMutation({
|
|
75
|
+
mutationFn: (id: string) => sdk.client.fetch(`/admin/reviews/${id}`, { method: "DELETE" }),
|
|
76
|
+
onSuccess: () => {
|
|
77
|
+
done()
|
|
78
|
+
toast.success(t("reviews.toast.deleted"))
|
|
79
|
+
},
|
|
80
|
+
onError: (e: Error) => toast.error(e.message),
|
|
81
|
+
})
|
|
82
|
+
const askDelete = async (r: Review) => {
|
|
83
|
+
const ok = await prompt({
|
|
84
|
+
title: t("reviews.deleteTitle"),
|
|
85
|
+
description: t("reviews.deleteText", { name: r.author_name }),
|
|
86
|
+
confirmText: t("reviews.delete"),
|
|
87
|
+
cancelText: t("reviews.cancel"),
|
|
88
|
+
variant: "danger",
|
|
89
|
+
})
|
|
90
|
+
if (ok) remove.mutate(r.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const date = (s: string) => new Date(s).toLocaleDateString(i18n.language, { day: "numeric", month: "short", year: "numeric" })
|
|
94
|
+
const count = data?.count ?? 0
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Container className="divide-y p-0">
|
|
98
|
+
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
|
|
99
|
+
<div>
|
|
100
|
+
<Heading level="h1">{t("reviews.title")}</Heading>
|
|
101
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
102
|
+
{t("reviews.description")}
|
|
103
|
+
</Text>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
106
|
+
<Select
|
|
107
|
+
value={status}
|
|
108
|
+
onValueChange={(v) => {
|
|
109
|
+
setStatus(v as Status | "all")
|
|
110
|
+
setOffset(0)
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<Select.Trigger className="w-44">
|
|
114
|
+
<Select.Value />
|
|
115
|
+
</Select.Trigger>
|
|
116
|
+
<Select.Content>
|
|
117
|
+
<Select.Item value="pending">
|
|
118
|
+
{t("reviews.status.pending")}
|
|
119
|
+
{data?.pending ? ` (${data.pending})` : ""}
|
|
120
|
+
</Select.Item>
|
|
121
|
+
<Select.Item value="approved">{t("reviews.status.approved")}</Select.Item>
|
|
122
|
+
<Select.Item value="rejected">{t("reviews.status.rejected")}</Select.Item>
|
|
123
|
+
<Select.Item value="all">{t("reviews.status.all")}</Select.Item>
|
|
124
|
+
</Select.Content>
|
|
125
|
+
</Select>
|
|
126
|
+
<Select
|
|
127
|
+
value={channel}
|
|
128
|
+
onValueChange={(v) => {
|
|
129
|
+
setChannel(v)
|
|
130
|
+
setOffset(0)
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<Select.Trigger className="w-52">
|
|
134
|
+
<Select.Value />
|
|
135
|
+
</Select.Trigger>
|
|
136
|
+
<Select.Content>
|
|
137
|
+
<Select.Item value="all">{t("reviews.allBoutiques")}</Select.Item>
|
|
138
|
+
{(shops?.boutiques ?? []).map((b) => (
|
|
139
|
+
<Select.Item key={b.sales_channel_id} value={b.sales_channel_id}>
|
|
140
|
+
{b.slug}
|
|
141
|
+
</Select.Item>
|
|
142
|
+
))}
|
|
143
|
+
</Select.Content>
|
|
144
|
+
</Select>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{isLoading ? (
|
|
149
|
+
<div className="px-6 py-8">
|
|
150
|
+
<Text className="text-ui-fg-subtle">…</Text>
|
|
151
|
+
</div>
|
|
152
|
+
) : !data?.reviews.length ? (
|
|
153
|
+
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center">
|
|
154
|
+
<ChatBubbleLeftRight className="text-ui-fg-muted" />
|
|
155
|
+
<Text className="text-ui-fg-subtle">{t(status === "pending" ? "reviews.emptyPending" : "reviews.empty")}</Text>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<Table>
|
|
159
|
+
<Table.Header>
|
|
160
|
+
<Table.Row>
|
|
161
|
+
<Table.HeaderCell>{t("reviews.col.product")}</Table.HeaderCell>
|
|
162
|
+
<Table.HeaderCell>{t("reviews.col.review")}</Table.HeaderCell>
|
|
163
|
+
<Table.HeaderCell>{t("reviews.col.author")}</Table.HeaderCell>
|
|
164
|
+
<Table.HeaderCell>{t("reviews.col.status")}</Table.HeaderCell>
|
|
165
|
+
<Table.HeaderCell className="text-right">{t("reviews.col.actions")}</Table.HeaderCell>
|
|
166
|
+
</Table.Row>
|
|
167
|
+
</Table.Header>
|
|
168
|
+
<Table.Body>
|
|
169
|
+
{data.reviews.map((r) => (
|
|
170
|
+
<Table.Row key={r.id} className="align-top">
|
|
171
|
+
<Table.Cell className="py-3">
|
|
172
|
+
<div className="flex items-center gap-3">
|
|
173
|
+
{r.product?.thumbnail && <img src={r.product.thumbnail} alt="" className="h-10 w-10 rounded object-cover" />}
|
|
174
|
+
<div className="min-w-0">
|
|
175
|
+
<Text size="small" weight="plus" className="truncate">
|
|
176
|
+
{r.product?.title ?? t("reviews.deletedProduct")}
|
|
177
|
+
</Text>
|
|
178
|
+
<Text size="xsmall" className="text-ui-fg-subtle">
|
|
179
|
+
{r.boutique ?? "—"}
|
|
180
|
+
</Text>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</Table.Cell>
|
|
184
|
+
<Table.Cell className="max-w-[420px] whitespace-normal py-3">
|
|
185
|
+
<Stars value={r.rating} />
|
|
186
|
+
{r.title && (
|
|
187
|
+
<Text size="small" weight="plus" className="mt-1">
|
|
188
|
+
{r.title}
|
|
189
|
+
</Text>
|
|
190
|
+
)}
|
|
191
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
192
|
+
{r.comment}
|
|
193
|
+
</Text>
|
|
194
|
+
</Table.Cell>
|
|
195
|
+
<Table.Cell className="py-3">
|
|
196
|
+
<Text size="small">{r.author_name}</Text>
|
|
197
|
+
<Text size="xsmall" className="text-ui-fg-subtle">
|
|
198
|
+
{r.customer_email}
|
|
199
|
+
</Text>
|
|
200
|
+
<Text size="xsmall" className="text-ui-fg-subtle">
|
|
201
|
+
{date(r.created_at)}
|
|
202
|
+
</Text>
|
|
203
|
+
</Table.Cell>
|
|
204
|
+
<Table.Cell className="py-3">
|
|
205
|
+
<Badge size="2xsmall" color={COLORS[r.status]}>
|
|
206
|
+
{t(`reviews.status.${r.status}`)}
|
|
207
|
+
</Badge>
|
|
208
|
+
</Table.Cell>
|
|
209
|
+
<Table.Cell className="py-3">
|
|
210
|
+
<div className="flex justify-end gap-2">
|
|
211
|
+
{r.status !== "approved" && (
|
|
212
|
+
<Button size="small" variant="secondary" onClick={() => setReviewStatus.mutate({ id: r.id, status: "approved" })}>
|
|
213
|
+
<Check />
|
|
214
|
+
{t("reviews.approve")}
|
|
215
|
+
</Button>
|
|
216
|
+
)}
|
|
217
|
+
{r.status !== "rejected" && (
|
|
218
|
+
<Button size="small" variant="secondary" onClick={() => setReviewStatus.mutate({ id: r.id, status: "rejected" })}>
|
|
219
|
+
<XMark />
|
|
220
|
+
{t("reviews.reject")}
|
|
221
|
+
</Button>
|
|
222
|
+
)}
|
|
223
|
+
<Button size="small" variant="transparent" onClick={() => askDelete(r)} aria-label={t("reviews.delete")}>
|
|
224
|
+
<Trash />
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
</Table.Cell>
|
|
228
|
+
</Table.Row>
|
|
229
|
+
))}
|
|
230
|
+
</Table.Body>
|
|
231
|
+
</Table>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{count > PAGE && (
|
|
235
|
+
<div className="flex items-center justify-between px-6 py-3">
|
|
236
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
237
|
+
{t("reviews.range", { from: offset + 1, to: Math.min(offset + PAGE, count), count })}
|
|
238
|
+
</Text>
|
|
239
|
+
<div className="flex gap-2">
|
|
240
|
+
<Button size="small" variant="secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
|
241
|
+
{t("reviews.prev")}
|
|
242
|
+
</Button>
|
|
243
|
+
<Button size="small" variant="secondary" disabled={offset + PAGE >= count} onClick={() => setOffset(offset + PAGE)}>
|
|
244
|
+
{t("reviews.next")}
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</Container>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const config = defineRouteConfig({
|
|
254
|
+
label: "reviews.nav",
|
|
255
|
+
translationNs: "translation",
|
|
256
|
+
icon: StarSolid,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
export default ReviewsPage
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { z } from "@medusajs/framework/zod"
|
|
3
|
+
import { MedusaError } from "@medusajs/framework/utils"
|
|
4
|
+
import { getActive, setActive } from "../../../lib/active"
|
|
5
|
+
import { readProject } from "../../../lib/project"
|
|
6
|
+
|
|
7
|
+
export const ActiveBoutique = z.object({ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) })
|
|
8
|
+
|
|
9
|
+
/** GET /admin/active-boutique : mode du projet et boutique en ligne (mode single). */
|
|
10
|
+
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
11
|
+
const { mode } = readProject()
|
|
12
|
+
res.json({ mode, active: mode === "single" ? await getActive(req.scope) : null })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** POST /admin/active-boutique { slug } : met cette boutique en ligne (la précédente est retirée). */
|
|
16
|
+
export async function POST(req: AuthenticatedMedusaRequest<z.infer<typeof ActiveBoutique>>, res: MedusaResponse) {
|
|
17
|
+
const project = readProject()
|
|
18
|
+
if (project.mode !== "single") {
|
|
19
|
+
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Mode multi : toutes les boutiques sont déjà en ligne")
|
|
20
|
+
}
|
|
21
|
+
if (!project.boutiques.some((b) => b.slug === req.validatedBody.slug)) {
|
|
22
|
+
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Unknown store")
|
|
23
|
+
}
|
|
24
|
+
res.json({ mode: project.mode, active: await setActive(req.scope, req.validatedBody.slug) })
|
|
25
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
2
|
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
|
|
3
3
|
import { toCampaignJson, type CampaignInput } from "../../../../modules/campaign/validators"
|
|
4
|
+
import { themeTexts } from "../../../../lib/project"
|
|
4
5
|
import { upsertCampaignWorkflow } from "../../../../workflows/upsert-campaign"
|
|
5
6
|
|
|
6
7
|
async function channelOrThrow(req: AuthenticatedMedusaRequest<unknown>) {
|
|
@@ -17,7 +18,12 @@ async function channelOrThrow(req: AuthenticatedMedusaRequest<unknown>) {
|
|
|
17
18
|
/** GET /admin/boutique-campaigns/:salesChannelId */
|
|
18
19
|
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
19
20
|
const channel = await channelOrThrow(req)
|
|
20
|
-
res.json({
|
|
21
|
+
res.json({
|
|
22
|
+
slug: channel.name,
|
|
23
|
+
campaign: channel.boutique_campaign ? toCampaignJson(channel.boutique_campaign) : null,
|
|
24
|
+
// Textes du thème : pré-remplissent les champs de l'admin
|
|
25
|
+
defaults: themeTexts(channel.name) ?? null,
|
|
26
|
+
})
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** POST /admin/boutique-campaigns/:salesChannelId : enregistre la campagne (corps validé par zod, cf. middlewares). */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
2
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
3
|
+
import { getActive } from "../../../lib/active"
|
|
4
|
+
import { readProject } from "../../../lib/project"
|
|
3
5
|
import { toCampaignJson } from "../../../modules/campaign/validators"
|
|
4
6
|
|
|
5
7
|
/** GET /admin/boutique-campaigns : les boutiques du projet (sales channels) et leur campagne. */
|
|
@@ -8,16 +10,22 @@ export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse)
|
|
|
8
10
|
|
|
9
11
|
const { data: channels } = await query.graph({
|
|
10
12
|
entity: "sales_channel",
|
|
11
|
-
fields: ["id", "name", "description", "boutique_campaign.*"],
|
|
13
|
+
fields: ["id", "name", "description", "metadata", "boutique_campaign.*"],
|
|
12
14
|
})
|
|
13
15
|
|
|
16
|
+
const { mode } = readProject()
|
|
14
17
|
res.json({
|
|
18
|
+
// Mode single : une seule boutique en ligne, choisie ici (POST /admin/active-boutique)
|
|
19
|
+
mode,
|
|
20
|
+
active: mode === "single" ? await getActive(req.scope) : null,
|
|
15
21
|
boutiques: channels
|
|
16
22
|
.filter((c) => c.name !== "Default Sales Channel")
|
|
17
23
|
.map((c) => ({
|
|
18
24
|
sales_channel_id: c.id,
|
|
19
25
|
slug: c.name,
|
|
20
26
|
description: c.description,
|
|
27
|
+
// Mode multi : site ouvert (défaut) ou fermé, choisi ici
|
|
28
|
+
online: c.metadata?.ub_online !== false,
|
|
21
29
|
campaign: c.boutique_campaign ? toCampaignJson(c.boutique_campaign) : null,
|
|
22
30
|
})),
|
|
23
31
|
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { z } from "@medusajs/framework/zod"
|
|
3
|
+
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
|
|
4
|
+
import { updateSalesChannelsWorkflow } from "@medusajs/medusa/core-flows"
|
|
5
|
+
|
|
6
|
+
export const BoutiqueOnline = z.object({ online: z.boolean() })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /admin/boutique-online/:salesChannelId { online } : ouvre ou ferme le site d'une boutique.
|
|
10
|
+
* Fermé : le site affiche « Boutique fermée » ; compte, suivi de commande et paniers en cours restent accessibles.
|
|
11
|
+
* Rien d'automatique : seul ce réglage ouvre ou ferme un site (les dates de campagne ne pilotent que la promo).
|
|
12
|
+
*/
|
|
13
|
+
export async function POST(req: AuthenticatedMedusaRequest<z.infer<typeof BoutiqueOnline>>, res: MedusaResponse) {
|
|
14
|
+
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
15
|
+
const { data } = await query.graph({ entity: "sales_channel", fields: ["id", "name", "metadata"], filters: { id: req.params.salesChannelId } })
|
|
16
|
+
if (!data.length) throw new MedusaError(MedusaError.Types.NOT_FOUND, "Unknown store")
|
|
17
|
+
const channel = data[0]
|
|
18
|
+
await updateSalesChannelsWorkflow(req.scope).run({
|
|
19
|
+
input: { selector: { id: channel.id }, update: { metadata: { ...(channel.metadata ?? {}), ub_online: req.validatedBody.online } } },
|
|
20
|
+
})
|
|
21
|
+
res.json({ slug: channel.name, online: req.validatedBody.online })
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type { ReviewStatusInput } from "../../../../modules/product-review/validators"
|
|
3
|
+
import { deleteReviewWorkflow, updateReviewStatusWorkflow } from "../../../../workflows/reviews"
|
|
4
|
+
|
|
5
|
+
/** POST /admin/reviews/:id { status } : valider, refuser, remettre en attente. */
|
|
6
|
+
export async function POST(req: AuthenticatedMedusaRequest<ReviewStatusInput>, res: MedusaResponse) {
|
|
7
|
+
const { result } = await updateReviewStatusWorkflow(req.scope).run({ input: { id: req.params.id, status: req.validatedBody.status } })
|
|
8
|
+
res.json({ review: result })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** DELETE /admin/reviews/:id : supprimer un avis. */
|
|
12
|
+
export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
13
|
+
await deleteReviewWorkflow(req.scope).run({ input: { id: req.params.id } })
|
|
14
|
+
res.json({ id: req.params.id, deleted: true })
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
3
|
+
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review"
|
|
4
|
+
import ProductReviewModuleService from "../../../modules/product-review/service"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /admin/reviews?status=pending&sales_channel_id=…&limit=20&offset=0 : avis pour la page Avis de l'admin,
|
|
8
|
+
* avec le produit et la boutique (sales channel) de chacun. Compte des avis en attente (badge).
|
|
9
|
+
*/
|
|
10
|
+
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
11
|
+
const status = ["pending", "approved", "rejected"].includes(String(req.query.status)) ? String(req.query.status) : undefined
|
|
12
|
+
const channel = typeof req.query.sales_channel_id === "string" && req.query.sales_channel_id ? req.query.sales_channel_id : undefined
|
|
13
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100)
|
|
14
|
+
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
|
15
|
+
|
|
16
|
+
const service: ProductReviewModuleService = req.scope.resolve(PRODUCT_REVIEW_MODULE)
|
|
17
|
+
const filters = { ...(status ? { status: status as "pending" | "approved" | "rejected" } : {}), ...(channel ? { sales_channel_id: channel } : {}) }
|
|
18
|
+
const [reviews, count] = await service.listAndCountReviews(filters, { take: limit, skip: offset, order: { created_at: "DESC" } })
|
|
19
|
+
const [, pending] = await service.listAndCountReviews({ status: "pending" }, { take: 1 })
|
|
20
|
+
|
|
21
|
+
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
22
|
+
const productIds = [...new Set(reviews.map((r) => r.product_id))]
|
|
23
|
+
const channelIds = [...new Set(reviews.map((r) => r.sales_channel_id))]
|
|
24
|
+
// Types explicites : query.graph n'est typé finement qu'avec les types générés par Medusa (absents en CI)
|
|
25
|
+
type P = { id: string; title: string; thumbnail: string | null }
|
|
26
|
+
type C = { id: string; name: string }
|
|
27
|
+
const products: P[] = productIds.length
|
|
28
|
+
? ((await query.graph({ entity: "product", fields: ["id", "title", "thumbnail"], filters: { id: productIds } })).data as P[])
|
|
29
|
+
: []
|
|
30
|
+
const channels: C[] = channelIds.length
|
|
31
|
+
? ((await query.graph({ entity: "sales_channel", fields: ["id", "name"], filters: { id: channelIds } })).data as C[])
|
|
32
|
+
: []
|
|
33
|
+
const productOf = new Map<string, P>(products.map((p) => [p.id, p]))
|
|
34
|
+
const channelOf = new Map<string, string>(channels.map((c) => [c.id, c.name]))
|
|
35
|
+
|
|
36
|
+
res.json({
|
|
37
|
+
reviews: reviews.map((r) => ({
|
|
38
|
+
...r,
|
|
39
|
+
product: productOf.get(r.product_id) ?? null,
|
|
40
|
+
boutique: channelOf.get(r.sales_channel_id) ?? null,
|
|
41
|
+
})),
|
|
42
|
+
count,
|
|
43
|
+
pending,
|
|
44
|
+
limit,
|
|
45
|
+
offset,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { defineMiddlewares, validateAndTransformBody } from "@medusajs/framework/http"
|
|
2
2
|
import { CampaignInput } from "../modules/campaign/validators"
|
|
3
3
|
import { rateLimit } from "./rate-limit"
|
|
4
|
+
import { ActiveBoutique } from "./admin/active-boutique/route"
|
|
5
|
+
import { BoutiqueOnline } from "./admin/boutique-online/[salesChannelId]/route"
|
|
4
6
|
import { OrderLookup } from "./store/order-lookup/route"
|
|
7
|
+
import { ReviewInput, ReviewStatusInput } from "../modules/product-review/validators"
|
|
5
8
|
|
|
6
9
|
// Anti force brute (par IP) : connexion admin, mot de passe oublié, suivi de commande.
|
|
7
10
|
const loginLimit = rateLimit({ name: "connexion", max: 10, windowMs: 15 * 60 * 1000 })
|
|
8
11
|
const resetLimit = rateLimit({ name: "mot de passe oublié", max: 5, windowMs: 60 * 60 * 1000 })
|
|
9
12
|
const lookupLimit = rateLimit({ name: "suivi de commande", max: 20, windowMs: 10 * 60 * 1000 })
|
|
13
|
+
const reviewLimit = rateLimit({ name: "avis", max: 20, windowMs: 60 * 60 * 1000 })
|
|
10
14
|
|
|
11
15
|
export default defineMiddlewares({
|
|
12
16
|
routes: [
|
|
@@ -16,9 +20,14 @@ export default defineMiddlewares({
|
|
|
16
20
|
method: "POST",
|
|
17
21
|
middlewares: [validateAndTransformBody(CampaignInput)],
|
|
18
22
|
},
|
|
23
|
+
{ matcher: "/admin/active-boutique", method: "POST", middlewares: [validateAndTransformBody(ActiveBoutique)] },
|
|
24
|
+
{ matcher: "/admin/boutique-online/:salesChannelId", method: "POST", middlewares: [validateAndTransformBody(BoutiqueOnline)] },
|
|
19
25
|
{ matcher: "/auth/user/emailpass", method: "POST", middlewares: [loginLimit] },
|
|
20
26
|
{ matcher: "/auth/user/emailpass/reset-password", method: "POST", middlewares: [resetLimit] },
|
|
21
27
|
{ matcher: "/auth/customer/emailpass", method: "POST", middlewares: [loginLimit] },
|
|
28
|
+
{ matcher: "/admin/reviews/:id", method: "POST", middlewares: [validateAndTransformBody(ReviewStatusInput)] },
|
|
29
|
+
{ matcher: "/store/reviews", method: "POST", middlewares: [reviewLimit, validateAndTransformBody(ReviewInput)] },
|
|
30
|
+
{ matcher: "/store/reviews/order", method: "GET", middlewares: [lookupLimit] },
|
|
22
31
|
{
|
|
23
32
|
matcher: "/store/order-lookup",
|
|
24
33
|
method: "POST",
|
|
@@ -20,6 +20,10 @@ export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
|
20
20
|
throw new MedusaError(MedusaError.Types.NOT_FOUND, "No campaign for this store")
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Site ouvert / fermé : choisi dans admin → Campagnes (metadata du sales channel), indépendant des dates
|
|
24
|
+
const { data: channels } = await query.graph({ entity: "sales_channel", fields: ["metadata"], filters: { id: salesChannelIds } })
|
|
25
|
+
const online = channels[0]?.metadata?.ub_online !== false
|
|
26
|
+
|
|
23
27
|
res.setHeader("Cache-Control", "public, max-age=60")
|
|
24
|
-
res.json({ campaign: toCampaignJson(data[0]) })
|
|
28
|
+
res.json({ campaign: { ...toCampaignJson(data[0]), online } })
|
|
25
29
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MedusaStoreRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
3
|
+
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review"
|
|
4
|
+
import ProductReviewModuleService from "../../../../modules/product-review/service"
|
|
5
|
+
import { PUBLIC_FIELDS, reviewChannels } from "../../../../lib/reviews"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /store/reviews/latest?limit=6 : derniers avis validés (section « avis » de l'accueil), 4 étoiles et plus,
|
|
9
|
+
* seulement sur des produits vendus dans la boutique qui interroge ; avec titre, lien et image du produit.
|
|
10
|
+
*/
|
|
11
|
+
export async function GET(req: MedusaStoreRequest, res: MedusaResponse) {
|
|
12
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 6, 1), 12)
|
|
13
|
+
const channels = reviewChannels(req)
|
|
14
|
+
const ownChannels = req.publishable_key_context?.sales_channel_ids ?? []
|
|
15
|
+
const service: ProductReviewModuleService = req.scope.resolve(PRODUCT_REVIEW_MODULE)
|
|
16
|
+
const reviews = await service.listReviews(
|
|
17
|
+
{ status: "approved", rating: { $gte: 4 }, ...(channels ? { sales_channel_id: channels } : {}) },
|
|
18
|
+
{ select: [...PUBLIC_FIELDS], take: 60, order: { created_at: "DESC" } }
|
|
19
|
+
)
|
|
20
|
+
if (!reviews.length) return res.json({ reviews: [] })
|
|
21
|
+
|
|
22
|
+
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
23
|
+
const { data: products } = await query.graph({
|
|
24
|
+
entity: "product",
|
|
25
|
+
fields: ["id", "title", "handle", "thumbnail", "status", "sales_channels.id"],
|
|
26
|
+
filters: { id: [...new Set(reviews.map((r) => r.product_id))] },
|
|
27
|
+
})
|
|
28
|
+
const sold = new Map(
|
|
29
|
+
products
|
|
30
|
+
.filter((p) => p.status === "published" && (p.sales_channels ?? []).some((c) => c && ownChannels.includes(c.id)))
|
|
31
|
+
.map((p) => [p.id, { title: p.title, handle: p.handle, thumbnail: p.thumbnail }])
|
|
32
|
+
)
|
|
33
|
+
res.json({
|
|
34
|
+
reviews: reviews
|
|
35
|
+
.filter((r) => sold.has(r.product_id))
|
|
36
|
+
.slice(0, limit)
|
|
37
|
+
.map((r) => ({ ...r, product: sold.get(r.product_id) })),
|
|
38
|
+
})
|
|
39
|
+
}
|