@aglyn/plugins-commerce 1.0.0-beta.237 → 1.0.0-beta.238

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 (34) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/cart.d.ts +2 -1
  3. package/src/lib/components/cart.js +2 -1
  4. package/src/lib/components/cart.js.map +1 -1
  5. package/src/lib/components/product-detail.d.ts +76 -0
  6. package/src/lib/components/product-detail.js +258 -52
  7. package/src/lib/components/product-detail.js.map +1 -1
  8. package/src/lib/components/product-grid.d.ts +35 -0
  9. package/src/lib/components/product-grid.js +207 -79
  10. package/src/lib/components/product-grid.js.map +1 -1
  11. package/src/lib/components/product-image-placeholder.d.ts +31 -0
  12. package/src/lib/components/product-image-placeholder.js +72 -0
  13. package/src/lib/components/product-image-placeholder.js.map +1 -0
  14. package/src/lib/components/product-reviews.d.ts +13 -0
  15. package/src/lib/components/product-reviews.js +267 -13
  16. package/src/lib/components/product-reviews.js.map +1 -1
  17. package/src/lib/components/related-products.d.ts +6 -0
  18. package/src/lib/components/related-products.js +73 -20
  19. package/src/lib/components/related-products.js.map +1 -1
  20. package/src/lib/constants/cart-events.d.ts +23 -0
  21. package/src/lib/constants/cart-events.js +23 -0
  22. package/src/lib/constants/cart-events.js.map +1 -0
  23. package/src/lib/server/catalog.d.ts +14 -0
  24. package/src/lib/server/catalog.js +25 -5
  25. package/src/lib/server/catalog.js.map +1 -1
  26. package/src/lib/server/reviews.d.ts +35 -0
  27. package/src/lib/server/reviews.js +120 -1
  28. package/src/lib/server/reviews.js.map +1 -1
  29. package/src/lib/server/site-page-resolver.d.ts +15 -3
  30. package/src/lib/server/site-page-resolver.js +78 -24
  31. package/src/lib/server/site-page-resolver.js.map +1 -1
  32. package/src/lib/utils/add-to-cart.d.ts +35 -0
  33. package/src/lib/utils/add-to-cart.js +53 -0
  34. package/src/lib/utils/add-to-cart.js.map +1 -0
@@ -31,6 +31,15 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
31
31
  import { generatePresetId } from "../utils/generate-preset-id.js";
32
32
  // Component ids are persisted in screen documents; never rename.
33
33
  export const ID = 'product-reviews';
34
+ const EDITOR_HINT_SX = {
35
+ p: 3,
36
+ border: '1px dashed',
37
+ borderColor: 'divider',
38
+ borderRadius: 1,
39
+ color: 'text.secondary',
40
+ fontSize: 13,
41
+ fontFamily: 'system-ui, sans-serif'
42
+ };
34
43
  async function resolveProductIdFromSlug(hostId) {
35
44
  var _ref;
36
45
  var _payload_product;
@@ -46,9 +55,218 @@ async function resolveProductIdFromSlug(hostId) {
46
55
  * badges, seller replies, an aggregate summary (emitted as JSON-LD
47
56
  * AggregateRating), and a submit form feeding the moderation queue.
48
57
  */ const ProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
49
- const { productId: productIdProp, heading } = props, rest = _object_without_properties_loose(props, [
58
+ if (props.scope === 'store') return /*#__PURE__*/ _jsx(StoreReviews, _extends({
59
+ ref: ref
60
+ }, props));
61
+ return /*#__PURE__*/ _jsx(SingleProductReviews, _extends({
62
+ ref: ref
63
+ }, props));
64
+ });
65
+ ProductReviews.displayName = 'AglynProductReviews';
66
+ /**
67
+ * The store-wide reviews band (`scope: 'store'`): the store's latest APPROVED
68
+ * reviews, read-only stars, the reviewer's first name and initial, a verified
69
+ * badge, and the product reviewed. Real reviews or nothing: with none it
70
+ * renders an empty, zero-height element — never sample reviews on a live page
71
+ * (sample testimonials presented as customers' would be fake reviews). The
72
+ * editor, which has no site, gets a dashed hint instead.
73
+ */ const StoreReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
74
+ const { productId: _productId, heading, scope: _scope, maxItems, headingLevel } = props, rest = _object_without_properties_loose(props, [
75
+ "productId",
76
+ "heading",
77
+ "scope",
78
+ "maxItems",
79
+ "headingLevel"
80
+ ]);
81
+ const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [
82
+ props['sx']
83
+ ];
84
+ const { hostId } = Aglyn.useSite();
85
+ const [payload, setPayload] = useState(null);
86
+ const limit = maxItems && Number(maxItems) > 0 ? Math.min(12, Math.floor(Number(maxItems))) : 6;
87
+ useEffect(()=>{
88
+ if (!hostId) return;
89
+ let active = true;
90
+ void fetch(`/api/commerce/reviews?hostId=${encodeURIComponent(hostId)}` + `&scope=store&limit=${limit}`).then((response)=>response.ok ? response.json() : null).then((body)=>{
91
+ var _body_aggregate;
92
+ if (!active || !body) return;
93
+ setPayload({
94
+ reviews: Array.isArray(body.reviews) ? body.reviews : [],
95
+ aggregate: (_body_aggregate = body.aggregate) != null ? _body_aggregate : {
96
+ count: 0,
97
+ average: 0
98
+ }
99
+ });
100
+ }).catch(()=>undefined);
101
+ return ()=>{
102
+ active = false;
103
+ };
104
+ }, [
105
+ hostId,
106
+ limit
107
+ ]);
108
+ if (!hostId) {
109
+ return /*#__PURE__*/ _jsx(Box, _extends({
110
+ ref: ref
111
+ }, rest, {
112
+ sx: [
113
+ EDITOR_HINT_SX,
114
+ ...nodeSx
115
+ ],
116
+ children: '★★★★★ Customer reviews appear here once shoppers review your products'
117
+ }));
118
+ }
119
+ // Loading, failed, or none approved: nothing at all — not the node's
120
+ // padding, not a heading over an empty band.
121
+ if (!payload || payload.reviews.length === 0) {
122
+ return /*#__PURE__*/ _jsx(Box, {
123
+ ref: ref,
124
+ "data-store-reviews-empty": "",
125
+ sx: {
126
+ display: 'none'
127
+ }
128
+ });
129
+ }
130
+ // A select may store the level as a string; read it as a number.
131
+ const asked = Number(headingLevel);
132
+ const level = asked === 3 || asked === 4 ? asked : 2;
133
+ const { aggregate } = payload;
134
+ return /*#__PURE__*/ _jsxs(Box, _extends({
135
+ ref: ref
136
+ }, rest, {
137
+ sx: [
138
+ {
139
+ display: 'flex',
140
+ flexDirection: 'column',
141
+ gap: 3
142
+ },
143
+ ...nodeSx
144
+ ],
145
+ children: [
146
+ /*#__PURE__*/ _jsxs(Box, {
147
+ sx: {
148
+ display: 'flex',
149
+ flexDirection: 'column',
150
+ alignItems: 'center',
151
+ gap: 1,
152
+ textAlign: 'center'
153
+ },
154
+ children: [
155
+ /*#__PURE__*/ _jsx(Typography, {
156
+ variant: level === 2 ? 'h4' : level === 3 ? 'h5' : 'h6',
157
+ component: `h${level}`,
158
+ children: heading || 'What customers say'
159
+ }),
160
+ aggregate.count > 0 ? /*#__PURE__*/ _jsxs(Box, {
161
+ sx: {
162
+ display: 'flex',
163
+ alignItems: 'center',
164
+ gap: 1
165
+ },
166
+ children: [
167
+ /*#__PURE__*/ _jsx(Rating, {
168
+ value: aggregate.average,
169
+ precision: 0.1,
170
+ readOnly: true,
171
+ size: "small"
172
+ }),
173
+ /*#__PURE__*/ _jsx(Typography, {
174
+ variant: "body2",
175
+ color: "text.secondary",
176
+ children: `${aggregate.average} out of 5 · ${aggregate.count} ${aggregate.count === 1 ? 'review' : 'reviews'}`
177
+ })
178
+ ]
179
+ }) : null
180
+ ]
181
+ }),
182
+ /*#__PURE__*/ _jsx(Box, {
183
+ sx: {
184
+ display: 'grid',
185
+ gap: 2,
186
+ gridTemplateColumns: {
187
+ xs: '1fr',
188
+ sm: 'repeat(2, 1fr)',
189
+ md: 'repeat(3, 1fr)'
190
+ }
191
+ },
192
+ children: payload.reviews.map((review)=>/*#__PURE__*/ _jsxs(Box, {
193
+ component: "figure",
194
+ sx: {
195
+ m: 0,
196
+ p: 2.5,
197
+ display: 'flex',
198
+ flexDirection: 'column',
199
+ gap: 1,
200
+ border: 1,
201
+ borderColor: 'divider',
202
+ borderRadius: 2,
203
+ bgcolor: 'background.paper'
204
+ },
205
+ children: [
206
+ /*#__PURE__*/ _jsx(Rating, {
207
+ value: review.rating,
208
+ readOnly: true,
209
+ size: "small"
210
+ }),
211
+ /*#__PURE__*/ _jsx(Typography, {
212
+ component: "blockquote",
213
+ variant: "body1",
214
+ sx: {
215
+ m: 0,
216
+ flex: 1
217
+ },
218
+ children: review.body
219
+ }),
220
+ /*#__PURE__*/ _jsxs(Box, {
221
+ component: "figcaption",
222
+ sx: {
223
+ display: 'flex',
224
+ flexWrap: 'wrap',
225
+ gap: 1,
226
+ alignItems: 'center'
227
+ },
228
+ children: [
229
+ /*#__PURE__*/ _jsx(Typography, {
230
+ variant: "body2",
231
+ sx: {
232
+ fontWeight: 600
233
+ },
234
+ children: review.authorName
235
+ }),
236
+ review.verified ? /*#__PURE__*/ _jsx(Chip, {
237
+ label: "Verified buyer",
238
+ size: "small",
239
+ variant: "outlined",
240
+ color: "success"
241
+ }) : null
242
+ ]
243
+ }),
244
+ review.productName ? /*#__PURE__*/ _jsx(Typography, _extends({
245
+ variant: "caption",
246
+ color: "text.secondary",
247
+ component: review.productSlug ? 'a' : 'span'
248
+ }, review.productSlug ? {
249
+ href: `/products/${review.productSlug}`
250
+ } : {}, {
251
+ sx: {
252
+ textDecoration: 'none'
253
+ },
254
+ children: review.productName
255
+ })) : null
256
+ ]
257
+ }, review.id))
258
+ })
259
+ ]
260
+ }));
261
+ });
262
+ StoreReviews.displayName = 'AglynStoreReviews';
263
+ const SingleProductReviews = /*#__PURE__*/ forwardRef((props, ref)=>{
264
+ const { productId: productIdProp, heading, scope: _scope, maxItems: _maxItems, headingLevel: _headingLevel } = props, rest = _object_without_properties_loose(props, [
50
265
  "productId",
51
- "heading"
266
+ "heading",
267
+ "scope",
268
+ "maxItems",
269
+ "headingLevel"
52
270
  ]);
53
271
  // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).
54
272
  const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [
@@ -117,15 +335,7 @@ async function resolveProductIdFromSlug(hostId) {
117
335
  ref: ref
118
336
  }, rest, {
119
337
  sx: [
120
- {
121
- p: 3,
122
- border: '1px dashed',
123
- borderColor: 'divider',
124
- borderRadius: 1,
125
- color: 'text.secondary',
126
- fontSize: 13,
127
- fontFamily: 'system-ui, sans-serif'
128
- },
338
+ EDITOR_HINT_SX,
129
339
  ...nodeSx
130
340
  ],
131
341
  children: '★★★★★ Product reviews render here'
@@ -291,7 +501,7 @@ async function resolveProductIdFromSlug(hostId) {
291
501
  ]
292
502
  }));
293
503
  });
294
- ProductReviews.displayName = 'AglynProductReviews';
504
+ SingleProductReviews.displayName = 'AglynSingleProductReviews';
295
505
  export const schema = {
296
506
  $id: ID,
297
507
  pluginId: BUNDLE_ID,
@@ -317,8 +527,52 @@ export const schema = {
317
527
  {
318
528
  name: 'heading',
319
529
  label: 'Heading',
320
- description: 'Defaults to "Reviews".',
530
+ description: 'Defaults to "Reviews" ("What customers say" store-wide).',
321
531
  component: Aglyn.FieldComponentType.TEXT_FIELD
532
+ },
533
+ {
534
+ // Default `product`, so a page that already places this block keeps it.
535
+ name: 'scope',
536
+ label: 'Show',
537
+ description: 'One product’s reviews with a form, or the store’s latest approved ' + 'reviews across every product. Store-wide shows nothing until a ' + 'shopper’s review is approved.',
538
+ component: Aglyn.FieldComponentType.SELECT,
539
+ options: [
540
+ {
541
+ label: 'This product',
542
+ value: 'product'
543
+ },
544
+ {
545
+ label: 'Whole store',
546
+ value: 'store'
547
+ }
548
+ ]
549
+ },
550
+ {
551
+ name: 'maxItems',
552
+ label: 'Max reviews',
553
+ description: 'Store-wide: how many to show (default 6, at most 12).',
554
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
555
+ type: 'number'
556
+ },
557
+ {
558
+ name: 'headingLevel',
559
+ label: 'Heading level',
560
+ description: 'Store-wide: the heading’s level on the page (default H2).',
561
+ component: Aglyn.FieldComponentType.SELECT,
562
+ options: [
563
+ {
564
+ label: 'H2',
565
+ value: '2'
566
+ },
567
+ {
568
+ label: 'H3',
569
+ value: '3'
570
+ },
571
+ {
572
+ label: 'H4',
573
+ value: '4'
574
+ }
575
+ ]
322
576
  }
323
577
  ]
324
578
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/components/product-reviews.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\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiStarOutline } from '@aglyn/shared-data-mdi'\nimport Alert from '@mui/material/Alert'\nimport Box from '@mui/material/Box'\nimport Button from '@mui/material/Button'\nimport Chip from '@mui/material/Chip'\nimport Divider from '@mui/material/Divider'\nimport Rating from '@mui/material/Rating'\nimport TextField from '@mui/material/TextField'\nimport Typography from '@mui/material/Typography'\nimport { forwardRef, useEffect, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'product-reviews'\n\nexport interface ProductReviewsProps {\n /** Product id; blank resolves from the /products/{slug} page product. */\n productId?: string\n heading?: string\n}\n\ninterface ReviewView {\n id: string\n rating: number\n body: string\n authorName: string\n verified: boolean\n reply?: string | null\n createdAtMs: number\n}\n\nasync function resolveProductIdFromSlug(hostId: string): Promise<string> {\n const match = window.location.pathname.match(/\\/products\\/([^/?#]+)/)\n if (!match) return ''\n const response = await fetch(\n `/api/commerce/product?hostId=${encodeURIComponent(hostId)}` +\n `&slug=${encodeURIComponent(decodeURIComponent(match[1]))}`,\n ).catch(() => null)\n if (!response?.ok) return ''\n const payload = await response.json().catch(() => ({}))\n return String(payload?.product?.id ?? '')\n}\n\n/**\n * Product reviews block (AGL-324): approved reviews with verified-buyer\n * badges, seller replies, an aggregate summary (emitted as JSON-LD\n * AggregateRating), and a submit form feeding the moderation queue.\n */\nconst ProductReviews = forwardRef<HTMLDivElement, ProductReviewsProps>(\n (props, ref) => {\n const { productId: productIdProp, heading, ...rest } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const siteFetch = Aglyn.useSiteFetch()\n const [productId, setProductId] = useState(productIdProp ?? '')\n const [reviews, setReviews] = useState<ReviewView[] | null>(null)\n const [aggregate, setAggregate] = useState({ count: 0, average: 0 })\n const [form, setForm] = useState({\n rating: 5,\n body: '',\n authorName: '',\n authorEmail: '',\n })\n const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>(\n 'idle',\n )\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void (async () => {\n const resolved =\n productIdProp || (await resolveProductIdFromSlug(hostId))\n if (!active || !resolved) return\n setProductId(resolved)\n const response = await fetch(\n `/api/commerce/reviews?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(resolved)}`,\n ).catch(() => null)\n if (!active || !response?.ok) return\n const payload = await response.json().catch(() => ({}))\n setReviews(payload?.reviews ?? [])\n setAggregate(payload?.aggregate ?? { count: 0, average: 0 })\n })()\n return () => {\n active = false\n }\n }, [hostId, productIdProp])\n\n const handleSubmit = async () => {\n if (!hostId || !productId || state === 'busy') return\n setState('busy')\n try {\n const response = await siteFetch('/api/commerce/reviews', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, productId, ...form }),\n })\n setState(response.ok ? 'done' : 'error')\n } catch {\n setState('error')\n }\n }\n\n if (!hostId) {\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[\n {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'★★★★★ Product reviews render here'}\n </Box>\n )\n }\n\n return (\n <Box ref={ref} {...rest} sx={[{ display: 'flex', flexDirection: 'column', gap: 1.5 }, ...nodeSx]}>\n {/* No JSON-LD here any more (AGL-686). This block used to emit a\n free-standing `AggregateRating` node, which schema.org ignores —\n a rating has to be a PROPERTY of the Product it rates. The PDP's\n server-rendered Product node now carries `aggregateRating`,\n built from the same `readProductReviews` reader this block's API\n uses, so the structured data and the visible stars cannot\n disagree. It also removes the one call site that was primed to\n become stored XSS the moment a reviewer name went into it. */}\n <Typography variant=\"h6\">\n {heading || 'Reviews'}\n {aggregate.count > 0 ? ` (${aggregate.average} ★ · ${aggregate.count})` : ''}\n </Typography>\n {(reviews ?? []).map((review) => (\n <Box key={review.id}>\n <Box sx={{ display: 'flex', gap: 1, 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 buyer\" size=\"small\" variant=\"outlined\" color=\"success\" />\n ) : null}\n </Box>\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n {review.body}\n </Typography>\n {review.reply ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ display: 'block', pl: 2, mt: 0.5, borderLeft: 2, borderColor: 'divider' }}\n >\n {`Seller: ${review.reply}`}\n </Typography>\n ) : null}\n <Divider sx={{ mt: 1.5 }} />\n </Box>\n ))}\n {reviews && reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No reviews yet — be the first.'}\n </Typography>\n ) : null}\n {state === 'done' ? (\n <Alert severity=\"success\">\n {'Thanks — your review is awaiting moderation.'}\n </Alert>\n ) : (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, maxWidth: 480 }}>\n <Typography variant=\"subtitle2\">{'Write a review'}</Typography>\n <Rating\n value={form.rating}\n onChange={(_event, value) =>\n setForm((prev) => ({ ...prev, rating: value ?? 5 }))\n }\n />\n <TextField\n placeholder=\"What did you think?\"\n value={form.body}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, body: event.target.value }))\n }\n size=\"small\"\n multiline\n minRows={2}\n />\n <Box sx={{ display: 'flex', gap: 1 }}>\n <TextField\n placeholder=\"Name\"\n value={form.authorName}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, authorName: event.target.value }))\n }\n size=\"small\"\n sx={{ flex: 1 }}\n />\n <TextField\n placeholder=\"Email (verifies your purchase)\"\n type=\"email\"\n value={form.authorEmail}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, authorEmail: event.target.value }))\n }\n size=\"small\"\n sx={{ flex: 1.4 }}\n />\n </Box>\n {state === 'error' ? (\n <Alert severity=\"error\">{'Could not submit — check the fields.'}</Alert>\n ) : null}\n <Button\n variant=\"outlined\"\n size=\"small\"\n disabled={state === 'busy' || !form.body.trim() || !form.authorEmail.trim()}\n onClick={handleSubmit}\n sx={{ alignSelf: 'flex-start' }}\n >\n {'Submit review'}\n </Button>\n </Box>\n )}\n </Box>\n )\n },\n)\nProductReviews.displayName = 'AglynProductReviews'\n\nexport const schema: Aglyn.ComponentSchema<ProductReviewsProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Product reviews',\n description:\n 'Approved reviews with verified-buyer badges, and a form to add one.',\n category: Aglyn.ComponentCategory.COMMERCE,\n icon: { path: mdiStarOutline.path, sx: { color: '#2e7d32' } },\n flags: { selfClosing: Aglyn.FEATURE_FLAG.ENABLED },\n attributes: [\n {\n name: 'productId',\n label: 'Product id',\n description:\n 'Whose reviews these are. Blank reads the product from the URL, ' +\n 'which is what you want on a product template; naming one pins the ' +\n 'block to that product wherever it is placed.',\n component: Aglyn.FieldComponentType.PRODUCT_SELECT,\n },\n {\n name: 'heading',\n label: 'Heading',\n description: 'Defaults to \"Reviews\".',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Product reviews',\n pluginId: BUNDLE_ID,\n description: 'Verified-buyer reviews with a submit form',\n category: Aglyn.ComponentCategory.COMMERCE,\n icon: { path: mdiStarOutline.path, sx: { color: '#2e7d32' } },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default ProductReviews\n"],"names":["Aglyn","mdiStarOutline","Alert","Box","Button","Chip","Divider","Rating","TextField","Typography","forwardRef","useEffect","useState","BUNDLE_ID","generatePresetId","ID","resolveProductIdFromSlug","hostId","payload","match","window","location","pathname","response","fetch","encodeURIComponent","decodeURIComponent","catch","ok","json","String","product","id","ProductReviews","props","ref","productId","productIdProp","heading","rest","nodeSx","Array","isArray","useSite","siteFetch","useSiteFetch","setProductId","reviews","setReviews","aggregate","setAggregate","count","average","form","setForm","rating","body","authorName","authorEmail","state","setState","active","resolved","handleSubmit","method","headers","JSON","stringify","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","display","flexDirection","gap","variant","map","review","alignItems","value","size","readOnly","fontWeight","verified","label","mt","reply","pl","borderLeft","length","severity","maxWidth","onChange","_event","prev","placeholder","event","target","multiline","minRows","flex","type","disabled","trim","onClick","alignSelf","displayName","schema","$id","pluginId","description","category","ComponentCategory","COMMERCE","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","name","component","FieldComponentType","PRODUCT_SELECT","TEXT_FIELD","presets","NodeType","PRESET","data","componentId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/components/product-reviews.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\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiStarOutline } from '@aglyn/shared-data-mdi'\nimport Alert from '@mui/material/Alert'\nimport Box from '@mui/material/Box'\nimport Button from '@mui/material/Button'\nimport Chip from '@mui/material/Chip'\nimport Divider from '@mui/material/Divider'\nimport Rating from '@mui/material/Rating'\nimport TextField from '@mui/material/TextField'\nimport Typography from '@mui/material/Typography'\nimport { forwardRef, useEffect, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'product-reviews'\n\nexport interface ProductReviewsProps {\n /** Product id; blank resolves from the /products/{slug} page product. */\n productId?: string\n heading?: string\n /**\n * `product` (default): one product's reviews with the submit form.\n * `store`: the store's latest approved reviews across every product, with\n * the average and count, and no form — for a storefront home. In store\n * scope the block draws its own heading (`heading`, default \"What\n * customers say\") and draws NOTHING when there are no approved reviews,\n * so a page never shows an empty reviews band.\n */\n scope?: 'product' | 'store'\n /** Store scope: how many reviews to show (default 6, at most 12). */\n maxItems?: number\n /** Store scope: the heading's level, 2–4 (default 2). */\n headingLevel?: 2 | 3 | 4 | '2' | '3' | '4'\n}\n\ninterface StoreReviewView {\n id: string\n rating: number\n body: string\n authorName: string\n verified: boolean\n createdAtMs: number\n productName?: string\n productSlug?: string\n}\n\nconst EDITOR_HINT_SX = {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n} as const\n\ninterface ReviewView {\n id: string\n rating: number\n body: string\n authorName: string\n verified: boolean\n reply?: string | null\n createdAtMs: number\n}\n\nasync function resolveProductIdFromSlug(hostId: string): Promise<string> {\n const match = window.location.pathname.match(/\\/products\\/([^/?#]+)/)\n if (!match) return ''\n const response = await fetch(\n `/api/commerce/product?hostId=${encodeURIComponent(hostId)}` +\n `&slug=${encodeURIComponent(decodeURIComponent(match[1]))}`,\n ).catch(() => null)\n if (!response?.ok) return ''\n const payload = await response.json().catch(() => ({}))\n return String(payload?.product?.id ?? '')\n}\n\n/**\n * Product reviews block (AGL-324): approved reviews with verified-buyer\n * badges, seller replies, an aggregate summary (emitted as JSON-LD\n * AggregateRating), and a submit form feeding the moderation queue.\n */\nconst ProductReviews = forwardRef<HTMLDivElement, ProductReviewsProps>(\n (props, ref) => {\n if (props.scope === 'store') return <StoreReviews ref={ref} {...props} />\n return <SingleProductReviews ref={ref} {...props} />\n },\n)\nProductReviews.displayName = 'AglynProductReviews'\n\n/**\n * The store-wide reviews band (`scope: 'store'`): the store's latest APPROVED\n * reviews, read-only stars, the reviewer's first name and initial, a verified\n * badge, and the product reviewed. Real reviews or nothing: with none it\n * renders an empty, zero-height element — never sample reviews on a live page\n * (sample testimonials presented as customers' would be fake reviews). The\n * editor, which has no site, gets a dashed hint instead.\n */\nconst StoreReviews = forwardRef<HTMLDivElement, ProductReviewsProps>(\n (props, ref) => {\n const {\n productId: _productId,\n heading,\n scope: _scope,\n maxItems,\n headingLevel,\n ...rest\n } = props\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const [payload, setPayload] = useState<{\n reviews: StoreReviewView[]\n aggregate: { count: number; average: number }\n } | null>(null)\n const limit = maxItems && Number(maxItems) > 0 ? Math.min(12, Math.floor(Number(maxItems))) : 6\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void fetch(\n `/api/commerce/reviews?hostId=${encodeURIComponent(hostId)}` +\n `&scope=store&limit=${limit}`,\n )\n .then((response) => (response.ok ? response.json() : null))\n .then((body) => {\n if (!active || !body) return\n setPayload({\n reviews: Array.isArray(body.reviews) ? body.reviews : [],\n aggregate: body.aggregate ?? { count: 0, average: 0 },\n })\n })\n .catch(() => undefined)\n return () => {\n active = false\n }\n }, [hostId, limit])\n\n if (!hostId) {\n return (\n <Box ref={ref} {...rest} sx={[EDITOR_HINT_SX, ...nodeSx]}>\n {'★★★★★ Customer reviews appear here once shoppers review your products'}\n </Box>\n )\n }\n // Loading, failed, or none approved: nothing at all — not the node's\n // padding, not a heading over an empty band.\n if (!payload || payload.reviews.length === 0) {\n return <Box ref={ref} data-store-reviews-empty=\"\" sx={{ display: 'none' }} />\n }\n // A select may store the level as a string; read it as a number.\n const asked = Number(headingLevel)\n const level = asked === 3 || asked === 4 ? asked : 2\n const { aggregate } = payload\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[{ display: 'flex', flexDirection: 'column', gap: 3 }, ...nodeSx]}\n >\n <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, textAlign: 'center' }}>\n <Typography variant={level === 2 ? 'h4' : level === 3 ? 'h5' : 'h6'} component={`h${level}`}>\n {heading || 'What customers say'}\n </Typography>\n {aggregate.count > 0 ? (\n <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>\n <Rating value={aggregate.average} precision={0.1} readOnly size=\"small\" />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`${aggregate.average} out of 5 · ${aggregate.count} ${aggregate.count === 1 ? 'review' : 'reviews'}`}\n </Typography>\n </Box>\n ) : null}\n </Box>\n <Box\n sx={{\n display: 'grid',\n gap: 2,\n gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },\n }}\n >\n {payload.reviews.map((review) => (\n <Box\n key={review.id}\n component=\"figure\"\n sx={{\n m: 0,\n p: 2.5,\n display: 'flex',\n flexDirection: 'column',\n gap: 1,\n border: 1,\n borderColor: 'divider',\n borderRadius: 2,\n bgcolor: 'background.paper',\n }}\n >\n <Rating value={review.rating} readOnly size=\"small\" />\n <Typography component=\"blockquote\" variant=\"body1\" sx={{ m: 0, flex: 1 }}>\n {review.body}\n </Typography>\n <Box component=\"figcaption\" sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>\n <Typography variant=\"body2\" sx={{ fontWeight: 600 }}>\n {review.authorName}\n </Typography>\n {review.verified ? (\n <Chip label=\"Verified buyer\" size=\"small\" variant=\"outlined\" color=\"success\" />\n ) : null}\n </Box>\n {review.productName ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n component={review.productSlug ? 'a' : 'span'}\n {...(review.productSlug ? { href: `/products/${review.productSlug}` } : {})}\n sx={{ textDecoration: 'none' }}\n >\n {review.productName}\n </Typography>\n ) : null}\n </Box>\n ))}\n </Box>\n </Box>\n )\n },\n)\nStoreReviews.displayName = 'AglynStoreReviews'\n\nconst SingleProductReviews = forwardRef<HTMLDivElement, ProductReviewsProps>(\n (props, ref) => {\n const {\n productId: productIdProp,\n heading,\n scope: _scope,\n maxItems: _maxItems,\n headingLevel: _headingLevel,\n ...rest\n } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const siteFetch = Aglyn.useSiteFetch()\n const [productId, setProductId] = useState(productIdProp ?? '')\n const [reviews, setReviews] = useState<ReviewView[] | null>(null)\n const [aggregate, setAggregate] = useState({ count: 0, average: 0 })\n const [form, setForm] = useState({\n rating: 5,\n body: '',\n authorName: '',\n authorEmail: '',\n })\n const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>(\n 'idle',\n )\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void (async () => {\n const resolved =\n productIdProp || (await resolveProductIdFromSlug(hostId))\n if (!active || !resolved) return\n setProductId(resolved)\n const response = await fetch(\n `/api/commerce/reviews?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(resolved)}`,\n ).catch(() => null)\n if (!active || !response?.ok) return\n const payload = await response.json().catch(() => ({}))\n setReviews(payload?.reviews ?? [])\n setAggregate(payload?.aggregate ?? { count: 0, average: 0 })\n })()\n return () => {\n active = false\n }\n }, [hostId, productIdProp])\n\n const handleSubmit = async () => {\n if (!hostId || !productId || state === 'busy') return\n setState('busy')\n try {\n const response = await siteFetch('/api/commerce/reviews', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, productId, ...form }),\n })\n setState(response.ok ? 'done' : 'error')\n } catch {\n setState('error')\n }\n }\n\n if (!hostId) {\n return (\n <Box ref={ref} {...rest} sx={[EDITOR_HINT_SX, ...nodeSx]}>\n {'★★★★★ Product reviews render here'}\n </Box>\n )\n }\n\n return (\n <Box ref={ref} {...rest} sx={[{ display: 'flex', flexDirection: 'column', gap: 1.5 }, ...nodeSx]}>\n {/* No JSON-LD here any more (AGL-686). This block used to emit a\n free-standing `AggregateRating` node, which schema.org ignores —\n a rating has to be a PROPERTY of the Product it rates. The PDP's\n server-rendered Product node now carries `aggregateRating`,\n built from the same `readProductReviews` reader this block's API\n uses, so the structured data and the visible stars cannot\n disagree. It also removes the one call site that was primed to\n become stored XSS the moment a reviewer name went into it. */}\n <Typography variant=\"h6\">\n {heading || 'Reviews'}\n {aggregate.count > 0 ? ` (${aggregate.average} ★ · ${aggregate.count})` : ''}\n </Typography>\n {(reviews ?? []).map((review) => (\n <Box key={review.id}>\n <Box sx={{ display: 'flex', gap: 1, 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 buyer\" size=\"small\" variant=\"outlined\" color=\"success\" />\n ) : null}\n </Box>\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n {review.body}\n </Typography>\n {review.reply ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ display: 'block', pl: 2, mt: 0.5, borderLeft: 2, borderColor: 'divider' }}\n >\n {`Seller: ${review.reply}`}\n </Typography>\n ) : null}\n <Divider sx={{ mt: 1.5 }} />\n </Box>\n ))}\n {reviews && reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No reviews yet — be the first.'}\n </Typography>\n ) : null}\n {state === 'done' ? (\n <Alert severity=\"success\">\n {'Thanks — your review is awaiting moderation.'}\n </Alert>\n ) : (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, maxWidth: 480 }}>\n <Typography variant=\"subtitle2\">{'Write a review'}</Typography>\n <Rating\n value={form.rating}\n onChange={(_event, value) =>\n setForm((prev) => ({ ...prev, rating: value ?? 5 }))\n }\n />\n <TextField\n placeholder=\"What did you think?\"\n value={form.body}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, body: event.target.value }))\n }\n size=\"small\"\n multiline\n minRows={2}\n />\n <Box sx={{ display: 'flex', gap: 1 }}>\n <TextField\n placeholder=\"Name\"\n value={form.authorName}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, authorName: event.target.value }))\n }\n size=\"small\"\n sx={{ flex: 1 }}\n />\n <TextField\n placeholder=\"Email (verifies your purchase)\"\n type=\"email\"\n value={form.authorEmail}\n onChange={(event) =>\n setForm((prev) => ({ ...prev, authorEmail: event.target.value }))\n }\n size=\"small\"\n sx={{ flex: 1.4 }}\n />\n </Box>\n {state === 'error' ? (\n <Alert severity=\"error\">{'Could not submit — check the fields.'}</Alert>\n ) : null}\n <Button\n variant=\"outlined\"\n size=\"small\"\n disabled={state === 'busy' || !form.body.trim() || !form.authorEmail.trim()}\n onClick={handleSubmit}\n sx={{ alignSelf: 'flex-start' }}\n >\n {'Submit review'}\n </Button>\n </Box>\n )}\n </Box>\n )\n },\n)\nSingleProductReviews.displayName = 'AglynSingleProductReviews'\n\nexport const schema: Aglyn.ComponentSchema<ProductReviewsProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Product reviews',\n description:\n 'Approved reviews with verified-buyer badges, and a form to add one.',\n category: Aglyn.ComponentCategory.COMMERCE,\n icon: { path: mdiStarOutline.path, sx: { color: '#2e7d32' } },\n flags: { selfClosing: Aglyn.FEATURE_FLAG.ENABLED },\n attributes: [\n {\n name: 'productId',\n label: 'Product id',\n description:\n 'Whose reviews these are. Blank reads the product from the URL, ' +\n 'which is what you want on a product template; naming one pins the ' +\n 'block to that product wherever it is placed.',\n component: Aglyn.FieldComponentType.PRODUCT_SELECT,\n },\n {\n name: 'heading',\n label: 'Heading',\n description: 'Defaults to \"Reviews\" (\"What customers say\" store-wide).',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n },\n {\n // Default `product`, so a page that already places this block keeps it.\n name: 'scope',\n label: 'Show',\n description:\n 'One product’s reviews with a form, or the store’s latest approved ' +\n 'reviews across every product. Store-wide shows nothing until a ' +\n 'shopper’s review is approved.',\n component: Aglyn.FieldComponentType.SELECT,\n options: [\n { label: 'This product', value: 'product' },\n { label: 'Whole store', value: 'store' },\n ],\n },\n {\n name: 'maxItems',\n label: 'Max reviews',\n description: 'Store-wide: how many to show (default 6, at most 12).',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n type: 'number',\n },\n {\n name: 'headingLevel',\n label: 'Heading level',\n description: 'Store-wide: the heading’s level on the page (default H2).',\n component: Aglyn.FieldComponentType.SELECT,\n options: [\n { label: 'H2', value: '2' },\n { label: 'H3', value: '3' },\n { label: 'H4', value: '4' },\n ],\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Product reviews',\n pluginId: BUNDLE_ID,\n description: 'Verified-buyer reviews with a submit form',\n category: Aglyn.ComponentCategory.COMMERCE,\n icon: { path: mdiStarOutline.path, sx: { color: '#2e7d32' } },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default ProductReviews\n"],"names":["Aglyn","mdiStarOutline","Alert","Box","Button","Chip","Divider","Rating","TextField","Typography","forwardRef","useEffect","useState","BUNDLE_ID","generatePresetId","ID","EDITOR_HINT_SX","p","border","borderColor","borderRadius","color","fontSize","fontFamily","resolveProductIdFromSlug","hostId","payload","match","window","location","pathname","response","fetch","encodeURIComponent","decodeURIComponent","catch","ok","json","String","product","id","ProductReviews","props","ref","scope","StoreReviews","SingleProductReviews","displayName","productId","_productId","heading","_scope","maxItems","headingLevel","rest","nodeSx","Array","isArray","useSite","setPayload","limit","Number","Math","min","floor","active","then","body","reviews","aggregate","count","average","undefined","sx","length","data-store-reviews-empty","display","asked","level","flexDirection","gap","alignItems","textAlign","variant","component","value","precision","readOnly","size","gridTemplateColumns","xs","sm","md","map","review","m","bgcolor","rating","flex","flexWrap","fontWeight","authorName","verified","label","productName","productSlug","href","textDecoration","productIdProp","_maxItems","_headingLevel","siteFetch","useSiteFetch","setProductId","setReviews","setAggregate","form","setForm","authorEmail","state","setState","resolved","handleSubmit","method","headers","JSON","stringify","mt","reply","pl","borderLeft","severity","maxWidth","onChange","_event","prev","placeholder","event","target","multiline","minRows","type","disabled","trim","onClick","alignSelf","schema","$id","pluginId","description","category","ComponentCategory","COMMERCE","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","name","FieldComponentType","PRODUCT_SELECT","TEXT_FIELD","SELECT","options","presets","NodeType","PRESET","data","componentId"],"mappings":"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"}
@@ -21,6 +21,12 @@ export interface RelatedProductsProps {
21
21
  productId?: string;
22
22
  heading?: string;
23
23
  maxItems?: number;
24
+ /**
25
+ * How the items are drawn: `strip`, the default, a scrolling row of small
26
+ * cards; `grid`, tall photo cards (4:5) with the name and price under
27
+ * them in a grid of up to four across — the product grid's photo look.
28
+ */
29
+ layout?: 'strip' | 'grid';
24
30
  }
25
31
  /**
26
32
  * Related products / upsell strip (AGL-325): the product's manual list,
@@ -27,6 +27,7 @@ import Typography from "@mui/material/Typography";
27
27
  import { forwardRef, useEffect, useState } from "react";
28
28
  import { BUNDLE_ID } from "../constants/bundle-common.js";
29
29
  import { generatePresetId } from "../utils/generate-preset-id.js";
30
+ import { ProductImagePlaceholder } from "./product-image-placeholder.js";
30
31
  // Component ids are persisted in screen documents; never rename.
31
32
  export const ID = 'related-products';
32
33
  /**
@@ -34,23 +35,29 @@ export const ID = 'related-products';
34
35
  * else "frequently bought together" from recent orders, else tag and
35
36
  * category neighbors — resolved server-side, rendered as cards.
36
37
  */ const RelatedProducts = /*#__PURE__*/ forwardRef((props, ref)=>{
37
- const { productId: productIdProp, heading, maxItems } = props, rest = _object_without_properties_loose(props, [
38
+ var _site_pageData_commerce_product, _site_pageData_commerce, _site_pageData;
39
+ const { productId: productIdProp, heading, maxItems, layout } = props, rest = _object_without_properties_loose(props, [
38
40
  "productId",
39
41
  "heading",
40
- "maxItems"
42
+ "maxItems",
43
+ "layout"
41
44
  ]);
42
45
  // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).
43
46
  const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [
44
47
  props['sx']
45
48
  ];
46
- const { hostId } = Aglyn.useSite();
49
+ const site = Aglyn.useSite();
50
+ const { hostId } = site;
51
+ // The page's product, when the server seeded it (AGL-659): its id is the
52
+ // anchor, so the rail skips a product lookup the page already made.
53
+ const seededProductId = (_site_pageData = site.pageData) == null ? void 0 : (_site_pageData_commerce = _site_pageData.commerce) == null ? void 0 : (_site_pageData_commerce_product = _site_pageData_commerce.product) == null ? void 0 : _site_pageData_commerce_product.id;
47
54
  const [items, setItems] = useState(null);
48
55
  useEffect(()=>{
49
56
  if (!hostId) return;
50
57
  let active = true;
51
58
  void (async ()=>{
52
59
  var _ref, _ref1;
53
- let anchor = productIdProp != null ? productIdProp : '';
60
+ let anchor = productIdProp || seededProductId || '';
54
61
  if (!anchor) {
55
62
  var _ref2;
56
63
  var _payload_product;
@@ -74,7 +81,8 @@ export const ID = 'related-products';
74
81
  };
75
82
  }, [
76
83
  hostId,
77
- productIdProp
84
+ productIdProp,
85
+ seededProductId
78
86
  ]);
79
87
  if (!hostId) {
80
88
  return /*#__PURE__*/ _jsx(Box, _extends({
@@ -98,17 +106,29 @@ export const ID = 'related-products';
98
106
  if (!items || items.length === 0) return /*#__PURE__*/ _jsx(Box, _extends({
99
107
  ref: ref
100
108
  }, rest));
109
+ const grid = layout === 'grid';
101
110
  return /*#__PURE__*/ _jsxs(Box, _extends({
102
111
  ref: ref
103
112
  }, rest, {
104
113
  children: [
105
114
  /*#__PURE__*/ _jsx(Typography, {
106
- variant: "h6",
115
+ variant: grid ? 'h5' : 'h6',
116
+ component: "h2",
107
117
  gutterBottom: true,
118
+ sx: grid ? {
119
+ mb: 2
120
+ } : undefined,
108
121
  children: heading || 'You may also like'
109
122
  }),
110
123
  /*#__PURE__*/ _jsx(Box, {
111
- sx: {
124
+ sx: grid ? {
125
+ display: 'grid',
126
+ gap: 2,
127
+ gridTemplateColumns: {
128
+ xs: 'repeat(2, 1fr)',
129
+ md: 'repeat(4, 1fr)'
130
+ }
131
+ } : {
112
132
  display: 'flex',
113
133
  gap: 1.5,
114
134
  overflowX: 'auto',
@@ -120,40 +140,56 @@ export const ID = 'related-products';
120
140
  const imageUrl = Aglyn.siteRelativeMediaSrc(item.imageUrl, {
121
141
  hostId
122
142
  });
143
+ const mediaSx = grid ? {
144
+ aspectRatio: '4 / 5',
145
+ borderRadius: 2
146
+ } : {
147
+ height: 110
148
+ };
123
149
  return /*#__PURE__*/ _jsx(Card, {
124
- variant: "outlined",
125
- sx: {
150
+ variant: grid ? 'elevation' : 'outlined',
151
+ elevation: 0,
152
+ sx: grid ? {
153
+ bgcolor: 'transparent',
154
+ overflow: 'visible'
155
+ } : {
126
156
  minWidth: 160
127
157
  },
128
158
  children: /*#__PURE__*/ _jsxs(CardActionArea, {
129
159
  href: `/products/${item.slug}`,
160
+ sx: grid ? {
161
+ borderRadius: 2
162
+ } : undefined,
130
163
  children: [
131
164
  imageUrl ? /*#__PURE__*/ _jsx(CardMedia, _extends({
132
165
  component: "img",
133
166
  image: imageUrl,
134
167
  alt: item.name,
135
- sx: {
136
- height: 110,
168
+ sx: _extends({}, mediaSx, {
137
169
  objectFit: 'cover'
138
- }
139
- }, Aglyn.DEFERRED_IMAGE_ATTRIBUTES)) : /*#__PURE__*/ _jsx(Box, {
140
- sx: {
141
- height: 110,
142
- bgcolor: 'action.hover'
143
- }
170
+ })
171
+ }, Aglyn.DEFERRED_IMAGE_ATTRIBUTES)) : // Never a blank tile: a product with no photo yet says
172
+ // so, in the theme's colours (AGL-3676 follow-up).
173
+ /*#__PURE__*/ _jsx(ProductImagePlaceholder, {
174
+ name: item.name,
175
+ sx: mediaSx
144
176
  }),
145
177
  /*#__PURE__*/ _jsxs(CardContent, {
146
- sx: {
178
+ sx: grid ? {
179
+ px: 0.5,
180
+ pt: 1.5,
181
+ pb: 1
182
+ } : {
147
183
  py: 1
148
184
  },
149
185
  children: [
150
186
  /*#__PURE__*/ _jsx(Typography, {
151
- variant: "body2",
187
+ variant: grid ? 'subtitle1' : 'body2',
152
188
  noWrap: true,
153
189
  children: item.name
154
190
  }),
155
191
  /*#__PURE__*/ _jsx(Typography, {
156
- variant: "caption",
192
+ variant: grid ? 'body2' : 'caption',
157
193
  color: "text.secondary",
158
194
  children: item.priceComingSoon ? 'Price coming soon' : `$${item.priceUsd}`
159
195
  })
@@ -202,6 +238,23 @@ export const schema = {
202
238
  description: 'Cap the strip (default 6).',
203
239
  component: Aglyn.FieldComponentType.TEXT_FIELD,
204
240
  type: 'number'
241
+ },
242
+ {
243
+ // Default `strip`, so a page that already places this block keeps it.
244
+ name: 'layout',
245
+ label: 'Layout',
246
+ description: 'A scrolling strip of small cards, or a grid of tall photo cards.',
247
+ component: Aglyn.FieldComponentType.SELECT,
248
+ options: [
249
+ {
250
+ label: 'Strip',
251
+ value: 'strip'
252
+ },
253
+ {
254
+ label: 'Photo grid',
255
+ value: 'grid'
256
+ }
257
+ ]
205
258
  }
206
259
  ]
207
260
  };