@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.
- package/README.md +4 -1
- package/package.json +13 -13
- package/src/lib/components/commerce-console-page.js +10 -0
- package/src/lib/components/commerce-console-page.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +7 -1
- package/src/lib/components/console/product-zones.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +3 -2
- package/src/lib/components/console/products-hub-zone.component.js +9 -1
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/review-settings-card.component.d.ts +14 -0
- package/src/lib/components/console/review-settings-card.component.js +168 -0
- package/src/lib/components/console/review-settings-card.component.js.map +1 -0
- package/src/lib/components/console/reviews-moderation-card.component.d.ts +14 -0
- package/src/lib/components/console/reviews-moderation-card.component.js +85 -11
- package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -1
- package/src/lib/components/product-reviews.d.ts +29 -1
- package/src/lib/components/product-reviews.js +97 -12
- package/src/lib/components/product-reviews.js.map +1 -1
- package/src/lib/model/commerce.d.ts +11 -2
- package/src/lib/model/commerce.js +14 -2
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/product-reviews.d.ts +124 -0
- package/src/lib/model/product-reviews.js +133 -0
- package/src/lib/model/product-reviews.js.map +1 -0
- package/src/lib/model/store-setup-content.d.ts +145 -0
- package/src/lib/model/store-setup-content.js +813 -0
- package/src/lib/model/store-setup-content.js.map +1 -0
- package/src/lib/server/membership-reset.js +8 -2
- package/src/lib/server/membership-reset.js.map +1 -1
- package/src/lib/server/order-event-triggers.d.ts +2 -0
- package/src/lib/server/order-event-triggers.js +18 -1
- package/src/lib/server/order-event-triggers.js.map +1 -1
- package/src/lib/server/review-requests.d.ts +102 -0
- package/src/lib/server/review-requests.js +346 -0
- package/src/lib/server/review-requests.js.map +1 -0
- package/src/lib/server/review-tokens.d.ts +82 -0
- package/src/lib/server/review-tokens.js +119 -0
- package/src/lib/server/review-tokens.js.map +1 -0
- package/src/lib/server/reviews.d.ts +36 -3
- package/src/lib/server/reviews.js +310 -52
- package/src/lib/server/reviews.js.map +1 -1
- package/src/lib/server/store-setup-ai-capability.d.ts +61 -0
- package/src/lib/server/store-setup-ai-capability.js +343 -0
- package/src/lib/server/store-setup-ai-capability.js.map +1 -0
- package/src/lib/server/store-setup-ai-writer.console.d.ts +27 -0
- package/src/lib/server/store-setup-ai-writer.console.js +44 -0
- package/src/lib/server/store-setup-ai-writer.console.js.map +1 -0
- package/src/lib/server/store-setup-ai-writer.d.ts +37 -0
- package/src/lib/server/store-setup-ai-writer.js +174 -0
- package/src/lib/server/store-setup-ai-writer.js.map +1 -0
- package/src/lib/server.js +27 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +58 -0
- 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
|
-
*
|
|
31
|
-
*
|
|
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: '#
|
|
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: '
|
|
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: ()=>
|
|
181
|
-
|
|
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":"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"}
|
|
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":"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;AAC7C,SACEC,yBAAyB,EACzBC,+BAA+B,QAE1B,iCAA6B;AAMpC;;;CAGC,GACD,OAAO,SAASC,gBAAgBC,MAA2B;IACzD,OAAOA,OAAOC,MAAM,KAAK,YAAY,aAAa;AACpD;AAEA;;;;CAIC,GACD,OAAO,SAASC,iBACdD,MAA0C,EAC1CE,MAAqC,EACrCC,KAAa;IAEb,OAAO;QACLH;QACAI,kBAAkBJ,WAAW,aAAa,OAAOE;QACjDG,eAAeF;IACjB;AACF;AAEA;;;;;;;;;;;;;CAaC,GAED,MAAMG,cAAcX,eAAe,WAAW;IAAEY,QAAQ;AAAqB;AAE7E,OAAO,SAASC,sBAAsBC,KAAiC;IACrE,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAME,YAAYrB;IAClB,MAAM,EAAEsB,MAAMC,IAAI,EAAE,GAAGtB;IACvB,MAAM,EAAEuB,eAAe,EAAE,GAAGvC;IAC5B,MAAM,CAACwC,QAAQC,UAAU,GAAG3B,SAAS;IACrC;;;;;;;;;;;;;;;GAeC,GACD,MAAM,EACJ4B,MAAMC,OAAO,EACbC,OAAO,EACPC,IAAI,EACJC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACZ,GAAG9B,mBACF,CAAC+B,YACCrC,MACEJ,WAAW4B,WAAW,SAASD,QAAQ,eACnCK,WAAW,QAAQ,EAAE,GAAG;YAAC3B,MAAM,UAAU,MAAM2B;SAAQ,EAC3D7B,QAAQ,eAAe,SACvBD,MAAMuC,aAEV;QAACb;QAAWD;QAAQK;KAAO,EAC3B;QAAEU,SAAS;IAAM;IAEnB,MAAM,CAACC,UAAUC,YAAY,GAAGtC,SAAwB;IACxD,MAAM,CAACuC,OAAOC,SAAS,GAAGxC,SAAS;IACnC,MAAM,CAACyC,SAASC,WAAW,GAAG1C,SAAwB;IACtD,MAAM,CAAC2C,YAAYC,cAAc,GAAG5C,SAAiC;IAErE,yEAAyE;IACzE,sEAAsE;IACtE,MAAM6C,YAAY,CAChBnC,QACAC,QACAE,SAAwC,IAAI,GAE5CV,oBAAoB;YAClBmB;YACAE;YACAH;YACAyB,OAAO,CAACC,QACNA,MAAMC,MAAM,CACVrD,IAAI2B,WAAW,SAASD,QAAQ,WAAWX,OAAOuC,GAAG,GACrDrC,iBAAiBD,QAAQE,QAAQqC,KAAKC,GAAG;QAE/C;IAEF,qBACE,KAAC9C;QACC+C,MAAMnC;QACNI,QAAQA;QACRgC,SAAQ;QACRC,QAAQ;QACRC,QACE,oEACA,iEACA;kBAGJ,cAAA,KAACvE;YACCsE,QAAQ;YACRF,MAAMnC;YACNuC,cAAc;YACdC,cAAc;sBAEd,cAAA,MAAClE;gBAAMmE,SAAS;;kCACd,MAAClE;wBACCmE,OAAM;wBACNC,OAAOlC;wBACPmC,UAAU,CAACC,QAAUnC,UAAUmC,MAAMC,MAAM,CAACH,KAAK;wBACjDI,MAAK;wBACLC,MAAM;wBACNC,IAAI;4BAAEC,UAAU;wBAAI;;0CAEpB,KAAC9E;gCAASuE,OAAM;0CAAW;;0CAC3B,KAACvE;gCAASuE,OAAM;0CAAY;;0CAC5B,KAACvE;gCAASuE,OAAM;0CAAU;;0CAC1B,KAACvE;gCAASuE,OAAM;0CAAY;;0CAC5B,KAACvE;gCAASuE,OAAM;0CAAO;;;;oBAExB/B,QAAQuC,MAAM,KAAK,kBAClB,KAAC3E;wBAAW4E,SAAQ;wBAAQC,OAAM;kCAC/B;yBAGHzC,QAAQ0C,GAAG,CAAC,CAAC7D,uBACX,MAACnB;4BAAuBmE,SAAS;;8CAC/B,MAACnE;oCAAMiF,WAAU;oCAAMd,SAAS;oCAAGQ,IAAI;wCAAEO,YAAY;oCAAS;;sDAC5D,KAACnF;4CAAOsE,OAAOlD,OAAOgE,MAAM;4CAAEV,MAAK;4CAAQW,QAAQ;;sDACnD,KAAClF;4CAAW4E,SAAQ;4CAAQH,IAAI;gDAAEU,YAAY;4CAAI;sDAC/ClE,OAAOmE,UAAU;;wCAEnBnE,OAAOoE,QAAQ,iBACd,KAAC1F;4CAAKuE,OAAM;4CAAWK,MAAK;4CAAQM,OAAM;4CAAUD,SAAQ;6CAC1D;sDACJ,KAACjF;4CAAKuE,OAAOjD,OAAOC,MAAM;4CAAEqD,MAAK;4CAAQK,SAAQ;;;;gCAElD3D,OAAOK,gBAAgB,IACxBL,OAAOK,gBAAgB,IAAIP,gDACzB,KAACf;oCAAW4E,SAAQ;oCAAUC,OAAM;8CACjC,CAAC,QAAQ,EACR9D,+BAA+B,CAC7BE,OAAOK,gBAAgB,CACxB,EACD;qCAEF;8CACJ,KAACtB;oCAAW4E,SAAQ;oCAAQC,OAAM;8CAC/B5D,OAAOqE,IAAI;;8CAEd,MAACxF;oCAAMiF,WAAU;oCAAMd,SAAS;;wCAC7BhD,OAAOC,MAAM,KAAK,2BACjB,KAACxB;4CAAO6E,MAAK;4CAAQgB,SAAS,IAAMnC,UAAUnC,QAAQ;sDACnD;6CAED;wCACHA,OAAOC,MAAM,KAAK,cAAcD,OAAOC,MAAM,KAAK,yBACjD,KAACxB;4CACC6E,MAAK;4CACLM,OAAM;4CACNU,SAAS;gDACPtC,WAAWhC,OAAOuC,GAAG;gDACrBL,cAAc;4CAChB;sDAEClC,OAAOC,MAAM,KAAK,YAAY,YAAY;6CAE3C;sDACJ,KAACxB;4CACC6E,MAAK;4CACLgB,SAAS;oDAEEtE;gDADT4B,YAAY5B,OAAOuC,GAAG;gDACtBT,UAAS9B,gBAAAA,OAAO6B,KAAK,YAAZ7B,gBAAgB;4CAC3B;sDAECA,OAAO6B,KAAK,GAAG,eAAe;;;;gCAGlCE,YAAY/B,OAAOuC,GAAG,iBACrB,MAAC1D;oCAAMiF,WAAU;oCAAMd,SAAS;oCAAGQ,IAAI;wCAAEO,YAAY;oCAAS;;sDAC5D,KAACjF;4CACCmE,OAAM;4CACNC,OAAOjB;4CACPkB,UAAU,CAACC,QACTlB,cAAckB,MAAMC,MAAM,CAACH,KAAK;4CAElCI,MAAK;4CACLC,MAAM;4CACNC,IAAI;gDAAEe,UAAU;4CAAI;4CACpBC,YAAW;sDAEV3E,0BAA0BgE,GAAG,CAAC,CAAC1D,uBAC9B,KAACxB;oDAAsBuE,OAAO/C;8DAC3BL,+BAA+B,CAACK,OAAO;mDAD3BA;;sDAKnB,KAAC1B;4CACC6E,MAAK;4CACLK,SAAQ;4CACRC,OAAM;4CACNU,SAAS;gDACP,MAAMnC,UAAUnC,QAAQD,gBAAgBC,SAASiC;gDACjDD,WAAW;4CACb;sDAEChC,OAAOC,MAAM,KAAK,YAAY,WAAW;;sDAE5C,KAACxB;4CAAO6E,MAAK;4CAAQgB,SAAS,IAAMtC,WAAW;sDAC5C;;;qCAGH;gCACHL,aAAa3B,OAAOuC,GAAG,iBACtB,MAAC1D;oCAAMiF,WAAU;oCAAMd,SAAS;;sDAC9B,KAAClE;4CACCoE,OAAOrB;4CACPsB,UAAU,CAACC,QAAUtB,SAASsB,MAAMC,MAAM,CAACH,KAAK;4CAChDI,MAAK;4CACLE,IAAI;gDAAEiB,MAAM;4CAAE;4CACdC,aAAY;;sDAEd,KAACjG;4CACC6E,MAAK;4CACLK,SAAQ;4CACRC,OAAM;4CACNU,SAAS;gDACP,MAAM7E,oBAAoB;oDACxBmB;oDACAE;oDACAH;oDACAyB,OAAO,CAACC,QACNA,MAAMC,MAAM,CACVrD,IAAI2B,WAAW,SAASD,QAAQ,WAAWX,OAAOuC,GAAG,GACrD;4DAAEV,OAAOA,MAAM8C,IAAI,GAAGC,KAAK,CAAC,GAAG,QAAQ;wDAAK;gDAElD;gDACAhD,YAAY;gDACZb,gBAAgB,eAAe;oDAC7B4C,SAAS;oDACTkB,SAAS;gDACX;4CACF;sDAEC;;;qCAGH;;2BAzHM7E,OAAOuC,GAAG;kCA6H1B,KAAChE;wBACC8C,MAAMA;wBACNE,UAAUA;wBACVuD,UAAU3D,QAAQuC,MAAM;wBACxBtC,SAASA;wBACT2D,cAAczD;wBACd0D,kBAAkBxD;;;;;;AAM5B;AACAf,sBAAsBwE,WAAW,GAAG;AAEpC,eAAexE,sBAAqB"}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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,
|