Skip to content

Commit ffb78ec

Browse files
treodenclaude
andcommitted
Add cart-page frequently-bought-together widget
Aggregates co-purchase candidates across ALL cart items: every pair is gated individually (pair count, per-anchor orders, lift with the NULLIF zero-denominator guard), then candidates collapse to one row ranked by MAX(lift), anchors matched, total co-purchases. Everything already in the cart — including variant siblings of cart items — is excluded; the bestseller fallback ladder spans all the cart's categories, then the store. No manual tier: modes and picks are per-product concepts. - queryCoPurchaseCandidates generalized to anchor SETS (correlated stats join + GROUP BY aggregate); the single-anchor path shares it — N=1 behavior pinned by the pre-existing FBT tests, unchanged - resolveCartCrossSellProducts: batch rep-resolution of cart lines (dead lines drop, never abort), all cart reps excluded - Cart.crossSellProducts(limit: Int = 4) — catalog-owned extension of checkout's Cart; myCart is the zero-variable session-resolved entry - Query.myCart now memoizes per GraphQL context: the widget's myCart selection next to the site-wide layout query would otherwise run the full cart build twice per request - The widget tracks the live cart after hydration (new non-throwing useOptionalCartState): an emptied cart hides the shelf instantly, composition changes refetch — the SSR-static shelf previously survived removing the last cart item - Unit tests pin every ranking key independently (mutation-tested), per-pair gating asymmetry, variant-group exclusion from cart ids, the full fallback ladder incl. the store rung - E2E: guest-cart specs incl. the remove-last-item live-sync regression Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bbb5719 commit ffb78ec

13 files changed

Lines changed: 807 additions & 37 deletions

File tree

changelog.md

508 Bytes

New Features

  • Product recommendations: rule-based Related Products (same category / same collection / same attribute values, with price band, priority ordering, and manual picks) configurable globally, per category, and per product; and Frequently Bought Together powered by co-purchase statistics (association confidence + lift with configurable thresholds), rebuilt nightly and on demand from order history. A third Upsell shelf ships alongside them, derived automatically from the related-products rules restricted to pricier products (price band percent as the upper cap, no bestseller fill), exposed as Product.upsellProducts and an upsell_products widget — nothing extra to configure. Frequently Bought Together also reaches the cart page: a fourth widget aggregates co-purchase candidates across all cart items (per-pair gating, strongest-affinity ranking, in-cart items and their variants excluded), exposed as Cart.crossSellProducts. Three new page-builder widgets render the shelves on product pages with full status/stock/visibility gating and variant-group awareness. Admin: a Product Recommendations section in Catalog settings, a Recommendations card on product editing (modes, manual picks, computed-candidates comparison, source-tagged shopper previews), and a rules-override card on category editing. New REST endpoints for manual links and stats recompute; new storefront GraphQL fields Product.relatedProducts / Product.crossSellProducts.
  • Widget types may now share component files (settingComponent/previewComponent) — fixed an AreaLoader identifier bug that previously broke the admin bundle when two types pointed at one file.

# v2.1.3

packages/evershop/src/components/frontStore/cart/CartContext.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,15 @@ export const useCartState = (): CartState => {
12391239
return context;
12401240
};
12411241

1242+
/**
1243+
* Non-throwing variant of useCartState for components that may render both
1244+
* inside and outside a CartProvider — e.g. widgets, which merchants can
1245+
* place into ANY area, including ones above the provider. Returns undefined
1246+
* when no provider is present; such placements simply stay non-reactive.
1247+
*/
1248+
export const useOptionalCartState = (): CartState | undefined =>
1249+
useContext(CartStateContext);
1250+
12421251
export const useCartDispatch = (): CartDispatch => {
12431252
const context = useContext(CartDispatchContext);
12441253
if (!context) {

packages/evershop/src/modules/catalog/bootstrap.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,49 @@ export default () => {
337337
}
338338
});
339339

340+
registerWidget({
341+
type: 'cart_frequently_bought_together',
342+
name: 'Frequently bought together (cart)',
343+
description:
344+
'Co-purchase recommendations aggregated across all cart items — intended for the cart page',
345+
category: 'commerce',
346+
icon: 'ShoppingCart',
347+
settingComponent: path.resolve(
348+
CONSTANTS.MODULESPATH,
349+
'catalog/components/RecommendationWidgetSetting.js'
350+
),
351+
component: path.resolve(
352+
CONSTANTS.MODULESPATH,
353+
'catalog/components/CartFrequentlyBoughtTogether.js'
354+
),
355+
previewComponent: path.resolve(
356+
CONSTANTS.MODULESPATH,
357+
'catalog/components/CartFrequentlyBoughtTogetherPreview.js'
358+
),
359+
defaultSettings: {
360+
heading: 'Frequently bought with your items',
361+
limit: 4
362+
},
363+
enabled: true,
364+
schema: {
365+
type: 'object',
366+
additionalProperties: true,
367+
properties: {
368+
heading: { type: ['string', 'null'], maxLength: 255 },
369+
limit: { type: 'integer', minimum: 1, maximum: 12 }
370+
}
371+
},
372+
graphql: {
373+
typeDefs: `
374+
type CartFrequentlyBoughtTogetherWidgetSettings {
375+
heading: String
376+
limit: Int
377+
}
378+
`,
379+
settingsType: 'CartFrequentlyBoughtTogetherWidgetSettings'
380+
}
381+
});
382+
340383
registerWidget({
341384
type: 'collection_products',
342385
name: 'Collection products',
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { useOptionalCartState } from '@components/frontStore/cart/CartContext.js';
2+
import React, { useEffect, useRef, useState } from 'react';
3+
import { useClient } from 'urql';
4+
import {
5+
RecommendationShelf,
6+
RecommendationShelfProps
7+
} from './RecommendationShelf.js';
8+
9+
interface CartFrequentlyBoughtTogetherProps {
10+
shelf: {
11+
products: RecommendationShelfProps['products'];
12+
} | null;
13+
recommendationWidget?: {
14+
heading?: string | null;
15+
limit?: number | null;
16+
};
17+
}
18+
19+
/**
20+
* Client-side refresh of the SSR shelf: the cart page mutates the cart
21+
* WITHOUT reloading (CartContext), so the SSR-baked products go stale the
22+
* moment an item is added or removed — most jarringly, an emptied cart kept
23+
* showing recommendations for products no longer in it. The composition
24+
* signature is product ids only (qty changes don't move recommendations).
25+
*/
26+
const REFRESH_QUERY = `
27+
query CartCrossSellRefresh($limit: Int) {
28+
myCart {
29+
products: crossSellProducts(limit: $limit) {
30+
productId
31+
name
32+
sku
33+
price {
34+
regular { value text }
35+
special { value text }
36+
}
37+
inventory { isInStock }
38+
image { alt url }
39+
url
40+
}
41+
}
42+
}
43+
`;
44+
45+
/**
46+
* Cart-page FBT widget (specifications/06 D-W1-6/7). The cart is NEVER a
47+
* variable: `myCart` resolves from the request's session cookie server-side
48+
* — the only zero-variable cart entry (widget `variables` silently drop
49+
* anything that isn't a getWidgetSetting placeholder). Renders wherever it
50+
* is placed: myCart resolves on every frontStore route, and an empty/absent
51+
* cart resolves to nothing, so off-cart placements are harmless. After
52+
* hydration the shelf tracks the live cart through CartContext (optional —
53+
* placements above the provider stay static).
54+
*/
55+
export default function CartFrequentlyBoughtTogether({
56+
shelf,
57+
recommendationWidget: { heading, limit } = {}
58+
}: CartFrequentlyBoughtTogetherProps) {
59+
const cart = useOptionalCartState();
60+
const client = useClient();
61+
const [products, setProducts] = useState(shelf?.products);
62+
// The first observed signature IS the SSR-time cart — only a CHANGE
63+
// afterwards warrants a refetch.
64+
const sigRef = useRef<string | null>(null);
65+
66+
const items = cart?.data?.items;
67+
const sig = items
68+
? items
69+
.map((item) => String(item.productId))
70+
.sort()
71+
.join(',')
72+
: null;
73+
74+
useEffect(() => {
75+
if (sig === null) {
76+
return undefined; // no provider / no cart data — stay static
77+
}
78+
if (sigRef.current === null || sigRef.current === sig) {
79+
sigRef.current = sig;
80+
return undefined;
81+
}
82+
sigRef.current = sig;
83+
if (!items || items.length === 0) {
84+
setProducts([]); // emptied cart: hide immediately, no round trip
85+
return undefined;
86+
}
87+
let cancelled = false;
88+
client
89+
.query(REFRESH_QUERY, { limit: limit ?? 4 })
90+
.toPromise()
91+
.then((result) => {
92+
if (!cancelled) {
93+
setProducts(result.data?.myCart?.products ?? []);
94+
}
95+
});
96+
return () => {
97+
cancelled = true;
98+
};
99+
}, [sig]);
100+
101+
return (
102+
<RecommendationShelf
103+
heading={heading}
104+
products={products}
105+
placeholderTitle="Frequently bought together (cart)"
106+
placeholderHint="Shows once the shopper's cart has items with co-purchase data — the preview has no cart context."
107+
/>
108+
);
109+
}
110+
111+
export const query = `
112+
query Query($limit: Int, $heading: String) {
113+
recommendationWidget(heading: $heading, limit: $limit) {
114+
heading
115+
limit
116+
}
117+
shelf: myCart {
118+
products: crossSellProducts(limit: $limit) {
119+
...Product
120+
}
121+
}
122+
}
123+
`;
124+
125+
export const fragments = `
126+
fragment Product on Product {
127+
productId
128+
name
129+
sku
130+
price {
131+
regular {
132+
value
133+
text
134+
}
135+
special {
136+
value
137+
text
138+
}
139+
}
140+
inventory {
141+
isInStock
142+
}
143+
image {
144+
alt
145+
url
146+
}
147+
url
148+
}
149+
`;
150+
151+
export const variables = `{
152+
limit: getWidgetSetting("limit"),
153+
heading: getWidgetSetting("heading")
154+
}`;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from 'react';
2+
3+
const TILE_COLORS = ['#c5d8e8', '#82a8c8', '#dbe8f0'];
4+
5+
/**
6+
* Stylized palette preview for the `cart_frequently_bought_together` widget
7+
* — a cart glyph joined to three tiles, the "goes with your cart" visual.
8+
* Static mock: the preview iframe has no cart context.
9+
*/
10+
export default function CartFrequentlyBoughtTogetherPreview(): React.ReactElement {
11+
return (
12+
<div
13+
className="evershop-cart-fbt__preview"
14+
style={{ padding: 16, background: '#ffffff', height: 130 }}
15+
>
16+
<div
17+
style={{
18+
width: '55%',
19+
height: 8,
20+
borderRadius: 2,
21+
background: '#2e3c4a',
22+
marginBottom: 12
23+
}}
24+
/>
25+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
26+
<div
27+
style={{
28+
width: 34,
29+
height: 70,
30+
borderRadius: 3,
31+
background: '#2e3c4a',
32+
display: 'flex',
33+
alignItems: 'center',
34+
justifyContent: 'center',
35+
color: '#ffffff',
36+
fontSize: 18
37+
}}
38+
>
39+
🛒
40+
</div>
41+
<span style={{ fontSize: 16, fontWeight: 600, color: '#66788a' }}>
42+
+
43+
</span>
44+
{TILE_COLORS.map((color, i) => (
45+
<div
46+
key={i}
47+
style={{
48+
background: color,
49+
borderRadius: 3,
50+
height: 70,
51+
flex: 1,
52+
display: 'flex',
53+
flexDirection: 'column',
54+
justifyContent: 'flex-end',
55+
padding: 4,
56+
gap: 4
57+
}}
58+
>
59+
<span
60+
style={{
61+
width: '70%',
62+
height: 4,
63+
borderRadius: 1,
64+
background: 'rgba(0,0,0,0.35)'
65+
}}
66+
/>
67+
</div>
68+
))}
69+
</div>
70+
</div>
71+
);
72+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Cart-page FBT (specifications/06 D-W1-6). Lives in CATALOG — the owner of
2+
# the recommendation tables — extending checkout's Cart type. Non-admin file:
3+
# visible to both schemas, always storefront-gated (D9).
4+
# NOTE: a block description above `extend type` is invalid SDL (graphql-js
5+
# only allows descriptions on definitions) — keep this header as comments.
6+
extend type Cart {
7+
"Frequently bought together across ALL cart items: per-pair gated co-purchase candidates aggregated over the cart (best affinity first), excluding everything already in the cart incl. variant siblings; bestseller ladder per configuration."
8+
crossSellProducts(limit: Int = 4): [Product]
9+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { camelCase } from '../../../../../lib/util/camelCase.js';
2+
import { resolveCartCrossSellProducts } from '../../../services/recommendation/resolveCartCrossSellProducts.js';
3+
4+
/**
5+
* The Cart parent is `camelCase(cart.exportData())` — SHALLOW camelCase, so
6+
* `parent.items` still carry the Item field registry's snake_case keys
7+
* (product_id). The checkout module's own Cart.items resolver camelCases
8+
* each item separately for the same reason.
9+
*/
10+
11+
function clampLimit(limit: unknown, fallback: number): number {
12+
const parsed = parseInt(String(limit), 10);
13+
if (!Number.isFinite(parsed)) {
14+
return fallback;
15+
}
16+
return Math.min(25, Math.max(1, parsed));
17+
}
18+
19+
export default {
20+
Cart: {
21+
crossSellProducts: async (
22+
cart: { items?: Array<{ product_id?: number }> },
23+
{ limit }: { limit?: number },
24+
{ pool }: { pool: any }
25+
) => {
26+
const productIds = (cart.items || [])
27+
.map((item) => Number(item?.product_id))
28+
.filter((id) => Number.isInteger(id) && id > 0);
29+
const rows = await resolveCartCrossSellProducts(
30+
productIds,
31+
clampLimit(limit, 4),
32+
pool
33+
);
34+
return rows.map((row) => camelCase(row));
35+
}
36+
}
37+
};

0 commit comments

Comments
 (0)