@aglyn/plugins-commerce 1.0.0-beta.236 → 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 +46 -0
  9. package/src/lib/components/product-grid.js +283 -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
@@ -18,18 +18,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
18
18
  * limitations under the License.
19
19
  */ import { advertisingConsentField } from "@aglyn/aglyn/app-utils/advertising-consent";
20
20
  import * as Aglyn from "@aglyn/aglyn";
21
- import { buildAddToCartParams, buildBeginCheckoutParams, trackEvent, trackEventBeforeNavigation } from "@aglyn/aglyn/app-utils/analytics-events";
22
- import { recordSiteJourneyStep } from "@aglyn/aglyn/app-utils/site-journey";
21
+ import { buildBeginCheckoutParams, trackEvent, trackEventBeforeNavigation } from "@aglyn/aglyn/app-utils/analytics-events";
23
22
  import { isPaymentsNotConfigured } from "@aglyn/aglyn/app-utils/payments-configured";
24
23
  import { storefrontPaymentsNotConfiguredText } from "../constants/storefront-payments.js";
25
24
  import * as CommerceModel from "../model/index.js";
26
- import { mdiTagOutline } from "@aglyn/shared-data-mdi";
25
+ import { mdiChevronDown, mdiTagOutline } from "@aglyn/shared-data-mdi";
26
+ import Accordion from "@mui/material/Accordion";
27
+ import AccordionDetails from "@mui/material/AccordionDetails";
28
+ import AccordionSummary from "@mui/material/AccordionSummary";
27
29
  import Alert from "@mui/material/Alert";
28
30
  import Box from "@mui/material/Box";
29
31
  import Button from "@mui/material/Button";
30
32
  import Chip from "@mui/material/Chip";
31
33
  import MenuItem from "@mui/material/MenuItem";
32
34
  import Skeleton from "@mui/material/Skeleton";
35
+ import SvgIcon from "@mui/material/SvgIcon";
33
36
  import TextField from "@mui/material/TextField";
34
37
  import ToggleButton from "@mui/material/ToggleButton";
35
38
  import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
@@ -38,7 +41,7 @@ import { Suspense, forwardRef, lazy, useEffect, useMemo, useRef, useState } from
38
41
  import { BUNDLE_ID } from "../constants/bundle-common.js";
39
42
  import { generatePresetId } from "../utils/generate-preset-id.js";
40
43
  import { useStorefrontPurchaseEvent } from "../utils/use-storefront-purchase-event.js";
41
- import { CART_UPDATED_EVENT } from "./cart.js";
44
+ import { addToCart } from "../utils/add-to-cart.js";
42
45
  import { CartFulfillmentChoice, useCartFulfillment } from "./cart-fulfillment.js";
43
46
  import { ID as PRODUCT_REVIEWS_ID } from "./product-reviews.js";
44
47
  import { ID as RELATED_PRODUCTS_ID } from "./related-products.js";
@@ -120,6 +123,84 @@ const SAMPLE = {
120
123
  quantity
121
124
  };
122
125
  }
126
+ /**
127
+ * Which options are a CHOICE (AGL-3676 follow-up). A select with one entry —
128
+ * or with entries no variant carries — is not a choice, and on a live store
129
+ * it read as a broken control: an AI-built candle listed a "Scent" select
130
+ * that offered nothing to pick. Decided from the variants, which are what is
131
+ * sold: an option's values count only where a variant carries them, and only
132
+ * when no variant names the option at all do its listed values stand in —
133
+ * and then as a choice only when there are several variants to choose among.
134
+ */ export function optionPresentation(options, variants) {
135
+ return options.map((option)=>{
136
+ var _option_values;
137
+ const listed = [
138
+ ...new Set(((_option_values = option.values) != null ? _option_values : []).map((value)=>String(value).trim()).filter(Boolean))
139
+ ];
140
+ const carried = new Set(variants.map((variant)=>{
141
+ var _variant_options;
142
+ return (_variant_options = variant.options) == null ? void 0 : _variant_options[option.name];
143
+ }).filter((value)=>typeof value === 'string' && value.trim() !== ''));
144
+ const values = carried.size ? [
145
+ ...listed.filter((value)=>carried.has(value)),
146
+ ...[
147
+ ...carried
148
+ ].filter((value)=>!listed.includes(value))
149
+ ] : variants.length > 1 || listed.length === 1 ? listed : [];
150
+ return {
151
+ name: option.name,
152
+ values,
153
+ mode: values.length >= 2 ? 'select' : values.length === 1 ? 'text' : 'hidden'
154
+ };
155
+ });
156
+ }
157
+ /** The selections a page opens on: the first variant on sale, else the first. */ function initialSelections(product) {
158
+ var _ref, _ref1;
159
+ const first = (_ref = product == null ? void 0 : product.variants.find((variant)=>!variant.soldOut)) != null ? _ref : product == null ? void 0 : product.variants[0];
160
+ return _extends({}, (_ref1 = first == null ? void 0 : first.options) != null ? _ref1 : {});
161
+ }
162
+ /** What the Details panel lists: only facts the product record holds. */ export function productDetailFacts(product, variant, presented) {
163
+ var _product_tags;
164
+ const facts = [];
165
+ if (product.type === 'digital') facts.push({
166
+ label: 'Format',
167
+ value: 'Digital product'
168
+ });
169
+ if (product.type === 'service') facts.push({
170
+ label: 'Format',
171
+ value: 'Service'
172
+ });
173
+ for (const option of presented){
174
+ if (option.mode !== 'hidden') {
175
+ facts.push({
176
+ label: option.name,
177
+ value: option.values.join(', ')
178
+ });
179
+ }
180
+ }
181
+ if (variant == null ? void 0 : variant.sku) facts.push({
182
+ label: 'SKU',
183
+ value: variant.sku
184
+ });
185
+ if ((_product_tags = product.tags) == null ? void 0 : _product_tags.length) facts.push({
186
+ label: 'Tags',
187
+ value: product.tags.join(', ')
188
+ });
189
+ return facts;
190
+ }
191
+ /**
192
+ * What the Shipping & returns panel says, from the product's kind and the
193
+ * store's own return settings — never a policy the store has not set.
194
+ */ export function productShippingLines(product, returns) {
195
+ const lines = [];
196
+ if (product.type === 'digital') lines.push('Delivered digitally after purchase — nothing ships.');
197
+ else if (product.type === 'service') lines.push('A service — nothing ships.');
198
+ else lines.push('Shipping is calculated at checkout.');
199
+ if (returns && returns.windowDays > 0) {
200
+ lines.push(`Returns accepted within ${returns.windowDays} ` + `${returns.windowDays === 1 ? 'day' : 'days'} of shipping — ` + 'request one from your order status page.');
201
+ }
202
+ return lines;
203
+ }
123
204
  function slugFromLocation() {
124
205
  if (typeof window === 'undefined') return '';
125
206
  const match = window.location.pathname.match(/\/products\/([^/?#]+)/);
@@ -133,12 +214,14 @@ function slugFromLocation() {
133
214
  * slug prop pins it; sample data on the inert canvas.
134
215
  */ const ProductDetail = /*#__PURE__*/ forwardRef((props, ref)=>{
135
216
  var _ref, _ref1, _ref2;
136
- var _site_pageData_commerce, _site_pageData;
137
- const { slug: slugProp, buyLabel, hideDescription, showCoupon } = props, rest = _object_without_properties_loose(props, [
217
+ var _site_pageData;
218
+ const { slug: slugProp, buyLabel, hideDescription, showCoupon, showDetails, showShipping } = props, rest = _object_without_properties_loose(props, [
138
219
  "slug",
139
220
  "buyLabel",
140
221
  "hideDescription",
141
- "showCoupon"
222
+ "showCoupon",
223
+ "showDetails",
224
+ "showShipping"
142
225
  ]);
143
226
  // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).
144
227
  const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [
@@ -157,12 +240,19 @@ function slugFromLocation() {
157
240
  // emitted a <Skeleton> and the crawler got a product page with no
158
241
  // product in it. Absent in the besigner/preview, where the effect below
159
242
  // still fetches — so this is an optimisation, not a new requirement.
160
- const seededProduct = (_site_pageData = site.pageData) == null ? void 0 : (_site_pageData_commerce = _site_pageData.commerce) == null ? void 0 : _site_pageData_commerce.product;
243
+ const seededCommerce = (_site_pageData = site.pageData) == null ? void 0 : _site_pageData.commerce;
244
+ const seededProduct = seededCommerce == null ? void 0 : seededCommerce.product;
245
+ // The store's return window, seeded with the built-in product page only
246
+ // when the store takes returns of this product's kind.
247
+ const seededReturns = seededCommerce == null ? void 0 : seededCommerce.returns;
161
248
  const [detail, setDetail] = useState(seededProduct != null ? seededProduct : null);
162
- const [selections, setSelections] = useState({});
249
+ // Opened on the first variant on sale. A seeded page skips the fetch that
250
+ // used to set this, so it opened with nothing selected — an empty select.
251
+ const [selections, setSelections] = useState(()=>initialSelections(seededProduct));
163
252
  const [quantity, setQuantity] = useState(1);
164
253
  /** A discount or coupon code typed on the product page. */ const [coupon, setCoupon] = useState('');
165
- const [activeImage, setActiveImage] = useState(0);
254
+ // A thumbnail the shopper picked; null follows the variant's own photo.
255
+ const [activeImage, setActiveImage] = useState(null);
166
256
  // `unconfigured` is not an `error` with softer words (AGL-2019). A store
167
257
  // with no Stripe key answers 501, and rendering that at `severity="error"`
168
258
  // tells a shopper their payment failed when nothing was ever attempted —
@@ -215,7 +305,6 @@ function slugFromLocation() {
215
305
  const product = payload == null ? void 0 : payload.product;
216
306
  setDetail(product != null ? product : 'missing');
217
307
  if (product) {
218
- var _product_variants_find, _ref;
219
308
  // GA4 ecommerce mirror (AGL-327), through the shared taxonomy
220
309
  // since AGL-1591 rather than raw gtag — same consent gate, same
221
310
  // sanitizer, and the name is now checked by the compiler.
@@ -227,8 +316,7 @@ function slugFromLocation() {
227
316
  }
228
317
  ]
229
318
  });
230
- const first = (_product_variants_find = product.variants.find((variant)=>!variant.soldOut)) != null ? _product_variants_find : product.variants[0];
231
- setSelections((_ref = first == null ? void 0 : first.options) != null ? _ref : {});
319
+ setSelections(initialSelections(product));
232
320
  }
233
321
  }).catch(()=>{
234
322
  if (active) setDetail('missing');
@@ -256,36 +344,20 @@ function slugFromLocation() {
256
344
  const handleAddToCart = async ()=>{
257
345
  if (!hostId || !resolved || !variant) return;
258
346
  setAdded(false);
259
- const response = await siteFetch('/api/commerce/cart', {
260
- method: 'POST',
261
- headers: {
262
- 'Content-Type': 'application/json'
263
- },
264
- body: JSON.stringify({
265
- hostId,
266
- action: 'add',
267
- productId: resolved.id,
268
- variantId: variant.id,
269
- quantity
270
- })
271
- }).catch(()=>null);
272
- if (response == null ? void 0 : response.ok) {
273
- setAdded(true);
274
- // A funnel step (AGL-3605): the product, by its id.
275
- recordSiteJourneyStep('cart', resolved.id);
276
- // Priced (AGL-1591's shape, completed): the resolved variant's price
277
- // and the chosen quantity are both known here and both come from the
278
- // server's product payload, so GA4's "value added to cart" is a real
279
- // number rather than the empty column an items-only hit leaves. The
280
- // `value` describes what was JUST ADDED, not the cart's new total —
281
- // see `buildAddToCartParams`.
282
- trackEvent('add_to_cart', buildAddToCartParams({
283
- items: [
284
- buyItem(resolved, variant, quantity)
285
- ]
286
- }));
287
- window.dispatchEvent(new Event(CART_UPDATED_EVENT));
288
- }
347
+ // Priced (AGL-1591's shape, completed): the resolved variant's price
348
+ // and the chosen quantity both come from the server's product payload,
349
+ // so GA4's "value added to cart" is a real number. The funnel step
350
+ // (AGL-3605) and the badge refresh ride the same shared helper the
351
+ // product grid's quick add uses.
352
+ const ok = await addToCart({
353
+ hostId,
354
+ siteFetch,
355
+ productId: resolved.id,
356
+ variantId: variant.id,
357
+ quantity,
358
+ item: buyItem(resolved, variant, quantity)
359
+ });
360
+ if (ok) setAdded(true);
289
361
  };
290
362
  /**
291
363
  * Idempotency key for ONE buy-now attempt (AGL-1697).
@@ -501,9 +573,12 @@ function slugFromLocation() {
501
573
  // schema.org `image` array below is read out of band by a crawler, where
502
574
  // an absolute URL is the point. That one wants `absoluteMediaSrc` with
503
575
  // the site's own origin, which is the tenant page's job (AGL-1725).
504
- const galleryImage = Aglyn.siteRelativeMediaSrc((_ref1 = (_ref2 = variant == null ? void 0 : variant.imageUrl) != null ? _ref2 : resolved.mediaUrls[activeImage]) != null ? _ref1 : resolved.mediaUrls[0], {
576
+ const galleryImage = Aglyn.siteRelativeMediaSrc((_ref1 = (_ref2 = activeImage !== null ? resolved.mediaUrls[activeImage] : undefined) != null ? _ref2 : variant == null ? void 0 : variant.imageUrl) != null ? _ref1 : resolved.mediaUrls[0], {
505
577
  hostId
506
578
  });
579
+ const presentedOptions = optionPresentation(resolved.options, resolved.variants);
580
+ const detailFacts = showDetails ? productDetailFacts(resolved, variant, presentedOptions) : [];
581
+ const shippingLines = showShipping ? productShippingLines(resolved, (seededProduct == null ? void 0 : seededProduct.id) === resolved.id ? seededReturns : undefined) : [];
507
582
  // Subscription framing (AGL-545): subscription-only products price as
508
583
  // $X/mo|/yr with a "Subscribe" button; subscriptionOptional products
509
584
  // surface the one-time/subscribe toggle instead. Same price either way
@@ -608,13 +683,13 @@ function slugFromLocation() {
608
683
  alt: "",
609
684
  onClick: ()=>setActiveImage(index),
610
685
  sx: {
611
- width: 56,
612
- height: 56,
686
+ width: 64,
687
+ height: 64,
613
688
  objectFit: 'cover',
614
689
  borderRadius: 1,
615
690
  cursor: 'pointer',
616
691
  border: 2,
617
- borderColor: index === activeImage ? 'primary.main' : 'transparent'
692
+ borderColor: index === (activeImage != null ? activeImage : 0) ? 'primary.main' : 'transparent'
618
693
  }
619
694
  }, Aglyn.DEFERRED_IMAGE_ATTRIBUTES), `${url}-${index}`))
620
695
  }) : null
@@ -711,14 +786,25 @@ function slugFromLocation() {
711
786
  })
712
787
  ]
713
788
  }) : null,
714
- resolved.options.map((option)=>{
789
+ presentedOptions.map((option)=>{
715
790
  var _selections_option_name;
716
- return /*#__PURE__*/ _jsx(TextField, {
791
+ return option.mode === 'hidden' ? null : option.mode === 'text' ? /*#__PURE__*/ _jsx(Typography, {
792
+ variant: "body2",
793
+ color: "text.secondary",
794
+ sx: {
795
+ mb: 1.5
796
+ },
797
+ children: `${option.name}: ${option.values[0]}`
798
+ }, option.name) : /*#__PURE__*/ _jsx(TextField, {
717
799
  label: option.name,
718
- value: (_selections_option_name = selections[option.name]) != null ? _selections_option_name : '',
719
- onChange: (event)=>setSelections((prev)=>_extends({}, prev, {
800
+ value: option.values.includes((_selections_option_name = selections[option.name]) != null ? _selections_option_name : '') ? selections[option.name] : '',
801
+ onChange: (event)=>{
802
+ // A new variant shows its own photo, not the last thumbnail.
803
+ setActiveImage(null);
804
+ setSelections((prev)=>_extends({}, prev, {
720
805
  [option.name]: event.target.value
721
- })),
806
+ }));
807
+ },
722
808
  size: "small",
723
809
  select: true,
724
810
  fullWidth: true,
@@ -937,7 +1023,65 @@ function slugFromLocation() {
937
1023
  !hideDescription && resolved.description ? /*#__PURE__*/ _jsx(Typography, {
938
1024
  variant: "body1",
939
1025
  color: "text.secondary",
1026
+ sx: {
1027
+ whiteSpace: 'pre-line'
1028
+ },
940
1029
  children: resolved.description
1030
+ }) : null,
1031
+ detailFacts.length || shippingLines.length ? /*#__PURE__*/ _jsxs(Box, {
1032
+ sx: {
1033
+ mt: 3
1034
+ },
1035
+ children: [
1036
+ detailFacts.length ? /*#__PURE__*/ _jsx(ProductDetailPanel, {
1037
+ title: "Details",
1038
+ children: /*#__PURE__*/ _jsx(Box, {
1039
+ component: "dl",
1040
+ sx: {
1041
+ m: 0,
1042
+ display: 'grid',
1043
+ gridTemplateColumns: 'auto 1fr',
1044
+ columnGap: 2,
1045
+ rowGap: 0.75
1046
+ },
1047
+ children: detailFacts.map((fact)=>/*#__PURE__*/ _jsxs(Box, {
1048
+ sx: {
1049
+ display: 'contents'
1050
+ },
1051
+ children: [
1052
+ /*#__PURE__*/ _jsx(Typography, {
1053
+ component: "dt",
1054
+ variant: "body2",
1055
+ sx: {
1056
+ fontWeight: 600
1057
+ },
1058
+ children: fact.label
1059
+ }),
1060
+ /*#__PURE__*/ _jsx(Typography, {
1061
+ component: "dd",
1062
+ variant: "body2",
1063
+ color: "text.secondary",
1064
+ sx: {
1065
+ m: 0
1066
+ },
1067
+ children: fact.value
1068
+ })
1069
+ ]
1070
+ }, fact.label))
1071
+ })
1072
+ }) : null,
1073
+ shippingLines.length ? /*#__PURE__*/ _jsx(ProductDetailPanel, {
1074
+ title: "Shipping & returns",
1075
+ children: shippingLines.map((line)=>/*#__PURE__*/ _jsx(Typography, {
1076
+ variant: "body2",
1077
+ color: "text.secondary",
1078
+ sx: {
1079
+ mb: 0.75
1080
+ },
1081
+ children: line
1082
+ }, line))
1083
+ }) : null
1084
+ ]
941
1085
  }) : null
942
1086
  ]
943
1087
  })
@@ -945,6 +1089,55 @@ function slugFromLocation() {
945
1089
  }));
946
1090
  });
947
1091
  ProductDetail.displayName = 'AglynProductDetail';
1092
+ /**
1093
+ * One fold of the panels under the buy box: a flat, divided accordion in the
1094
+ * theme's own colours — the way a designer storefront stacks Details and
1095
+ * Shipping under the description.
1096
+ */ function ProductDetailPanel({ title, children }) {
1097
+ return /*#__PURE__*/ _jsxs(Accordion, {
1098
+ disableGutters: true,
1099
+ elevation: 0,
1100
+ square: true,
1101
+ sx: {
1102
+ bgcolor: 'transparent',
1103
+ borderTop: 1,
1104
+ borderColor: 'divider',
1105
+ '&:last-of-type': {
1106
+ borderBottom: 1,
1107
+ borderColor: 'divider'
1108
+ },
1109
+ '&::before': {
1110
+ display: 'none'
1111
+ }
1112
+ },
1113
+ children: [
1114
+ /*#__PURE__*/ _jsx(AccordionSummary, {
1115
+ expandIcon: /*#__PURE__*/ _jsx(SvgIcon, {
1116
+ fontSize: "small",
1117
+ children: /*#__PURE__*/ _jsx("path", {
1118
+ d: mdiChevronDown.path
1119
+ })
1120
+ }),
1121
+ sx: {
1122
+ px: 0
1123
+ },
1124
+ children: /*#__PURE__*/ _jsx(Typography, {
1125
+ variant: "subtitle1",
1126
+ component: "h2",
1127
+ children: title
1128
+ })
1129
+ }),
1130
+ /*#__PURE__*/ _jsx(AccordionDetails, {
1131
+ sx: {
1132
+ px: 0,
1133
+ pt: 0,
1134
+ pb: 2
1135
+ },
1136
+ children: children
1137
+ })
1138
+ ]
1139
+ });
1140
+ }
948
1141
  export const schema = {
949
1142
  $id: ID,
950
1143
  pluginId: BUNDLE_ID,
@@ -988,6 +1181,19 @@ export const schema = {
988
1181
  label: 'Show discount code field',
989
1182
  description: 'Lets a buyer enter a discount or coupon code before buying.',
990
1183
  component: Aglyn.FieldComponentType.CHECKBOX
1184
+ },
1185
+ {
1186
+ // Off by default, so no existing page changes shape on deploy.
1187
+ name: 'showDetails',
1188
+ label: 'Show details panel',
1189
+ description: 'A Details fold under the description with the facts the product ' + 'holds: its options, SKU, tags, and whether it is digital or a service.',
1190
+ component: Aglyn.FieldComponentType.CHECKBOX
1191
+ },
1192
+ {
1193
+ name: 'showShipping',
1194
+ label: 'Show shipping & returns panel',
1195
+ description: 'A Shipping & returns fold: shipping priced at checkout (or digital ' + 'delivery), and your return window when your store takes returns.',
1196
+ component: Aglyn.FieldComponentType.CHECKBOX
991
1197
  }
992
1198
  ]
993
1199
  };