@aglyn/plugins-commerce 1.0.0-beta.243 → 1.0.0-beta.246

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 (57) hide show
  1. package/README.md +4 -1
  2. package/package.json +13 -13
  3. package/src/lib/components/commerce-console-page.js +10 -0
  4. package/src/lib/components/commerce-console-page.js.map +1 -1
  5. package/src/lib/components/console/product-zones.d.ts +7 -1
  6. package/src/lib/components/console/product-zones.js.map +1 -1
  7. package/src/lib/components/console/products-hub-zone.component.d.ts +3 -2
  8. package/src/lib/components/console/products-hub-zone.component.js +9 -1
  9. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  10. package/src/lib/components/console/review-settings-card.component.d.ts +14 -0
  11. package/src/lib/components/console/review-settings-card.component.js +168 -0
  12. package/src/lib/components/console/review-settings-card.component.js.map +1 -0
  13. package/src/lib/components/console/reviews-moderation-card.component.d.ts +14 -0
  14. package/src/lib/components/console/reviews-moderation-card.component.js +85 -11
  15. package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -1
  16. package/src/lib/components/product-reviews.d.ts +29 -1
  17. package/src/lib/components/product-reviews.js +97 -12
  18. package/src/lib/components/product-reviews.js.map +1 -1
  19. package/src/lib/model/commerce.d.ts +11 -2
  20. package/src/lib/model/commerce.js +14 -2
  21. package/src/lib/model/commerce.js.map +1 -1
  22. package/src/lib/model/index.d.ts +1 -0
  23. package/src/lib/model/index.js +1 -0
  24. package/src/lib/model/index.js.map +1 -1
  25. package/src/lib/model/product-reviews.d.ts +124 -0
  26. package/src/lib/model/product-reviews.js +133 -0
  27. package/src/lib/model/product-reviews.js.map +1 -0
  28. package/src/lib/model/store-setup-content.d.ts +145 -0
  29. package/src/lib/model/store-setup-content.js +813 -0
  30. package/src/lib/model/store-setup-content.js.map +1 -0
  31. package/src/lib/server/membership-reset.js +8 -2
  32. package/src/lib/server/membership-reset.js.map +1 -1
  33. package/src/lib/server/order-event-triggers.d.ts +2 -0
  34. package/src/lib/server/order-event-triggers.js +18 -1
  35. package/src/lib/server/order-event-triggers.js.map +1 -1
  36. package/src/lib/server/review-requests.d.ts +102 -0
  37. package/src/lib/server/review-requests.js +346 -0
  38. package/src/lib/server/review-requests.js.map +1 -0
  39. package/src/lib/server/review-tokens.d.ts +82 -0
  40. package/src/lib/server/review-tokens.js +119 -0
  41. package/src/lib/server/review-tokens.js.map +1 -0
  42. package/src/lib/server/reviews.d.ts +36 -3
  43. package/src/lib/server/reviews.js +310 -52
  44. package/src/lib/server/reviews.js.map +1 -1
  45. package/src/lib/server/store-setup-ai-capability.d.ts +61 -0
  46. package/src/lib/server/store-setup-ai-capability.js +343 -0
  47. package/src/lib/server/store-setup-ai-capability.js.map +1 -0
  48. package/src/lib/server/store-setup-ai-writer.console.d.ts +27 -0
  49. package/src/lib/server/store-setup-ai-writer.console.js +44 -0
  50. package/src/lib/server/store-setup-ai-writer.console.js.map +1 -0
  51. package/src/lib/server/store-setup-ai-writer.d.ts +37 -0
  52. package/src/lib/server/store-setup-ai-writer.js +174 -0
  53. package/src/lib/server/store-setup-ai-writer.js.map +1 -0
  54. package/src/lib/server.js +27 -0
  55. package/src/lib/server.js.map +1 -1
  56. package/src/lib/tenant-emails.js +58 -0
  57. package/src/lib/tenant-emails.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/review-settings-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport {\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport * as CommerceModel from '../../model'\n\nexport interface ReviewSettingsCardProps {\n hostId: string\n}\n\n/**\n * Who may review the store's products, and the review-request email\n * (AGL-3736), on `hosts/{hostId}/settings/store.reviews`. Each control writes\n * only its own key, so this card never overwrites the store settings card's\n * fields beside it. Defaults: verified buyers only, the email on, 7 days.\n */\nexport function ReviewSettingsCard(props: ReviewSettingsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { data: store } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'settings', 'store'),\n [firestore, hostId],\n )\n const settings = CommerceModel.reviewSettingsFrom(store)\n const [days, setDays] = useState(String(settings.requestDelayDays))\n useEffect(() => setDays(String(settings.requestDelayDays)), [settings.requestDelayDays])\n const [saving, setSaving] = useState<string | null>(null)\n\n const save = useCallback(\n async (key: 'policy' | 'requestEmail' | 'requestDelayDays', value: unknown) => {\n setSaving(key)\n try {\n // The policy decides what the product page's review form draws, so\n // the write carries the site's cache drop (AGL-3386).\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.set(\n doc(firestore, 'hosts', hostId, 'settings', 'store'),\n { reviews: { [key]: value } },\n { merge: true },\n ),\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('That setting could not be saved. Try again.', { variant: 'error' })\n } finally {\n setSaving(null)\n }\n },\n [enqueueSnackbar, firestore, hostId, user],\n )\n\n const commitDays = () => {\n const parsed = Math.round(Number(days))\n const bounded = Number.isFinite(parsed)\n ? Math.min(\n CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS,\n Math.max(CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS, parsed),\n )\n : CommerceModel.REVIEW_REQUEST_DELAY_DEFAULT_DAYS\n setDays(String(bounded))\n if (bounded !== settings.requestDelayDays) void save('requestDelayDays', bounded)\n }\n\n return (\n <CardDisplay\n header={'Reviews'}\n help={pluginDocsHelp('catalog', { anchor: '#review-settings' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <TextField\n label=\"Who can review\"\n value={settings.policy}\n onChange={(event) => void save('policy', event.target.value)}\n disabled={saving === 'policy'}\n size=\"small\"\n select\n sx={{ maxWidth: 360 }}\n helperText={\n settings.policy === 'verified'\n ? 'Customers who bought the product: signed in to their account, or ' +\n 'through the link in their review-request email. Their reviews ' +\n 'publish right away with a \"Verified buyer\" badge.'\n : 'Anyone may review. Customers who bought the product still get the ' +\n '\"Verified buyer\" badge and publish right away; everyone else gives a ' +\n 'name and email (never shown) and waits for your approval.'\n }\n >\n <MenuItem value=\"verified\">{'Verified buyers only (recommended)'}</MenuItem>\n <MenuItem value=\"anyone\">{'Anyone'}</MenuItem>\n </TextField>\n <FormControlLabel\n control={\n <Switch\n size=\"small\"\n checked={settings.requestEmail}\n disabled={saving === 'requestEmail'}\n onChange={(change) => void save('requestEmail', change.target.checked)}\n />\n }\n label={\n <Stack>\n <Typography variant=\"body2\">{'Ask customers for a review'}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One email per order, after it is fulfilled, with a link for each ' +\n 'product. Each link works once, for 60 days. Change its wording ' +\n 'under Emails → Review request.'}\n </Typography>\n </Stack>\n }\n />\n <TextField\n label=\"Send the request (days after fulfillment)\"\n value={days}\n onChange={(event) => setDays(event.target.value)}\n onBlur={commitDays}\n disabled={!settings.requestEmail || saving === 'requestDelayDays'}\n size=\"small\"\n type=\"number\"\n slotProps={{\n htmlInput: {\n min: CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS,\n max: CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS,\n },\n }}\n sx={{ maxWidth: 280 }}\n helperText={`Between ${CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS} and ${CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS} days. Give the order time to arrive.`}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Verified reviews are published as written, good or bad. You can hide ' +\n 'or reject one only for spam, abuse, being off-topic or sharing ' +\n 'private information.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nReviewSettingsCard.displayName = 'ReviewSettingsCard'\n\nexport default ReviewSettingsCard\n"],"names":["pluginDocsHelp","CardDisplay","useSnackbar","useFirestore","useFirestoreDoc","useUser","writeSiteWideChange","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","doc","useCallback","useEffect","useState","CommerceModel","ReviewSettingsCard","props","hostId","firestore","data","user","enqueueSnackbar","store","settings","reviewSettingsFrom","days","setDays","String","requestDelayDays","saving","setSaving","save","key","value","write","batch","set","reviews","merge","error","console","variant","commitDays","parsed","Math","round","Number","bounded","isFinite","min","REVIEW_REQUEST_DELAY_MAX_DAYS","max","REVIEW_REQUEST_DELAY_MIN_DAYS","REVIEW_REQUEST_DELAY_DEFAULT_DAYS","header","help","anchor","contentGutterX","contentGutterY","spacing","label","policy","onChange","event","target","disabled","size","select","sx","maxWidth","helperText","control","checked","requestEmail","change","color","onBlur","type","slotProps","htmlInput","displayName"],"mappings":"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"}
@@ -1,6 +1,20 @@
1
+ import { type ReviewModerationReason } from '../../model/product-reviews';
1
2
  export interface ReviewsModerationCardProps {
2
3
  hostId: string;
3
4
  }
5
+ /**
6
+ * The status a "Hide" writes (AGL-3736): a published review is hidden, a
7
+ * pending one is rejected. Either way a content reason goes with it.
8
+ */
9
+ export declare function hiddenStatusFor(review: {
10
+ status?: string;
11
+ }): 'hidden' | 'rejected';
12
+ /**
13
+ * The fields a moderation decision writes. Exactly the keys the Firestore
14
+ * rules let a site editor change on a review: never `verified`, the rating
15
+ * or the text. Approving clears the reason; hiding records it.
16
+ */
17
+ export declare function moderationUpdate(status: 'approved' | 'hidden' | 'rejected', reason: ReviewModerationReason | null, nowMs: number): Record<string, unknown>;
4
18
  export declare function ReviewsModerationCard(props: ReviewsModerationCardProps): import("react").JSX.Element;
5
19
  export declare namespace ReviewsModerationCard {
6
20
  var displayName: string;
@@ -26,16 +26,39 @@ import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helper
26
26
  import { usePagedCollection } from "@aglyn/tenant-feature-instance";
27
27
  import { EntitlementGatedCard } from "./entitlement-gate.component.js";
28
28
  import { pluginDocsHelp } from "@aglyn/aglyn";
29
+ import { REVIEW_MODERATION_REASONS, REVIEW_MODERATION_REASON_LABELS } from "../../model/product-reviews.js";
29
30
  /**
30
- * Review moderation (AGL-324): pending queue with approve/reject and
31
- * inline seller replies; approved reviews render on the product-reviews
31
+ * The status a "Hide" writes (AGL-3736): a published review is hidden, a
32
+ * pending one is rejected. Either way a content reason goes with it.
33
+ */ export function hiddenStatusFor(review) {
34
+ return review.status === 'pending' ? 'rejected' : 'hidden';
35
+ }
36
+ /**
37
+ * The fields a moderation decision writes. Exactly the keys the Firestore
38
+ * rules let a site editor change on a review: never `verified`, the rating
39
+ * or the text. Approving clears the reason; hiding records it.
40
+ */ export function moderationUpdate(status, reason, nowMs) {
41
+ return {
42
+ status,
43
+ moderationReason: status === 'approved' ? null : reason,
44
+ moderatedAtMs: nowMs
45
+ };
46
+ }
47
+ /**
48
+ * Review moderation (AGL-324, AGL-3736): the queue of reviews waiting for
49
+ * approval (unverified ones, under the store's "anyone" policy), with
50
+ * approve, hide-for-a-reason and inline seller replies. Verified reviews
51
+ * publish on their own; the merchant may hide one only for a content reason
52
+ * (spam, abuse, off topic, private information) — the FTC's 2024 rule bars
53
+ * suppressing an honest review for being negative — and the reason is kept.
54
+ * Seller replies; approved reviews render on the product-reviews
32
55
  * block with the aggregate in JSON-LD.
33
56
  *
34
57
  * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces
35
58
  * it, and this card rendered identically on Free — an operator could work a
36
59
  * moderation queue whose approvals the renderer would never honour.
37
60
  */ const reviewsHelp = pluginDocsHelp('catalog', {
38
- anchor: '#product-reviews'
61
+ anchor: '#review-moderation'
39
62
  });
40
63
  export function ReviewsModerationCard(props) {
41
64
  const { hostId } = props;
@@ -69,15 +92,15 @@ export function ReviewsModerationCard(props) {
69
92
  });
70
93
  const [replyFor, setReplyFor] = useState(null);
71
94
  const [reply, setReply] = useState('');
95
+ const [hideFor, setHideFor] = useState(null);
96
+ const [hideReason, setHideReason] = useState('spam');
72
97
  // An approved review is on the product page — its star aggregate and its
73
98
  // reply — so moderating one carries the site's cache drop (AGL-3386).
74
- const setStatus = (review, status)=>writeSiteWideChange({
99
+ const setStatus = (review, status, reason = null)=>writeSiteWideChange({
75
100
  firestore,
76
101
  user,
77
102
  hostId,
78
- write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
79
- status
80
- })
103
+ write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), moderationUpdate(status, reason, Date.now()))
81
104
  });
82
105
  return /*#__PURE__*/ _jsx(EntitlementGatedCard, {
83
106
  help: reviewsHelp,
@@ -109,7 +132,11 @@ export function ReviewsModerationCard(props) {
109
132
  }),
110
133
  /*#__PURE__*/ _jsx(MenuItem, {
111
134
  value: "approved",
112
- children: 'Approved'
135
+ children: 'Published'
136
+ }),
137
+ /*#__PURE__*/ _jsx(MenuItem, {
138
+ value: "hidden",
139
+ children: 'Hidden'
113
140
  }),
114
141
  /*#__PURE__*/ _jsx(MenuItem, {
115
142
  value: "rejected",
@@ -160,6 +187,11 @@ export function ReviewsModerationCard(props) {
160
187
  })
161
188
  ]
162
189
  }),
190
+ review.moderationReason && review.moderationReason in REVIEW_MODERATION_REASON_LABELS ? /*#__PURE__*/ _jsx(Typography, {
191
+ variant: "caption",
192
+ color: "text.secondary",
193
+ children: `Hidden: ${REVIEW_MODERATION_REASON_LABELS[review.moderationReason]}`
194
+ }) : null,
163
195
  /*#__PURE__*/ _jsx(Typography, {
164
196
  variant: "body2",
165
197
  color: "text.secondary",
@@ -174,11 +206,14 @@ export function ReviewsModerationCard(props) {
174
206
  onClick: ()=>setStatus(review, 'approved'),
175
207
  children: 'Approve'
176
208
  }) : null,
177
- review.status !== 'rejected' ? /*#__PURE__*/ _jsx(Button, {
209
+ review.status !== 'rejected' && review.status !== 'hidden' ? /*#__PURE__*/ _jsx(Button, {
178
210
  size: "small",
179
211
  color: "error",
180
- onClick: ()=>setStatus(review, 'rejected'),
181
- children: 'Reject'
212
+ onClick: ()=>{
213
+ setHideFor(review.$id);
214
+ setHideReason('spam');
215
+ },
216
+ children: review.status === 'pending' ? 'Reject…' : 'Hide…'
182
217
  }) : null,
183
218
  /*#__PURE__*/ _jsx(Button, {
184
219
  size: "small",
@@ -191,6 +226,45 @@ export function ReviewsModerationCard(props) {
191
226
  })
192
227
  ]
193
228
  }),
229
+ hideFor === review.$id ? /*#__PURE__*/ _jsxs(Stack, {
230
+ direction: "row",
231
+ spacing: 1,
232
+ sx: {
233
+ alignItems: 'center'
234
+ },
235
+ children: [
236
+ /*#__PURE__*/ _jsx(TextField, {
237
+ label: "Reason",
238
+ value: hideReason,
239
+ onChange: (event)=>setHideReason(event.target.value),
240
+ size: "small",
241
+ select: true,
242
+ sx: {
243
+ minWidth: 220
244
+ },
245
+ helperText: "Reviews can't be hidden for being negative.",
246
+ children: REVIEW_MODERATION_REASONS.map((reason)=>/*#__PURE__*/ _jsx(MenuItem, {
247
+ value: reason,
248
+ children: REVIEW_MODERATION_REASON_LABELS[reason]
249
+ }, reason))
250
+ }),
251
+ /*#__PURE__*/ _jsx(Button, {
252
+ size: "small",
253
+ variant: "contained",
254
+ color: "error",
255
+ onClick: async ()=>{
256
+ await setStatus(review, hiddenStatusFor(review), hideReason);
257
+ setHideFor(null);
258
+ },
259
+ children: review.status === 'pending' ? 'Reject' : 'Hide'
260
+ }),
261
+ /*#__PURE__*/ _jsx(Button, {
262
+ size: "small",
263
+ onClick: ()=>setHideFor(null),
264
+ children: 'Cancel'
265
+ })
266
+ ]
267
+ }) : null,
194
268
  replyFor === review.$id ? /*#__PURE__*/ _jsxs(Stack, {
195
269
  direction: "row",
196
270
  spacing: 1,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/reviews-moderation-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n MenuItem,\n Rating,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, doc, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport { usePagedCollection } from '@aglyn/tenant-feature-instance'\nimport { EntitlementGatedCard } from './entitlement-gate.component'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\n\nexport interface ReviewsModerationCardProps {\n hostId: string\n}\n\n/**\n * Review moderation (AGL-324): pending queue with approve/reject and\n * inline seller replies; approved reviews render on the product-reviews\n * block with the aggregate in JSON-LD.\n *\n * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces\n * it, and this card rendered identically on Free — an operator could work a\n * moderation queue whose approvals the renderer would never honour.\n */\n\nconst reviewsHelp = pluginDocsHelp('catalog', { anchor: '#product-reviews' })\n\nexport function ReviewsModerationCard(props: ReviewsModerationCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const [filter, setFilter] = useState('pending')\n /*\n * The status filter is the QUERY, and the order is the server's.\n *\n * This read `limit(200)` with no `orderBy` and then filtered and sorted in\n * the browser. A limited Firestore query with no ordering returns\n * DOCUMENT-ID order, and reviews are written with `.add()`, so those two\n * hundred were a pseudo-random sample — \"Pending\" showed whichever pending\n * reviews happened to fall in it, which on a busy shop is not the queue and\n * cannot be worked through.\n *\n * Filtering here rather than in the browser is what lets the window shrink:\n * a page of 25 mixed reviews might hold three pending ones, and a card that\n * rendered those three would call it a page while the queue sat behind it.\n * `reviews(status ASC, createdAtMs DESC)` is in\n * cloud/firebase-firestore.indexes.json for exactly this.\n */\n const {\n rows: reviews,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'reviews'),\n ...(filter === 'all' ? [] : [where('status', '==', filter)]),\n orderBy('createdAtMs', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId, filter],\n { idField: '$id' },\n )\n const [replyFor, setReplyFor] = useState<string | null>(null)\n const [reply, setReply] = useState('')\n\n // An approved review is on the product page — its star aggregate and its\n // reply — so moderating one carries the site's cache drop (AGL-3386).\n const setStatus = (review: any, status: string) =>\n writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {\n status,\n }),\n })\n\n return (\n <EntitlementGatedCard\n help={reviewsHelp}\n hostId={hostId}\n feature=\"productReviews\"\n header={'Review moderation'}\n upsell={\n 'Verified-buyer reviews let customers rate what they bought and ' +\n 'show the star aggregate on your product pages and in search ' +\n 'results.'\n }\n >\n <CardDisplay\n header={'Review moderation'}\n help={reviewsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <TextField\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value)}\n size=\"small\"\n select\n sx={{ maxWidth: 180 }}\n >\n <MenuItem value=\"pending\">{'Pending'}</MenuItem>\n <MenuItem value=\"approved\">{'Approved'}</MenuItem>\n <MenuItem value=\"rejected\">{'Rejected'}</MenuItem>\n <MenuItem value=\"all\">{'All'}</MenuItem>\n </TextField>\n {reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing to moderate.'}\n </Typography>\n ) : (\n reviews.map((review: any) => (\n <Stack key={review.$id} spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Rating value={review.rating} size=\"small\" readOnly />\n <Typography variant=\"body2\" sx={{ fontWeight: 600 }}>\n {review.authorName}\n </Typography>\n {review.verified ? (\n <Chip label=\"Verified\" size=\"small\" color=\"success\" variant=\"outlined\" />\n ) : null}\n <Chip label={review.status} size=\"small\" variant=\"outlined\" />\n </Stack>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {review.body}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n {review.status !== 'approved' ? (\n <Button size=\"small\" onClick={() => setStatus(review, 'approved')}>\n {'Approve'}\n </Button>\n ) : null}\n {review.status !== 'rejected' ? (\n <Button\n size=\"small\"\n color=\"error\"\n onClick={() => setStatus(review, 'rejected')}\n >\n {'Reject'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n onClick={() => {\n setReplyFor(review.$id)\n setReply(review.reply ?? '')\n }}\n >\n {review.reply ? 'Edit reply' : 'Reply'}\n </Button>\n </Stack>\n {replyFor === review.$id ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n value={reply}\n onChange={(event) => setReply(event.target.value)}\n size=\"small\"\n sx={{ flex: 1 }}\n placeholder=\"Public seller reply\"\n />\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={async () => {\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(\n doc(firestore, 'hosts', hostId, 'reviews', review.$id),\n { reply: reply.trim().slice(0, 500) || null },\n ),\n })\n setReplyFor(null)\n enqueueSnackbar('Reply saved', {\n variant: 'success',\n persist: false,\n })\n }}\n >\n {'Save'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n ))\n )}\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={reviews.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </Stack>\n </CardDisplay>\n </EntitlementGatedCard>\n )\n}\nReviewsModerationCard.displayName = 'ReviewsModerationCard'\n\nexport default ReviewsModerationCard\n"],"names":["CardDisplay","ListPagination","useSnackbar","Button","Chip","MenuItem","Rating","Stack","TextField","Typography","collection","doc","limit","orderBy","query","where","useState","useFirestore","useUser","writeSiteWideChange","usePagedCollection","EntitlementGatedCard","pluginDocsHelp","reviewsHelp","anchor","ReviewsModerationCard","props","hostId","firestore","data","user","enqueueSnackbar","filter","setFilter","rows","reviews","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","replyFor","setReplyFor","reply","setReply","setStatus","review","status","write","batch","update","$id","help","feature","header","upsell","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","sx","maxWidth","length","variant","color","map","direction","alignItems","rating","readOnly","fontWeight","authorName","verified","body","onClick","flex","placeholder","trim","slice","persist","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,WAAW,QAAQ,uBAAsB;AAClD,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,MAAM,EACNC,IAAI,EACJC,QAAQ,EACRC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,UAAU,QACL,gBAAe;AACtB,SAASC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAClF,SAAkBC,QAAQ,QAAQ,QAAO;AACzC,SAASC,YAAY,EAAEC,OAAO,QAAQ,iCAAgC;AACtE,SAASC,mBAAmB,QAAQ,gEAA+D;AACnG,SAASC,kBAAkB,QAAQ,iCAAgC;AACnE,SAASC,oBAAoB,QAAQ,kCAA8B;AACnE,SAASC,cAAc,QAAQ,eAAc;AAM7C;;;;;;;;CAQC,GAED,MAAMC,cAAcD,eAAe,WAAW;IAAEE,QAAQ;AAAmB;AAE3E,OAAO,SAASC,sBAAsBC,KAAiC;IACrE,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAME,YAAYX;IAClB,MAAM,EAAEY,MAAMC,IAAI,EAAE,GAAGZ;IACvB,MAAM,EAAEa,eAAe,EAAE,GAAG7B;IAC5B,MAAM,CAAC8B,QAAQC,UAAU,GAAGjB,SAAS;IACrC;;;;;;;;;;;;;;;GAeC,GACD,MAAM,EACJkB,MAAMC,OAAO,EACbC,OAAO,EACPC,IAAI,EACJC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACZ,GAAGpB,mBACF,CAACqB,YACC3B,MACEJ,WAAWkB,WAAW,SAASD,QAAQ,eACnCK,WAAW,QAAQ,EAAE,GAAG;YAACjB,MAAM,UAAU,MAAMiB;SAAQ,EAC3DnB,QAAQ,eAAe,SACvBD,MAAM6B,aAEV;QAACb;QAAWD;QAAQK;KAAO,EAC3B;QAAEU,SAAS;IAAM;IAEnB,MAAM,CAACC,UAAUC,YAAY,GAAG5B,SAAwB;IACxD,MAAM,CAAC6B,OAAOC,SAAS,GAAG9B,SAAS;IAEnC,yEAAyE;IACzE,sEAAsE;IACtE,MAAM+B,YAAY,CAACC,QAAaC,SAC9B9B,oBAAoB;YAClBS;YACAE;YACAH;YACAuB,OAAO,CAACC,QACNA,MAAMC,MAAM,CAACzC,IAAIiB,WAAW,SAASD,QAAQ,WAAWqB,OAAOK,GAAG,GAAG;oBACnEJ;gBACF;QACJ;IAEF,qBACE,KAAC5B;QACCiC,MAAM/B;QACNI,QAAQA;QACR4B,SAAQ;QACRC,QAAQ;QACRC,QACE,oEACA,iEACA;kBAGJ,cAAA,KAACzD;YACCwD,QAAQ;YACRF,MAAM/B;YACNmC,cAAc;YACdC,cAAc;sBAEd,cAAA,MAACpD;gBAAMqD,SAAS;;kCACd,MAACpD;wBACCqD,OAAM;wBACNC,OAAO9B;wBACP+B,UAAU,CAACC,QAAU/B,UAAU+B,MAAMC,MAAM,CAACH,KAAK;wBACjDI,MAAK;wBACLC,MAAM;wBACNC,IAAI;4BAAEC,UAAU;wBAAI;;0CAEpB,KAAChE;gCAASyD,OAAM;0CAAW;;0CAC3B,KAACzD;gCAASyD,OAAM;0CAAY;;0CAC5B,KAACzD;gCAASyD,OAAM;0CAAY;;0CAC5B,KAACzD;gCAASyD,OAAM;0CAAO;;;;oBAExB3B,QAAQmC,MAAM,KAAK,kBAClB,KAAC7D;wBAAW8D,SAAQ;wBAAQC,OAAM;kCAC/B;yBAGHrC,QAAQsC,GAAG,CAAC,CAACzB,uBACX,MAACzC;4BAAuBqD,SAAS;;8CAC/B,MAACrD;oCAAMmE,WAAU;oCAAMd,SAAS;oCAAGQ,IAAI;wCAAEO,YAAY;oCAAS;;sDAC5D,KAACrE;4CAAOwD,OAAOd,OAAO4B,MAAM;4CAAEV,MAAK;4CAAQW,QAAQ;;sDACnD,KAACpE;4CAAW8D,SAAQ;4CAAQH,IAAI;gDAAEU,YAAY;4CAAI;sDAC/C9B,OAAO+B,UAAU;;wCAEnB/B,OAAOgC,QAAQ,iBACd,KAAC5E;4CAAKyD,OAAM;4CAAWK,MAAK;4CAAQM,OAAM;4CAAUD,SAAQ;6CAC1D;sDACJ,KAACnE;4CAAKyD,OAAOb,OAAOC,MAAM;4CAAEiB,MAAK;4CAAQK,SAAQ;;;;8CAEnD,KAAC9D;oCAAW8D,SAAQ;oCAAQC,OAAM;8CAC/BxB,OAAOiC,IAAI;;8CAEd,MAAC1E;oCAAMmE,WAAU;oCAAMd,SAAS;;wCAC7BZ,OAAOC,MAAM,KAAK,2BACjB,KAAC9C;4CAAO+D,MAAK;4CAAQgB,SAAS,IAAMnC,UAAUC,QAAQ;sDACnD;6CAED;wCACHA,OAAOC,MAAM,KAAK,2BACjB,KAAC9C;4CACC+D,MAAK;4CACLM,OAAM;4CACNU,SAAS,IAAMnC,UAAUC,QAAQ;sDAEhC;6CAED;sDACJ,KAAC7C;4CACC+D,MAAK;4CACLgB,SAAS;oDAEElC;gDADTJ,YAAYI,OAAOK,GAAG;gDACtBP,UAASE,gBAAAA,OAAOH,KAAK,YAAZG,gBAAgB;4CAC3B;sDAECA,OAAOH,KAAK,GAAG,eAAe;;;;gCAGlCF,aAAaK,OAAOK,GAAG,iBACtB,MAAC9C;oCAAMmE,WAAU;oCAAMd,SAAS;;sDAC9B,KAACpD;4CACCsD,OAAOjB;4CACPkB,UAAU,CAACC,QAAUlB,SAASkB,MAAMC,MAAM,CAACH,KAAK;4CAChDI,MAAK;4CACLE,IAAI;gDAAEe,MAAM;4CAAE;4CACdC,aAAY;;sDAEd,KAACjF;4CACC+D,MAAK;4CACLK,SAAQ;4CACRC,OAAM;4CACNU,SAAS;gDACP,MAAM/D,oBAAoB;oDACxBS;oDACAE;oDACAH;oDACAuB,OAAO,CAACC,QACNA,MAAMC,MAAM,CACVzC,IAAIiB,WAAW,SAASD,QAAQ,WAAWqB,OAAOK,GAAG,GACrD;4DAAER,OAAOA,MAAMwC,IAAI,GAAGC,KAAK,CAAC,GAAG,QAAQ;wDAAK;gDAElD;gDACA1C,YAAY;gDACZb,gBAAgB,eAAe;oDAC7BwC,SAAS;oDACTgB,SAAS;gDACX;4CACF;sDAEC;;;qCAGH;;2BAzEMvC,OAAOK,GAAG;kCA6E1B,KAACpD;wBACCoC,MAAMA;wBACNE,UAAUA;wBACViD,UAAUrD,QAAQmC,MAAM;wBACxBlC,SAASA;wBACTqD,cAAcnD;wBACdoD,kBAAkBlD;;;;;;AAM5B;AACAf,sBAAsBkE,WAAW,GAAG;AAEpC,eAAelE,sBAAqB"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/reviews-moderation-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n MenuItem,\n Rating,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, doc, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport { usePagedCollection } from '@aglyn/tenant-feature-instance'\nimport { EntitlementGatedCard } from './entitlement-gate.component'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n REVIEW_MODERATION_REASONS,\n REVIEW_MODERATION_REASON_LABELS,\n type ReviewModerationReason,\n} from '../../model/product-reviews'\n\nexport interface ReviewsModerationCardProps {\n hostId: string\n}\n\n/**\n * The status a \"Hide\" writes (AGL-3736): a published review is hidden, a\n * pending one is rejected. Either way a content reason goes with it.\n */\nexport function hiddenStatusFor(review: { status?: string }): 'hidden' | 'rejected' {\n return review.status === 'pending' ? 'rejected' : 'hidden'\n}\n\n/**\n * The fields a moderation decision writes. Exactly the keys the Firestore\n * rules let a site editor change on a review: never `verified`, the rating\n * or the text. Approving clears the reason; hiding records it.\n */\nexport function moderationUpdate(\n status: 'approved' | 'hidden' | 'rejected',\n reason: ReviewModerationReason | null,\n nowMs: number,\n): Record<string, unknown> {\n return {\n status,\n moderationReason: status === 'approved' ? null : reason,\n moderatedAtMs: nowMs,\n }\n}\n\n/**\n * Review moderation (AGL-324, AGL-3736): the queue of reviews waiting for\n * approval (unverified ones, under the store's \"anyone\" policy), with\n * approve, hide-for-a-reason and inline seller replies. Verified reviews\n * publish on their own; the merchant may hide one only for a content reason\n * (spam, abuse, off topic, private information) — the FTC's 2024 rule bars\n * suppressing an honest review for being negative — and the reason is kept.\n * Seller replies; approved reviews render on the product-reviews\n * block with the aggregate in JSON-LD.\n *\n * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces\n * it, and this card rendered identically on Free — an operator could work a\n * moderation queue whose approvals the renderer would never honour.\n */\n\nconst reviewsHelp = pluginDocsHelp('catalog', { anchor: '#review-moderation' })\n\nexport function ReviewsModerationCard(props: ReviewsModerationCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const [filter, setFilter] = useState('pending')\n /*\n * The status filter is the QUERY, and the order is the server's.\n *\n * This read `limit(200)` with no `orderBy` and then filtered and sorted in\n * the browser. A limited Firestore query with no ordering returns\n * DOCUMENT-ID order, and reviews are written with `.add()`, so those two\n * hundred were a pseudo-random sample — \"Pending\" showed whichever pending\n * reviews happened to fall in it, which on a busy shop is not the queue and\n * cannot be worked through.\n *\n * Filtering here rather than in the browser is what lets the window shrink:\n * a page of 25 mixed reviews might hold three pending ones, and a card that\n * rendered those three would call it a page while the queue sat behind it.\n * `reviews(status ASC, createdAtMs DESC)` is in\n * cloud/firebase-firestore.indexes.json for exactly this.\n */\n const {\n rows: reviews,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'reviews'),\n ...(filter === 'all' ? [] : [where('status', '==', filter)]),\n orderBy('createdAtMs', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId, filter],\n { idField: '$id' },\n )\n const [replyFor, setReplyFor] = useState<string | null>(null)\n const [reply, setReply] = useState('')\n const [hideFor, setHideFor] = useState<string | null>(null)\n const [hideReason, setHideReason] = useState<ReviewModerationReason>('spam')\n\n // An approved review is on the product page — its star aggregate and its\n // reply — so moderating one carries the site's cache drop (AGL-3386).\n const setStatus = (\n review: any,\n status: 'approved' | 'hidden' | 'rejected',\n reason: ReviewModerationReason | null = null,\n ) =>\n writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(\n doc(firestore, 'hosts', hostId, 'reviews', review.$id),\n moderationUpdate(status, reason, Date.now()),\n ),\n })\n\n return (\n <EntitlementGatedCard\n help={reviewsHelp}\n hostId={hostId}\n feature=\"productReviews\"\n header={'Review moderation'}\n upsell={\n 'Verified-buyer reviews let customers rate what they bought and ' +\n 'show the star aggregate on your product pages and in search ' +\n 'results.'\n }\n >\n <CardDisplay\n header={'Review moderation'}\n help={reviewsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <TextField\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value)}\n size=\"small\"\n select\n sx={{ maxWidth: 180 }}\n >\n <MenuItem value=\"pending\">{'Pending'}</MenuItem>\n <MenuItem value=\"approved\">{'Published'}</MenuItem>\n <MenuItem value=\"hidden\">{'Hidden'}</MenuItem>\n <MenuItem value=\"rejected\">{'Rejected'}</MenuItem>\n <MenuItem value=\"all\">{'All'}</MenuItem>\n </TextField>\n {reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing to moderate.'}\n </Typography>\n ) : (\n reviews.map((review: any) => (\n <Stack key={review.$id} spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Rating value={review.rating} size=\"small\" readOnly />\n <Typography variant=\"body2\" sx={{ fontWeight: 600 }}>\n {review.authorName}\n </Typography>\n {review.verified ? (\n <Chip label=\"Verified\" size=\"small\" color=\"success\" variant=\"outlined\" />\n ) : null}\n <Chip label={review.status} size=\"small\" variant=\"outlined\" />\n </Stack>\n {review.moderationReason &&\n review.moderationReason in REVIEW_MODERATION_REASON_LABELS ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Hidden: ${\n REVIEW_MODERATION_REASON_LABELS[\n review.moderationReason as ReviewModerationReason\n ]\n }`}\n </Typography>\n ) : null}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {review.body}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n {review.status !== 'approved' ? (\n <Button size=\"small\" onClick={() => setStatus(review, 'approved')}>\n {'Approve'}\n </Button>\n ) : null}\n {review.status !== 'rejected' && review.status !== 'hidden' ? (\n <Button\n size=\"small\"\n color=\"error\"\n onClick={() => {\n setHideFor(review.$id)\n setHideReason('spam')\n }}\n >\n {review.status === 'pending' ? 'Reject…' : 'Hide…'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n onClick={() => {\n setReplyFor(review.$id)\n setReply(review.reply ?? '')\n }}\n >\n {review.reply ? 'Edit reply' : 'Reply'}\n </Button>\n </Stack>\n {hideFor === review.$id ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <TextField\n label=\"Reason\"\n value={hideReason}\n onChange={(event) =>\n setHideReason(event.target.value as ReviewModerationReason)\n }\n size=\"small\"\n select\n sx={{ minWidth: 220 }}\n helperText=\"Reviews can't be hidden for being negative.\"\n >\n {REVIEW_MODERATION_REASONS.map((reason) => (\n <MenuItem key={reason} value={reason}>\n {REVIEW_MODERATION_REASON_LABELS[reason]}\n </MenuItem>\n ))}\n </TextField>\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"error\"\n onClick={async () => {\n await setStatus(review, hiddenStatusFor(review), hideReason)\n setHideFor(null)\n }}\n >\n {review.status === 'pending' ? 'Reject' : 'Hide'}\n </Button>\n <Button size=\"small\" onClick={() => setHideFor(null)}>\n {'Cancel'}\n </Button>\n </Stack>\n ) : null}\n {replyFor === review.$id ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n value={reply}\n onChange={(event) => setReply(event.target.value)}\n size=\"small\"\n sx={{ flex: 1 }}\n placeholder=\"Public seller reply\"\n />\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={async () => {\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(\n doc(firestore, 'hosts', hostId, 'reviews', review.$id),\n { reply: reply.trim().slice(0, 500) || null },\n ),\n })\n setReplyFor(null)\n enqueueSnackbar('Reply saved', {\n variant: 'success',\n persist: false,\n })\n }}\n >\n {'Save'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n ))\n )}\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={reviews.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </Stack>\n </CardDisplay>\n </EntitlementGatedCard>\n )\n}\nReviewsModerationCard.displayName = 'ReviewsModerationCard'\n\nexport default ReviewsModerationCard\n"],"names":["CardDisplay","ListPagination","useSnackbar","Button","Chip","MenuItem","Rating","Stack","TextField","Typography","collection","doc","limit","orderBy","query","where","useState","useFirestore","useUser","writeSiteWideChange","usePagedCollection","EntitlementGatedCard","pluginDocsHelp","REVIEW_MODERATION_REASONS","REVIEW_MODERATION_REASON_LABELS","hiddenStatusFor","review","status","moderationUpdate","reason","nowMs","moderationReason","moderatedAtMs","reviewsHelp","anchor","ReviewsModerationCard","props","hostId","firestore","data","user","enqueueSnackbar","filter","setFilter","rows","reviews","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","replyFor","setReplyFor","reply","setReply","hideFor","setHideFor","hideReason","setHideReason","setStatus","write","batch","update","$id","Date","now","help","feature","header","upsell","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","sx","maxWidth","length","variant","color","map","direction","alignItems","rating","readOnly","fontWeight","authorName","verified","body","onClick","minWidth","helperText","flex","placeholder","trim","slice","persist","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
@@ -34,10 +34,38 @@ export interface ProductReviewsProps {
34
34
  /** Store scope: the heading's level, 2–4 (default 2). */
35
35
  headingLevel?: 2 | 3 | 4 | '2' | '3' | '4';
36
36
  }
37
+ /** What `GET /api/commerce/reviews?mode=eligibility` answers (AGL-3736). */
38
+ export interface ReviewFormEligibility {
39
+ enabled?: boolean;
40
+ policy?: 'verified' | 'anyone';
41
+ signedIn?: boolean;
42
+ verifiedVia?: 'member' | 'token' | null;
43
+ canReview?: boolean;
44
+ needsEmail?: boolean;
45
+ tokenProblem?: string;
46
+ }
47
+ /** Where a shopper signs in to their account on the store. */
48
+ export declare const REVIEW_SIGN_IN_HREF = "/account";
49
+ /** The review link from the review-request email, if the page carries one. */
50
+ export declare function reviewTokenFromLocation(search: string): string;
51
+ /**
52
+ * Which form the block draws, from the server's answer. Pure, so the three
53
+ * states are specified in one place:
54
+ * - `form`: the visitor may review (verified or, under `anyone`, pending);
55
+ * - `sign-in`: the store takes verified reviews only and this visitor is
56
+ * not one — "Only customers who bought this can review." and a sign-in
57
+ * link (no form: a typed email verifies nothing);
58
+ * - `none`: reviews are off on this plan, or the answer has not come yet.
59
+ */
60
+ export declare function reviewFormMode(eligibility: ReviewFormEligibility | null): 'form' | 'sign-in' | 'none';
37
61
  /**
38
62
  * Product reviews block (AGL-324): approved reviews with verified-buyer
39
63
  * badges, seller replies, an aggregate summary (emitted as JSON-LD
40
- * AggregateRating), and a submit form feeding the moderation queue.
64
+ * AggregateRating), and a submit form. Since AGL-3736 the form asks the server
65
+ * who the visitor is first: a verified buyer (signed in, or arriving with the
66
+ * review-request email's link) gets the form and publishes at once; anyone
67
+ * else sees "Only customers who bought this can review" and a sign-in link,
68
+ * unless the store accepts reviews from anyone (held for moderation).
41
69
  */
42
70
  declare const ProductReviews: import("react").ForwardRefExoticComponent<ProductReviewsProps & import("react").RefAttributes<HTMLDivElement>>;
43
71
  export declare const schema: Aglyn.ComponentSchema<ProductReviewsProps>;
@@ -40,6 +40,30 @@ const EDITOR_HINT_SX = {
40
40
  fontSize: 13,
41
41
  fontFamily: 'system-ui, sans-serif'
42
42
  };
43
+ /** Where a shopper signs in to their account on the store. */ export const REVIEW_SIGN_IN_HREF = '/account';
44
+ /** The review link from the review-request email, if the page carries one. */ export function reviewTokenFromLocation(search) {
45
+ var _get;
46
+ const token = (_get = new URLSearchParams(search).get('review')) != null ? _get : '';
47
+ return /^[A-Za-z0-9_-]{32}$/.test(token) ? token : '';
48
+ }
49
+ /**
50
+ * Which form the block draws, from the server's answer. Pure, so the three
51
+ * states are specified in one place:
52
+ * - `form`: the visitor may review (verified or, under `anyone`, pending);
53
+ * - `sign-in`: the store takes verified reviews only and this visitor is
54
+ * not one — "Only customers who bought this can review." and a sign-in
55
+ * link (no form: a typed email verifies nothing);
56
+ * - `none`: reviews are off on this plan, or the answer has not come yet.
57
+ */ export function reviewFormMode(eligibility) {
58
+ if (!eligibility || eligibility.enabled === false) return 'none';
59
+ if (eligibility.canReview) return 'form';
60
+ return 'sign-in';
61
+ }
62
+ const TOKEN_PROBLEM_TEXT = {
63
+ review_token_used: 'This review link has already been used.',
64
+ review_token_expired: 'This review link has expired.',
65
+ review_token_invalid: 'This review link is not valid for this product.'
66
+ };
43
67
  async function resolveProductIdFromSlug(hostId) {
44
68
  var _ref;
45
69
  var _payload_product;
@@ -53,7 +77,11 @@ async function resolveProductIdFromSlug(hostId) {
53
77
  /**
54
78
  * Product reviews block (AGL-324): approved reviews with verified-buyer
55
79
  * badges, seller replies, an aggregate summary (emitted as JSON-LD
56
- * AggregateRating), and a submit form feeding the moderation queue.
80
+ * AggregateRating), and a submit form. Since AGL-3736 the form asks the server
81
+ * who the visitor is first: a verified buyer (signed in, or arriving with the
82
+ * review-request email's link) gets the form and publishes at once; anyone
83
+ * else sees "Only customers who bought this can review" and a sign-in link,
84
+ * unless the store accepts reviews from anyone (held for moderation).
57
85
  */ const ProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
58
86
  if (props.scope === 'store') return /*#__PURE__*/ _jsx(StoreReviews, _extends({
59
87
  ref: ref
@@ -287,6 +315,10 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
287
315
  authorEmail: ''
288
316
  });
289
317
  const [state, setState] = useState('idle');
318
+ const [eligibility, setEligibility] = useState(null);
319
+ const [reviewToken, setReviewToken] = useState('');
320
+ const [submitted, setSubmitted] = useState(null);
321
+ const [errorText, setErrorText] = useState('');
290
322
  useEffect(()=>{
291
323
  if (!hostId) return;
292
324
  let active = true;
@@ -303,6 +335,14 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
303
335
  count: 0,
304
336
  average: 0
305
337
  });
338
+ // May this visitor review, and would it be verified? (AGL-3736)
339
+ const token = reviewTokenFromLocation(window.location.search);
340
+ setReviewToken(token);
341
+ const answer = await fetch(`/api/commerce/reviews?hostId=${encodeURIComponent(hostId)}` + `&productId=${encodeURIComponent(resolved)}&mode=eligibility` + (token ? `&review=${encodeURIComponent(token)}` : ''), {
342
+ credentials: 'same-origin'
343
+ }).catch(()=>null);
344
+ if (!active || !(answer == null ? void 0 : answer.ok)) return;
345
+ setEligibility(await answer.json().catch(()=>null));
306
346
  })();
307
347
  return ()=>{
308
348
  active = false;
@@ -322,10 +362,27 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
322
362
  },
323
363
  body: JSON.stringify(_extends({
324
364
  hostId,
325
- productId
326
- }, form))
365
+ productId,
366
+ rating: form.rating,
367
+ body: form.body,
368
+ authorName: form.authorName
369
+ }, (eligibility == null ? void 0 : eligibility.needsEmail) ? {
370
+ authorEmail: form.authorEmail
371
+ } : {}, reviewToken ? {
372
+ reviewToken
373
+ } : {}))
327
374
  });
328
- setState(response.ok ? 'done' : 'error');
375
+ const payload = await response.json().catch(()=>({}));
376
+ if (response.ok) {
377
+ setSubmitted({
378
+ pending: Boolean(payload == null ? void 0 : payload.pending)
379
+ });
380
+ setState('done');
381
+ } else {
382
+ var _ref;
383
+ setErrorText(String((_ref = payload == null ? void 0 : payload.error) != null ? _ref : ''));
384
+ setState('error');
385
+ }
329
386
  } catch (unused) {
330
387
  setState('error');
331
388
  }
@@ -420,10 +477,29 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
420
477
  color: "text.secondary",
421
478
  children: 'No reviews yet — be the first.'
422
479
  }) : null,
480
+ (eligibility == null ? void 0 : eligibility.tokenProblem) && TOKEN_PROBLEM_TEXT[eligibility.tokenProblem] ? /*#__PURE__*/ _jsx(Alert, {
481
+ severity: "info",
482
+ children: TOKEN_PROBLEM_TEXT[eligibility.tokenProblem]
483
+ }) : null,
423
484
  state === 'done' ? /*#__PURE__*/ _jsx(Alert, {
424
485
  severity: "success",
425
- children: 'Thanks — your review is awaiting moderation.'
426
- }) : /*#__PURE__*/ _jsxs(Box, {
486
+ children: (submitted == null ? void 0 : submitted.pending) ? 'Thanks — your review is awaiting moderation.' : 'Thanks — your review is posted.'
487
+ }) : reviewFormMode(eligibility) === 'sign-in' ? /*#__PURE__*/ _jsxs(Typography, {
488
+ variant: "body2",
489
+ color: "text.secondary",
490
+ "data-review-sign-in": "",
491
+ children: [
492
+ 'Only customers who bought this can review. ',
493
+ /*#__PURE__*/ _jsx(Box, {
494
+ component: "a",
495
+ href: REVIEW_SIGN_IN_HREF,
496
+ sx: {
497
+ color: 'primary.main'
498
+ },
499
+ children: 'Sign in'
500
+ })
501
+ ]
502
+ }) : reviewFormMode(eligibility) === 'none' ? null : /*#__PURE__*/ _jsxs(Box, {
427
503
  sx: {
428
504
  display: 'flex',
429
505
  flexDirection: 'column',
@@ -435,6 +511,15 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
435
511
  variant: "subtitle2",
436
512
  children: 'Write a review'
437
513
  }),
514
+ (eligibility == null ? void 0 : eligibility.verifiedVia) ? /*#__PURE__*/ _jsx(Chip, {
515
+ label: "Your review will show as Verified buyer",
516
+ size: "small",
517
+ variant: "outlined",
518
+ color: "success",
519
+ sx: {
520
+ alignSelf: 'flex-start'
521
+ }
522
+ }) : null,
438
523
  /*#__PURE__*/ _jsx(Rating, {
439
524
  value: form.rating,
440
525
  onChange: (_event, value)=>setForm((prev)=>_extends({}, prev, {
@@ -468,8 +553,8 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
468
553
  flex: 1
469
554
  }
470
555
  }),
471
- /*#__PURE__*/ _jsx(TextField, {
472
- placeholder: "Email (verifies your purchase)",
556
+ (eligibility == null ? void 0 : eligibility.needsEmail) ? /*#__PURE__*/ _jsx(TextField, {
557
+ placeholder: "Email (never shown)",
473
558
  type: "email",
474
559
  value: form.authorEmail,
475
560
  onChange: (event)=>setForm((prev)=>_extends({}, prev, {
@@ -479,17 +564,17 @@ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
479
564
  sx: {
480
565
  flex: 1.4
481
566
  }
482
- })
567
+ }) : null
483
568
  ]
484
569
  }),
485
570
  state === 'error' ? /*#__PURE__*/ _jsx(Alert, {
486
571
  severity: "error",
487
- children: 'Could not submit — check the fields.'
572
+ children: errorText || 'Could not submit — check the fields.'
488
573
  }) : null,
489
574
  /*#__PURE__*/ _jsx(Button, {
490
575
  variant: "outlined",
491
576
  size: "small",
492
- disabled: state === 'busy' || !form.body.trim() || !form.authorEmail.trim(),
577
+ disabled: state === 'busy' || !form.body.trim() || Boolean((eligibility == null ? void 0 : eligibility.needsEmail) && !form.authorEmail.trim()),
493
578
  onClick: handleSubmit,
494
579
  sx: {
495
580
  alignSelf: 'flex-start'
@@ -506,7 +591,7 @@ export const schema = {
506
591
  $id: ID,
507
592
  pluginId: BUNDLE_ID,
508
593
  displayName: 'Product reviews',
509
- description: 'Approved reviews with verified-buyer badges, and a form to add one.',
594
+ description: 'Approved reviews with verified-buyer badges, and a form for customers ' + 'who bought the product to add one.',
510
595
  category: Aglyn.ComponentCategory.COMMERCE,
511
596
  icon: {
512
597
  path: mdiStarOutline.path,