@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
@@ -37,8 +37,29 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
37
37
  import { BUNDLE_ID } from "../constants/bundle-common.js";
38
38
  import { STOREFRONT_CATALOG_DECLARATION, STOREFRONT_CATALOG_HEADERS, STOREFRONT_CATALOG_OPTIONS } from "../constants/storefront-catalog-query.js";
39
39
  import { generatePresetId } from "../utils/generate-preset-id.js";
40
+ import { addToCart } from "../utils/add-to-cart.js";
41
+ import { ProductImagePlaceholder } from "./product-image-placeholder.js";
40
42
  // Component ids are persisted in screen documents; never rename.
41
43
  export const ID = 'product-grid';
44
+ /** What a card's quick add offers for one product. */ export function quickAddAction(item) {
45
+ var _item_variantCount;
46
+ if (item.priceComingSoon || item.soldOut) return null;
47
+ if (item.defaultVariantId) return 'add';
48
+ if (((_item_variantCount = item.variantCount) != null ? _item_variantCount : 0) > 1) return 'options';
49
+ return null;
50
+ }
51
+ /**
52
+ * The chip and tag a browsing grid opens on, from the page's address:
53
+ * `?category=<slug>` and `?tag=<tag>`. Read after hydration only — the
54
+ * server renders the unfiltered first page and has no address to read.
55
+ */ export function browseParamsFromSearch(search) {
56
+ var _params_get, _params_get1;
57
+ const params = new URLSearchParams(search);
58
+ return {
59
+ categorySlug: ((_params_get = params.get('category')) != null ? _params_get : '').trim(),
60
+ tag: ((_params_get1 = params.get('tag')) != null ? _params_get1 : '').trim()
61
+ };
62
+ }
42
63
  /** What a card says where a product listed before it has a price would show one (AGL-3676). */ const PRICE_COMING_SOON_LABEL = 'Price coming soon';
43
64
  const SAMPLE_ITEMS = [
44
65
  {
@@ -141,7 +162,7 @@ function priceLabel(item) {
141
162
  */ const ProductGrid = /*#__PURE__*/ forwardRef((props, ref)=>{
142
163
  var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
143
164
  var _site_pageData_commerce_grids, _site_pageData_commerce, _site_pageData;
144
- const { source, collectionId, collectionSlug: collectionSlugProp, categoryId, categorySlug, tag, sort: sortProp, columns, maxItems, emptyText, showFilters, showSearch, showCategories, showSort, showTypeFilter, showPriceFilter, pageSize, cardStyle } = props, rest = _object_without_properties_loose(props, [
165
+ const { source, collectionId, collectionSlug: collectionSlugProp, categoryId, categorySlug, tag, sort: sortProp, columns, maxItems, emptyText, emptyTitle, emptyActionLabel, emptyActionHref, showFilters, showSearch, showCategories, showSort, showTypeFilter, showPriceFilter, pageSize, cardStyle, quickAdd } = props, rest = _object_without_properties_loose(props, [
145
166
  "source",
146
167
  "collectionId",
147
168
  "collectionSlug",
@@ -152,6 +173,9 @@ function priceLabel(item) {
152
173
  "columns",
153
174
  "maxItems",
154
175
  "emptyText",
176
+ "emptyTitle",
177
+ "emptyActionLabel",
178
+ "emptyActionHref",
155
179
  "showFilters",
156
180
  "showSearch",
157
181
  "showCategories",
@@ -159,7 +183,8 @@ function priceLabel(item) {
159
183
  "showTypeFilter",
160
184
  "showPriceFilter",
161
185
  "pageSize",
162
- "cardStyle"
186
+ "cardStyle",
187
+ "quickAdd"
163
188
  ]);
164
189
  const photoCards = cardStyle === 'photo';
165
190
  // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).
@@ -167,7 +192,11 @@ function priceLabel(item) {
167
192
  props['sx']
168
193
  ];
169
194
  const site = Aglyn.useSite();
195
+ const siteFetch = Aglyn.useSiteFetch();
170
196
  const { hostId } = site;
197
+ // Quick add (AGL-3676 follow-up): which card is adding, which just did.
198
+ const [quickAdding, setQuickAdding] = useState('');
199
+ const [quickAdded, setQuickAdded] = useState('');
171
200
  // Server-rendered first page (AGL-659). The site-page enricher ran this
172
201
  // grid's own query on the server and keyed the result by node id — two
173
202
  // grids on a page have different queries, so an unkeyed seed would put
@@ -205,6 +234,29 @@ function priceLabel(item) {
205
234
  const collectionSlug = source === 'collection' ? collectionSlugProp || collectionSlugFromLocation() : collectionSlugProp;
206
235
  const pageLimit = pageSize && Number(pageSize) > 0 ? Math.floor(Number(pageSize)) : 0;
207
236
  const pinnedCategoryId = source === 'category' ? categoryId != null ? categoryId : '' : '';
237
+ // A browsing grid opens on the address's `?category=` / `?tag=`: a shop
238
+ // page linked as /shop?category=candles shows that chip picked. Read in
239
+ // an effect, so the server and the first client render agree.
240
+ const [urlCategorySlug, setUrlCategorySlug] = useState('');
241
+ useEffect(()=>{
242
+ if (!hostId || typeof window === 'undefined') return;
243
+ const asked = browseParamsFromSearch(window.location.search);
244
+ if (showCategories && asked.categorySlug) setUrlCategorySlug(asked.categorySlug);
245
+ if (showFilters && asked.tag) setActiveTag(asked.tag);
246
+ // Once, on arrival: later chip picks are the visitor's.
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, [
249
+ hostId
250
+ ]);
251
+ useEffect(()=>{
252
+ if (!urlCategorySlug || !categories.length) return;
253
+ const match = categories.find((category)=>category.slug === urlCategorySlug);
254
+ if (match) setActiveCategoryId(match.id);
255
+ setUrlCategorySlug('');
256
+ }, [
257
+ urlCategorySlug,
258
+ categories
259
+ ]);
208
260
  // Debounce keystrokes so large catalogs aren't fetched per letter.
209
261
  useEffect(()=>{
210
262
  const timer = setTimeout(()=>setQuery(searchInput.trim()), 300);
@@ -552,8 +604,58 @@ function priceLabel(item) {
552
604
  }) : null
553
605
  ]
554
606
  }) : null;
555
- if (visible && visible.length === 0 && !hasCatalogControls) {
556
- return /*#__PURE__*/ _jsx(Box, _extends({
607
+ const filtering = Boolean(query || activeType || selectedCategoryId || activeTag || inStockOnly || priceFilter);
608
+ const emptyCopy = emptyText || 'No products here yet — check back soon.';
609
+ // A store with nothing listed yet (AGL-3676): a headline, a line and one
610
+ // way to hear more — never sample products, never a sort over nothing.
611
+ const emptyPanel = emptyTitle ? /*#__PURE__*/ _jsxs(Box, {
612
+ sx: {
613
+ display: 'flex',
614
+ flexDirection: 'column',
615
+ alignItems: 'center',
616
+ gap: 1.5,
617
+ px: 3,
618
+ py: {
619
+ xs: 6,
620
+ md: 10
621
+ },
622
+ textAlign: 'center',
623
+ borderRadius: 2,
624
+ bgcolor: 'action.hover'
625
+ },
626
+ children: [
627
+ /*#__PURE__*/ _jsx(Typography, {
628
+ variant: "h5",
629
+ component: "h3",
630
+ children: emptyTitle
631
+ }),
632
+ /*#__PURE__*/ _jsx(Typography, {
633
+ variant: "body1",
634
+ color: "text.secondary",
635
+ sx: {
636
+ maxWidth: 480
637
+ },
638
+ children: emptyCopy
639
+ }),
640
+ emptyActionLabel && emptyActionHref ? /*#__PURE__*/ _jsx(Button, {
641
+ variant: "outlined",
642
+ href: emptyActionHref,
643
+ sx: {
644
+ mt: 1
645
+ },
646
+ children: emptyActionLabel
647
+ }) : null
648
+ ]
649
+ }) : null;
650
+ // An empty catalog with nothing asked of it shows its empty state alone:
651
+ // controls are for narrowing products, and there are none to narrow.
652
+ if (visible && visible.length === 0 && (!hasCatalogControls || !filtering)) {
653
+ return emptyPanel ? /*#__PURE__*/ _jsx(Box, _extends({
654
+ ref: ref
655
+ }, rest, {
656
+ sx: nodeSx,
657
+ children: emptyPanel
658
+ })) : /*#__PURE__*/ _jsx(Box, _extends({
557
659
  ref: ref
558
660
  }, rest, {
559
661
  sx: [
@@ -566,7 +668,7 @@ function priceLabel(item) {
566
668
  children: /*#__PURE__*/ _jsx(Typography, {
567
669
  variant: "body2",
568
670
  color: "text.secondary",
569
- children: emptyText || 'No products here yet — check back soon.'
671
+ children: emptyCopy
570
672
  })
571
673
  }));
572
674
  }
@@ -580,7 +682,7 @@ function priceLabel(item) {
580
682
  children: /*#__PURE__*/ _jsx(Typography, {
581
683
  variant: "body2",
582
684
  color: "text.secondary",
583
- children: query || activeType || selectedCategoryId || activeTag || inStockOnly || priceFilter ? 'No products match — try clearing a filter.' : emptyText || 'No products here yet — check back soon.'
685
+ children: filtering ? 'No products match — try clearing a filter.' : emptyCopy
584
686
  })
585
687
  });
586
688
  const loadMore = hostId && pageLimit && nextCursor != null ? /*#__PURE__*/ _jsx(Box, {
@@ -610,85 +712,118 @@ function priceLabel(item) {
610
712
  const imageUrl = Aglyn.siteRelativeMediaSrc(item.imageUrl, {
611
713
  hostId
612
714
  });
613
- return /*#__PURE__*/ _jsx(Card, {
715
+ return /*#__PURE__*/ _jsxs(Card, {
614
716
  variant: photoCards ? 'elevation' : 'outlined',
615
717
  elevation: 0,
616
718
  sx: photoCards ? {
617
719
  bgcolor: 'transparent',
618
720
  overflow: 'visible'
619
721
  } : undefined,
620
- children: /*#__PURE__*/ _jsxs(CardActionArea, {
621
- href: hostId ? `/products/${item.slug}` : undefined,
622
- disabled: !hostId,
623
- sx: photoCards ? {
624
- borderRadius: 2
625
- } : undefined,
626
- children: [
627
- imageUrl ? /*#__PURE__*/ _jsx(CardMedia, _extends({
628
- component: "img",
629
- image: imageUrl,
630
- alt: item.name,
631
- sx: photoCards ? {
632
- aspectRatio: '4 / 5',
633
- objectFit: 'cover',
634
- borderRadius: 2
635
- } : {
636
- height: 160,
637
- objectFit: 'cover'
638
- }
639
- }, Aglyn.DEFERRED_IMAGE_ATTRIBUTES)) : /*#__PURE__*/ _jsx(Box, {
640
- sx: photoCards ? {
641
- aspectRatio: '4 / 5',
642
- bgcolor: 'action.hover',
643
- borderRadius: 2
644
- } : {
645
- height: 160,
646
- bgcolor: 'action.hover'
647
- }
648
- }),
649
- /*#__PURE__*/ _jsxs(CardContent, {
650
- sx: photoCards ? {
651
- px: 0.5,
652
- pt: 1.5,
653
- pb: 1
654
- } : undefined,
655
- children: [
656
- /*#__PURE__*/ _jsx(Typography, {
657
- variant: photoCards ? 'subtitle1' : 'subtitle2',
658
- noWrap: true,
659
- children: item.name
660
- }),
661
- /*#__PURE__*/ _jsxs(Box, {
662
- sx: {
663
- display: 'flex',
664
- gap: 1,
665
- alignItems: 'center'
666
- },
667
- children: [
668
- /*#__PURE__*/ _jsx(Typography, {
669
- variant: "body2",
670
- color: "text.secondary",
671
- children: priceLabel(item)
672
- }),
673
- item.compareAtPriceUsd ? /*#__PURE__*/ _jsx(Typography, {
674
- variant: "caption",
675
- color: "text.disabled",
676
- sx: {
677
- textDecoration: 'line-through'
678
- },
679
- children: `$${item.compareAtPriceUsd}`
680
- }) : null,
681
- item.soldOut ? /*#__PURE__*/ _jsx(Chip, {
682
- label: "Sold out",
683
- size: "small",
684
- variant: "outlined"
685
- }) : null
686
- ]
687
- })
688
- ]
689
- })
690
- ]
691
- })
722
+ children: [
723
+ /*#__PURE__*/ _jsxs(CardActionArea, {
724
+ href: hostId ? `/products/${item.slug}` : undefined,
725
+ disabled: !hostId,
726
+ sx: photoCards ? {
727
+ borderRadius: 2
728
+ } : undefined,
729
+ children: [
730
+ imageUrl ? /*#__PURE__*/ _jsx(CardMedia, _extends({
731
+ component: "img",
732
+ image: imageUrl,
733
+ alt: item.name,
734
+ sx: photoCards ? {
735
+ aspectRatio: '4 / 5',
736
+ objectFit: 'cover',
737
+ borderRadius: 2,
738
+ // A storefront's photo answers the pointer (AGL-3676).
739
+ transition: 'opacity 200ms ease',
740
+ '.MuiCardActionArea-root:hover &': {
741
+ opacity: 0.88
742
+ }
743
+ } : {
744
+ height: 160,
745
+ objectFit: 'cover'
746
+ }
747
+ }, Aglyn.DEFERRED_IMAGE_ATTRIBUTES)) : // Never a blank tile (AGL-3676 follow-up).
748
+ /*#__PURE__*/ _jsx(ProductImagePlaceholder, {
749
+ name: item.name,
750
+ sx: photoCards ? {
751
+ aspectRatio: '4 / 5',
752
+ borderRadius: 2
753
+ } : {
754
+ height: 160
755
+ }
756
+ }),
757
+ /*#__PURE__*/ _jsxs(CardContent, {
758
+ sx: photoCards ? {
759
+ px: 0.5,
760
+ pt: 1.5,
761
+ pb: 1
762
+ } : undefined,
763
+ children: [
764
+ /*#__PURE__*/ _jsx(Typography, {
765
+ variant: photoCards ? 'subtitle1' : 'subtitle2',
766
+ noWrap: true,
767
+ children: item.name
768
+ }),
769
+ /*#__PURE__*/ _jsxs(Box, {
770
+ sx: {
771
+ display: 'flex',
772
+ gap: 1,
773
+ alignItems: 'center'
774
+ },
775
+ children: [
776
+ /*#__PURE__*/ _jsx(Typography, {
777
+ variant: "body2",
778
+ color: "text.secondary",
779
+ children: priceLabel(item)
780
+ }),
781
+ item.compareAtPriceUsd ? /*#__PURE__*/ _jsx(Typography, {
782
+ variant: "caption",
783
+ color: "text.disabled",
784
+ sx: {
785
+ textDecoration: 'line-through'
786
+ },
787
+ children: `$${item.compareAtPriceUsd}`
788
+ }) : null,
789
+ item.soldOut ? /*#__PURE__*/ _jsx(Chip, {
790
+ label: "Sold out",
791
+ size: "small",
792
+ variant: "outlined"
793
+ }) : null
794
+ ]
795
+ })
796
+ ]
797
+ })
798
+ ]
799
+ }),
800
+ quickAdd ? /*#__PURE__*/ _jsx(QuickAddButton, {
801
+ item: item,
802
+ inert: !hostId,
803
+ busy: quickAdding === item.id,
804
+ added: quickAdded === item.id,
805
+ photoCards: photoCards,
806
+ onAdd: async ()=>{
807
+ if (!hostId || !item.defaultVariantId || quickAdding) return;
808
+ setQuickAdding(item.id);
809
+ const ok = await addToCart({
810
+ hostId,
811
+ siteFetch,
812
+ productId: item.id,
813
+ variantId: item.defaultVariantId,
814
+ quantity: 1,
815
+ item: {
816
+ item_id: item.id,
817
+ item_name: item.name,
818
+ price: item.priceUsd,
819
+ quantity: 1
820
+ }
821
+ });
822
+ setQuickAdding('');
823
+ if (ok) setQuickAdded(item.id);
824
+ }
825
+ }) : null
826
+ ]
692
827
  }, item.id);
693
828
  })
694
829
  });
@@ -776,6 +911,50 @@ function priceLabel(item) {
776
911
  ]
777
912
  }));
778
913
  });
914
+ /**
915
+ * A card's quick add: outside the card's link, so a click never follows it.
916
+ * Sample cards on the canvas show the button inert.
917
+ */ function QuickAddButton({ item, inert, busy, added, photoCards, onAdd }) {
918
+ const action = inert ? 'add' : quickAddAction(item);
919
+ if (!action) return null;
920
+ const sx = photoCards ? {
921
+ mt: 0.5,
922
+ mx: 0.5,
923
+ mb: 1
924
+ } : {
925
+ mx: 2,
926
+ mb: 2
927
+ };
928
+ if (action === 'options') {
929
+ return /*#__PURE__*/ _jsx(Box, {
930
+ sx: sx,
931
+ children: /*#__PURE__*/ _jsx(Button, {
932
+ size: "small",
933
+ variant: "outlined",
934
+ fullWidth: true,
935
+ href: `/products/${item.slug}`,
936
+ onClick: (event)=>event.stopPropagation(),
937
+ children: 'Choose options'
938
+ })
939
+ });
940
+ }
941
+ return /*#__PURE__*/ _jsx(Box, {
942
+ sx: sx,
943
+ children: /*#__PURE__*/ _jsx(Button, {
944
+ size: "small",
945
+ variant: "outlined",
946
+ fullWidth: true,
947
+ disabled: inert || busy,
948
+ "aria-label": `Add ${item.name} to cart`,
949
+ onClick: (event)=>{
950
+ event.preventDefault();
951
+ event.stopPropagation();
952
+ onAdd();
953
+ },
954
+ children: added ? 'Added ✓' : busy ? 'Adding…' : 'Add to cart'
955
+ })
956
+ });
957
+ }
779
958
  ProductGrid.displayName = 'AglynProductGrid';
780
959
  export const schema = {
781
960
  $id: ID,
@@ -891,6 +1070,24 @@ export const schema = {
891
1070
  description: 'Copy when nothing matches.',
892
1071
  component: Aglyn.FieldComponentType.TEXT_FIELD
893
1072
  },
1073
+ {
1074
+ name: 'emptyTitle',
1075
+ label: 'Empty headline',
1076
+ description: 'Headline while the store lists nothing yet; set, the empty state ' + 'is a full panel with the empty text and one button.',
1077
+ component: Aglyn.FieldComponentType.TEXT_FIELD
1078
+ },
1079
+ {
1080
+ name: 'emptyActionLabel',
1081
+ label: 'Empty button label',
1082
+ description: 'The empty panel’s button, e.g. “Get in touch”.',
1083
+ component: Aglyn.FieldComponentType.TEXT_FIELD
1084
+ },
1085
+ {
1086
+ name: 'emptyActionHref',
1087
+ label: 'Empty button link',
1088
+ description: 'Where the empty panel’s button goes, e.g. /contact.',
1089
+ component: Aglyn.FieldComponentType.TEXT_FIELD
1090
+ },
894
1091
  {
895
1092
  name: 'showFilters',
896
1093
  label: 'Show filters',
@@ -949,6 +1146,13 @@ export const schema = {
949
1146
  value: 'photo'
950
1147
  }
951
1148
  ]
1149
+ },
1150
+ {
1151
+ // Off by default, so no existing grid changes shape on deploy.
1152
+ name: 'quickAdd',
1153
+ label: 'Quick add to cart',
1154
+ description: 'An Add to cart button on each card for a product with one variant; ' + '"Choose options" for one with several.',
1155
+ component: Aglyn.FieldComponentType.CHECKBOX
952
1156
  }
953
1157
  ]
954
1158
  };