@salifbiaye/create-boutique 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +241 -17
  3. package/cli/dist/create.js +108 -15
  4. package/cli/dist/generate.js +108 -39
  5. package/cli/dist/guide.js +12 -4
  6. package/cli/dist/index.js +38 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/cli/dist/project.js +42 -3
  9. package/docs/fiche-client.en.html +2 -2
  10. package/docs/fiche-client.html +2 -2
  11. package/docs/guide-complet.fr.html +681 -256
  12. package/docs/guide-images/categories.jpg +0 -0
  13. package/docs/guide-images/clients.jpg +0 -0
  14. package/docs/guide-images/collections.jpg +0 -0
  15. package/docs/guide-images/commande-detail.jpg +0 -0
  16. package/docs/guide-images/commande-expedier.jpg +0 -0
  17. package/docs/guide-images/commande-menu.jpg +0 -0
  18. package/docs/guide-images/commande-paiement.jpg +0 -0
  19. package/docs/guide-images/commandes-liste.jpg +0 -0
  20. package/docs/guide-images/connexion.jpg +0 -0
  21. package/docs/guide-images/groupes-clients.jpg +0 -0
  22. package/docs/guide-images/inventaire.jpg +0 -0
  23. package/docs/guide-images/listes-prix.jpg +0 -0
  24. package/docs/guide-images/menu.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  27. package/docs/guide-images/mes-boutiques.jpg +0 -0
  28. package/docs/guide-images/parametres-boutique.jpg +0 -0
  29. package/docs/guide-images/parametres-canaux.jpg +0 -0
  30. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  31. package/docs/guide-images/parametres-profil.jpg +0 -0
  32. package/docs/guide-images/parametres-regions.jpg +0 -0
  33. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  34. package/docs/guide-images/produit-creer-details.jpg +0 -0
  35. package/docs/guide-images/produit-detail.jpg +0 -0
  36. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  37. package/docs/guide-images/produit-variantes.jpg +0 -0
  38. package/docs/guide-images/produits-liste.jpg +0 -0
  39. package/docs/guide-images/promotion-details.jpg +0 -0
  40. package/docs/guide-images/promotion-type.jpg +0 -0
  41. package/docs/guide-images/promotions-liste.jpg +0 -0
  42. package/docs/guide-images/site-boutique.jpg +0 -0
  43. package/docs/guide-images/site-fermee.png +0 -0
  44. package/docs/guide-images/vue-ensemble.jpg +0 -0
  45. package/package.json +3 -2
  46. package/templates/backend/medusa-config.ts +31 -25
  47. package/templates/backend/package-lock.json +21 -0
  48. package/templates/backend/package.json +2 -0
  49. package/templates/backend/src/admin/i18n/json/en.json +63 -5
  50. package/templates/backend/src/admin/i18n/json/fr.json +63 -5
  51. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
  52. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
  53. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  54. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  55. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  56. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
  57. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  58. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  59. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  60. package/templates/backend/src/api/middlewares.ts +9 -0
  61. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  62. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  63. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  64. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  65. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  66. package/templates/backend/src/emails/en.json +3 -1
  67. package/templates/backend/src/emails/fr.json +3 -1
  68. package/templates/backend/src/lib/active.ts +41 -0
  69. package/templates/backend/src/lib/email-config.ts +14 -0
  70. package/templates/backend/src/lib/emails.ts +9 -0
  71. package/templates/backend/src/lib/notify.ts +14 -8
  72. package/templates/backend/src/lib/payment-config.ts +66 -0
  73. package/templates/backend/src/lib/project.ts +38 -1
  74. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  75. package/templates/backend/src/lib/review-token.ts +40 -0
  76. package/templates/backend/src/lib/reviews.ts +25 -0
  77. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  78. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  79. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  80. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  81. package/templates/backend/src/modules/paytech/index.ts +6 -0
  82. package/templates/backend/src/modules/paytech/service.ts +114 -0
  83. package/templates/backend/src/modules/product-review/index.ts +8 -0
  84. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  85. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  86. package/templates/backend/src/modules/product-review/service.ts +6 -0
  87. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  88. package/templates/backend/src/modules/smtp/index.ts +6 -0
  89. package/templates/backend/src/modules/smtp/service.ts +73 -0
  90. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  91. package/templates/backend/src/scripts/seed.ts +60 -36
  92. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  93. package/templates/backend/src/workflows/reviews.ts +21 -0
  94. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  95. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  96. package/templates/storefront/PERSONNALISER.md +134 -0
  97. package/templates/storefront/locales/en.json +198 -11
  98. package/templates/storefront/locales/fr.json +198 -11
  99. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  100. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  101. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  102. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  103. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  104. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  105. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  106. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  107. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  108. package/templates/storefront/src/app/layout.tsx +13 -1
  109. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  110. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  111. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  112. package/templates/storefront/src/lib/boutique/schema.ts +7 -2
  113. package/templates/storefront/src/lib/config.ts +37 -8
  114. package/templates/storefront/src/lib/constants.tsx +17 -0
  115. package/templates/storefront/src/lib/data/cart.ts +4 -1
  116. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  117. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  118. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  119. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  120. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  121. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  122. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  123. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  124. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  125. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  126. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  127. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  128. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  129. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  130. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  131. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  132. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  133. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  134. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  135. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  136. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  137. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  138. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  139. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  140. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  141. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  142. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  143. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  144. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  145. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  146. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  147. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  148. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  149. package/templates/storefront/src/proxy.ts +4 -1
  150. package/templates/storefront/src/styles/custom.css +26 -0
  151. package/templates/storefront/src/styles/ub/album.css +1 -1
  152. package/templates/storefront/src/styles/ub/arena.css +120 -0
  153. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  154. package/templates/storefront/src/styles/ub/gala.css +1 -1
  155. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  156. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  157. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  158. package/templates/storefront/src/styles/ub/locker.css +115 -0
  159. package/templates/storefront/src/styles/ub/media.css +10 -0
  160. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  161. package/templates/storefront/src/styles/ub/motion.css +0 -2
  162. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  163. package/templates/storefront/src/styles/ub/pages.css +176 -0
  164. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  165. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  166. package/templates/storefront/src/styles/ub/souk.css +144 -0
  167. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  168. package/templates/storefront/src/styles/ub/tech.css +5 -8
  169. package/templates/storefront/src/styles/ub/training.css +120 -0
  170. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  171. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  172. package/templates/storefront/themes/jeux/theme.json +11 -0
  173. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  174. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  175. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  176. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  177. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  178. package/templates/storefront/themes/mubarak/theme.json +11 -0
  179. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  180. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  181. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  182. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  183. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  184. package/templates/storefront/themes/sport/theme.json +11 -0
  185. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  186. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  187. package/templates/storefront/themes/sport-2/theme.json +11 -0
  188. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  189. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  190. package/templates/storefront/themes/summer/theme.json +11 -0
  191. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  192. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  193. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -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: () => sdk.client.fetch<{ boutiques: Boutique[] }>("/admin/boutique-campaigns"),
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({ slug: channel.name, campaign: channel.boutique_campaign ? toCampaignJson(channel.boutique_campaign) : null })
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
+ }