@salifbiaye/create-boutique 0.6.0 → 0.7.1

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