diff --git a/packages/storefront/package.json b/packages/storefront/package.json
index 4f8328343..e290e4f81 100644
--- a/packages/storefront/package.json
+++ b/packages/storefront/package.json
@@ -36,6 +36,7 @@
"build:static": "BUILD_OUTPUT=static astro build",
"build": "bash scripts/build-prod.sh",
"preview": "astro preview",
+ "test": "vitest run",
"astro": "astro",
"prepare-monorepo": "bash scripts/prepare-monorepo.sh",
"lint:fix": "eslint -c ../eslint/storefront.staged.eslintrc.cjs src/lib/**/*.{ts,vue,astro} --fix"
@@ -73,6 +74,7 @@
"devDependencies": {
"@cloudcommerce/eslint": "workspace:*",
"@cloudcommerce/types": "workspace:*",
- "@types/react": "^18.3.31"
+ "@types/react": "^18.3.31",
+ "jsdom": "^25.0.1"
}
-}
\ No newline at end of file
+}
diff --git a/packages/storefront/src/lib/components/CartRecommendations.vue b/packages/storefront/src/lib/components/CartRecommendations.vue
new file mode 100644
index 000000000..e101a7d17
--- /dev/null
+++ b/packages/storefront/src/lib/components/CartRecommendations.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
diff --git a/packages/storefront/src/lib/composables/use-cart-recommendations.ts b/packages/storefront/src/lib/composables/use-cart-recommendations.ts
new file mode 100644
index 000000000..d8f74e035
--- /dev/null
+++ b/packages/storefront/src/lib/composables/use-cart-recommendations.ts
@@ -0,0 +1,219 @@
+import type { ResourceId, SearchItem } from '@cloudcommerce/types';
+import type { CartSet } from '@cloudcommerce/api/types';
+import {
+ ref,
+ shallowRef,
+ computed,
+ watch,
+ onScopeDispose,
+} from 'vue';
+import api from '@cloudcommerce/api';
+import { inStock as checkInStock } from '@ecomplus/utils';
+import { SEARCH_ENGINE_DEFAULTS } from '@@sf/state/search-engine';
+import { shoppingCart } from '@@sf/state/shopping-cart';
+
+type CartItem = CartSet['items'][0];
+
+export type MatchType = 'recommended' | 'related';
+
+export type Props = {
+ /** Graphs match types tried in order, stopping once `minIds` is reached. */
+ matchTypes?: MatchType[];
+ /** How many cart items (most quantity first) are used as recommendation sources. */
+ maxSourceItems?: number;
+ /** Stop trying further `matchTypes` once this many product IDs are found. */
+ minIds?: number;
+ /** Max product IDs sent to the search API. */
+ maxIds?: number;
+ /** Max products returned to render. */
+ limit?: number;
+ /**
+ * Hash routes (`/app/#/cart` -> `cart`) where recommendations may show,
+ * `null` (default) disables the route gate for regular storefront pages.
+ */
+ onRoutes?: string[] | null;
+};
+
+const GRAPHS_BASE_URI = 'https://apx-graphs.e-com.plus/api/v1';
+
+const graphsCache: Record> = {};
+
+/** Drops the in-memory Graphs cache, mostly to keep tests isolated. */
+export const clearRecommendationsCache = () => {
+ Object.keys(graphsCache).forEach((cacheKey) => { delete graphsCache[cacheKey]; });
+};
+
+export const fetchRecommendedIds = async (
+ productId: ResourceId,
+ matchType: MatchType,
+) => {
+ const cacheKey = `${productId}/${matchType}`;
+ if (!graphsCache[cacheKey]) {
+ const fetching = (async () => {
+ const storeId = globalThis.window?.ECOM_STORE_ID;
+ const res = await fetch(`${GRAPHS_BASE_URI}/products/${productId}/${matchType}.json`, {
+ headers: storeId ? { 'X-Store-ID': `${storeId}` } : undefined,
+ signal: typeof AbortSignal.timeout === 'function'
+ ? AbortSignal.timeout(5000)
+ : undefined,
+ });
+ if (!res.ok) {
+ throw new Error(`Graphs ${matchType} for ${productId} failed with ${res.status}`);
+ }
+ const { results } = await res.json();
+ const rows: Array<{ row: ResourceId[] }> = results?.[0]?.data || [];
+ return rows.map(({ row }) => row[0]).filter(Boolean);
+ })();
+ // Keep successful responses only, so a network blip may be retried later
+ fetching.catch(() => { delete graphsCache[cacheKey]; });
+ graphsCache[cacheKey] = fetching;
+ }
+ try {
+ return await graphsCache[cacheKey];
+ } catch (err) {
+ console.error(err);
+ return [] as ResourceId[];
+ }
+};
+
+/**
+ * On `/app/#/cart` and `/app/#/checkout` the cart is owned by the legacy
+ * storefront-app (`window.ecomCart`), which writes to the same local storage key
+ * but can't notify our reactive state within the same document.
+ */
+const legacyCartItems = shallowRef(null);
+let isWatchingLegacyCart = false;
+
+const watchLegacyCart = () => {
+ if (isWatchingLegacyCart || import.meta.env.SSR) return;
+ isWatchingLegacyCart = true;
+ // `window.ecomCart` can only exist within the legacy storefront-app document,
+ // skip polling for it on regular storefront pages
+ if (!document.getElementById('storefront-app')) return;
+ let tries = 0;
+ const tryWatch = () => {
+ const { ecomCart } = globalThis.window as Record;
+ if (!ecomCart) {
+ tries += 1;
+ // storefront-app is loaded async, give it some time to set the global up
+ if (tries <= 50) setTimeout(tryWatch, 200);
+ return;
+ }
+ const syncItems = () => {
+ legacyCartItems.value = [...(ecomCart.data?.items || [])];
+ };
+ ecomCart.on('change', syncItems);
+ syncItems();
+ };
+ tryWatch();
+};
+
+const hashRoute = ref('');
+let isWatchingHashRoute = false;
+
+const parseHashRoute = () => {
+ hashRoute.value = (globalThis.location?.hash || '')
+ .replace(/^#\/?/, '').split(/[/?]/)[0];
+};
+
+const watchHashRoute = () => {
+ if (isWatchingHashRoute || import.meta.env.SSR) return;
+ isWatchingHashRoute = true;
+ parseHashRoute();
+ globalThis.addEventListener('hashchange', parseHashRoute);
+};
+
+const useCartRecommendations = (props: Props = {}) => {
+ const {
+ matchTypes = ['recommended', 'related'],
+ maxSourceItems = 3,
+ minIds = 4,
+ maxIds = 24,
+ limit = 4,
+ onRoutes = null,
+ } = props;
+ const productIds = ref([]);
+ const products = shallowRef([]);
+ const isFetching = ref(false);
+ if (import.meta.env.SSR) {
+ return { productIds, products, isFetching };
+ }
+ watchLegacyCart();
+ if (onRoutes) watchHashRoute();
+ const cartItems = computed(() => legacyCartItems.value || shoppingCart.items);
+ const sourceIds = computed(() => {
+ if (onRoutes && !onRoutes.includes(hashRoute.value)) return [];
+ return [...cartItems.value]
+ .sort((a, b) => (b.quantity || 0) - (a.quantity || 0))
+ .map(({ product_id: productId }) => productId)
+ .filter((productId, i, ids) => productId && ids.indexOf(productId) === i)
+ .slice(0, maxSourceItems);
+ });
+
+ let execCount = 0;
+ const fetchRecommendations = async (_sourceIds: ResourceId[]) => {
+ execCount += 1;
+ const execId = execCount;
+ if (!_sourceIds.length) {
+ productIds.value = [];
+ products.value = [];
+ // May be true from an older still pending execution, which won't reset
+ // it on finish (see `execId` guards) since this exec took it over
+ isFetching.value = false;
+ return;
+ }
+ isFetching.value = true;
+ const cartProductIds = cartItems.value.map(({ product_id: productId }) => productId);
+ const ids: ResourceId[] = [];
+ for (let i = 0; i < matchTypes.length; i++) {
+ // eslint-disable-next-line no-await-in-loop
+ const graphsIds = await Promise.all(_sourceIds.map((productId) => {
+ return fetchRecommendedIds(productId, matchTypes[i]);
+ }));
+ if (execId !== execCount) return;
+ graphsIds.forEach((_ids) => {
+ _ids.forEach((productId) => {
+ if (!cartProductIds.includes(productId) && !ids.includes(productId)) {
+ ids.push(productId);
+ }
+ });
+ });
+ if (ids.length >= minIds) break;
+ }
+ productIds.value = ids.slice(0, maxIds);
+ if (!productIds.value.length) {
+ products.value = [];
+ isFetching.value = false;
+ return;
+ }
+ let endpointQuery = `_id=${productIds.value.join(',')}&limit=${maxIds}`;
+ if (SEARCH_ENGINE_DEFAULTS.fields?.length) {
+ endpointQuery += `&fields=${SEARCH_ENGINE_DEFAULTS.fields.join(',')}`;
+ }
+ try {
+ const { data } = await api.get(`search/v1?${endpointQuery}`);
+ if (execId !== execCount) return;
+ products.value = (data.result as SearchItem[])
+ .filter((item) => item.available && checkInStock(item))
+ .sort((a, b) => productIds.value.indexOf(a._id) - productIds.value.indexOf(b._id))
+ .slice(0, limit);
+ } catch (err) {
+ console.error(err);
+ if (execId !== execCount) return;
+ products.value = [];
+ }
+ isFetching.value = false;
+ };
+
+ const unwatch = watch(sourceIds, (_sourceIds, oldSourceIds) => {
+ if (oldSourceIds && `${_sourceIds}` === `${oldSourceIds}`) return;
+ fetchRecommendations(_sourceIds);
+ }, { immediate: true });
+ onScopeDispose(() => unwatch(), true);
+
+ return { productIds, products, isFetching };
+};
+
+export default useCartRecommendations;
+
+export { useCartRecommendations };
diff --git a/packages/storefront/src/lib/scripts/cart-recommendations.ts b/packages/storefront/src/lib/scripts/cart-recommendations.ts
new file mode 100644
index 000000000..5b23cb0de
--- /dev/null
+++ b/packages/storefront/src/lib/scripts/cart-recommendations.ts
@@ -0,0 +1,31 @@
+import { createApp } from 'vue';
+import CartRecommendations from '@@sf/components/CartRecommendations.vue';
+// Theme own Vue entrypoint, so the showcase inherits globals ($t, ALink, AImg…)
+import createThemeApp from '~/pages/_vue';
+
+const CONTAINER_ID = 'cart-recommendations';
+
+/**
+ * Mounts the recommended products showcase right below the legacy SPA on `/app/`.
+ * Themes may customize it with `window.propsCartRecommendations` or disable it
+ * by assigning `false`, with no need to touch any store file.
+ */
+const mountCartRecommendations = () => {
+ if (document.getElementById(CONTAINER_ID)) return;
+ const appEl = document.getElementById('storefront-app');
+ if (!appEl) return;
+ const container = document.createElement('div');
+ container.id = CONTAINER_ID;
+ appEl.insertAdjacentElement('afterend', container);
+ const props = (window as any).propsCartRecommendations;
+ const app = createApp(CartRecommendations, {
+ onRoutes: ['cart', 'checkout', 'confirmation'],
+ ...(props && typeof props === 'object' && !Array.isArray(props) ? props : null),
+ });
+ createThemeApp(app);
+ app.mount(container);
+};
+
+mountCartRecommendations();
+
+export default mountCartRecommendations;
diff --git a/packages/storefront/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts
index 0a113d5cd..c6e0f188d 100644
--- a/packages/storefront/src/lib/scripts/vbeta-app.ts
+++ b/packages/storefront/src/lib/scripts/vbeta-app.ts
@@ -285,6 +285,31 @@ if (!import.meta.env.SSR) {
}, {
immediate: true,
});
+ // Recommended products showcase on cart and checkout, replacing the legacy
+ // SPA one (which never renders today: `_id` filter on `search/_els` gets 0 hits).
+ // Stores turn it off with `window.propsCartRecommendations = false`.
+ if ((window as any).propsCartRecommendations !== false) {
+ (window as any).propsEcCheckout = {
+ canRecommendItems: false,
+ ...(window as any).propsEcCheckout,
+ };
+ if ((window as any).propsEcCheckout.canRecommendItems === false) {
+ // Also hide the `` hardcoded on legacy `TheCart.html`,
+ // but respect themes explicitly opting the legacy showcase back in
+ const style = document.createElement('style');
+ style.textContent = '#storefront-app .recommended-items{display:none}';
+ document.head.appendChild(style);
+ }
+ const loadCartRecommendations = () => {
+ const route = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0];
+ if (!['cart', 'checkout', 'confirmation'].includes(route)) return;
+ window.removeEventListener('hashchange', loadCartRecommendations);
+ import('@@sf/scripts/cart-recommendations').catch(console.error);
+ };
+ window.addEventListener('hashchange', loadCartRecommendations);
+ loadCartRecommendations();
+ }
+
const loadAppScript = (src?: string) => {
const appScript = document.createElement('script');
appScript.src = src
diff --git a/packages/storefront/tests/cart-recommendations.test.ts b/packages/storefront/tests/cart-recommendations.test.ts
new file mode 100644
index 000000000..6ae1999bf
--- /dev/null
+++ b/packages/storefront/tests/cart-recommendations.test.ts
@@ -0,0 +1,235 @@
+// @vitest-environment jsdom
+/**
+ * Product recommendations on cart and checkout.
+ * Proves the full path: cart items -> Graphs API (`recommended` falling back
+ * to `related`) -> products fetched by `_id` on `search/v1`.
+ *
+ * The regression which motivated this module: the legacy storefront-app
+ * component fetched products with `{terms: {_id: [...]}}` on `search/_els`,
+ * which responds 0 hits today, keeping the showcase always empty with no
+ * console error.
+ */
+import {
+ describe, test, expect, beforeEach, afterEach, vi,
+} from 'vitest';
+import { nextTick, reactive, effectScope } from 'vue';
+
+const apiGet = vi.fn();
+vi.mock('@cloudcommerce/api', () => ({
+ default: { get: (...args: any[]) => apiGet(...args) },
+}));
+
+const shoppingCart = reactive<{ items: any[] }>({ items: [] });
+vi.mock('@@sf/state/shopping-cart', () => ({ shoppingCart }));
+
+const {
+ useCartRecommendations,
+ fetchRecommendedIds,
+ clearRecommendationsCache,
+} = await import('@@sf/composables/use-cart-recommendations');
+
+const graphRows = (ids: string[]) => ({
+ results: [{ columns: ['products.id'], data: ids.map((id) => ({ row: [id], meta: [null] })) }],
+});
+
+const searchItem = (over: Record = {}) => ({
+ _id: over._id || 'p00000000000000000000001',
+ name: over.name || 'Produto',
+ available: true,
+ quantity: 10,
+ price: 19.9,
+ ...over,
+});
+
+const cartItem = (productId: string, quantity = 1) => ({
+ _id: `i${productId.slice(1)}`,
+ product_id: productId,
+ quantity,
+ price: 10,
+});
+
+const A = 'a00000000000000000000001';
+const B = 'b00000000000000000000002';
+const C = 'c00000000000000000000003';
+const D = 'd00000000000000000000004';
+const E = 'e00000000000000000000005';
+
+let graphsResponses: Record;
+let fetchCalls: string[];
+let scope: ReturnType | undefined;
+
+// Each instance lives on its own scope so watchers don't leak between tests.
+const setup = (props?: Parameters[0]) => {
+ scope = effectScope();
+ return scope.run(() => useCartRecommendations(props))!;
+};
+
+// Let all pending promises (graphs + search) resolve.
+const flush = async () => {
+ for (let i = 0; i < 12; i++) {
+ // eslint-disable-next-line no-await-in-loop
+ await nextTick();
+ }
+};
+
+beforeEach(() => {
+ clearRecommendationsCache();
+ apiGet.mockReset();
+ shoppingCart.items = [];
+ fetchCalls = [];
+ graphsResponses = {};
+ globalThis.location.hash = '';
+ (globalThis as any).window.ECOM_STORE_ID = 1011;
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ fetchCalls.push(url);
+ const key = url.replace(/^.+\/products\//, '').replace('.json', '');
+ const ids = graphsResponses[key];
+ if (!ids) return { ok: true, json: async () => graphRows([]) };
+ return { ok: true, json: async () => graphRows(ids) };
+ }));
+ apiGet.mockResolvedValue({ data: { result: [] } });
+});
+
+afterEach(() => {
+ scope?.stop();
+ scope = undefined;
+ vi.unstubAllGlobals();
+});
+
+describe('useCartRecommendations', () => {
+ test('fetches nothing with an empty cart', async () => {
+ const { products, productIds } = setup();
+ await flush();
+ expect(fetchCalls).toHaveLength(0);
+ expect(apiGet).not.toHaveBeenCalled();
+ expect(productIds.value).toEqual([]);
+ expect(products.value).toEqual([]);
+ });
+
+ test('fetches recommended products by `_id` on search/v1', async () => {
+ graphsResponses[`${A}/recommended`] = [B, C];
+ apiGet.mockResolvedValue({
+ data: { result: [searchItem({ _id: C, name: 'Terceiro' }), searchItem({ _id: B, name: 'Segundo' })] },
+ });
+ shoppingCart.items = [cartItem(A)];
+ const { products, productIds } = setup();
+ await flush();
+ expect(productIds.value).toEqual([B, C]);
+ const [endpoint] = apiGet.mock.calls[0];
+ expect(endpoint).toContain('search/v1?');
+ expect(endpoint).toContain(`_id=${B},${C}`);
+ // sorted by graph relevance, not by search response order
+ expect(products.value.map(({ name }) => name)).toEqual(['Segundo', 'Terceiro']);
+ });
+
+ test('falls back to `related` when `recommended` comes empty', async () => {
+ graphsResponses[`${A}/related`] = [B];
+ apiGet.mockResolvedValue({ data: { result: [searchItem({ _id: B })] } });
+ shoppingCart.items = [cartItem(A)];
+ const { productIds } = setup();
+ await flush();
+ expect(fetchCalls.some((url) => url.includes('/recommended.json'))).toBe(true);
+ expect(fetchCalls.some((url) => url.includes('/related.json'))).toBe(true);
+ expect(productIds.value).toEqual([B]);
+ });
+
+ test('skips `related` once `recommended` reaches `minIds`', async () => {
+ graphsResponses[`${A}/recommended`] = [B, C, D, E];
+ shoppingCart.items = [cartItem(A)];
+ const { productIds } = setup();
+ await flush();
+ expect(productIds.value).toEqual([B, C, D, E]);
+ expect(fetchCalls.some((url) => url.includes('/related.json'))).toBe(false);
+ });
+
+ test('excludes items already in cart and unavailable or out of stock products', async () => {
+ graphsResponses[`${A}/recommended`] = [B, C, D];
+ graphsResponses[`${A}/related`] = [B, C, D];
+ apiGet.mockResolvedValue({
+ data: {
+ result: [
+ searchItem({ _id: C, name: 'Sem estoque', quantity: 0 }),
+ searchItem({ _id: D, name: 'Indisponível', available: false }),
+ ],
+ },
+ });
+ shoppingCart.items = [cartItem(A), cartItem(B)];
+ const { productIds, products } = setup();
+ await flush();
+ expect(productIds.value).not.toContain(B);
+ expect(products.value).toEqual([]);
+ });
+
+ test('uses highest quantity items as sources, limited by maxSourceItems', async () => {
+ graphsResponses[`${B}/recommended`] = [D];
+ shoppingCart.items = [cartItem(A, 1), cartItem(B, 5), cartItem(C, 2)];
+ setup({ maxSourceItems: 1 });
+ await flush();
+ expect(fetchCalls[0]).toContain(`/products/${B}/recommended.json`);
+ expect(fetchCalls.every((url) => url.includes(`/products/${B}/`))).toBe(true);
+ });
+
+ test('respects the /app/ route gate', async () => {
+ graphsResponses[`${A}/recommended`] = [B];
+ shoppingCart.items = [cartItem(A)];
+ globalThis.location.hash = '#/confirmation/123';
+ const { productIds } = setup({ onRoutes: ['cart', 'checkout'] });
+ await flush();
+ expect(fetchCalls).toHaveLength(0);
+ expect(productIds.value).toEqual([]);
+ });
+
+ test('resets `isFetching` when the cart is emptied mid-flight', async () => {
+ let resolveGraphs!: (value: any) => void;
+ vi.stubGlobal('fetch', vi.fn((url: string) => {
+ fetchCalls.push(url);
+ return new Promise((resolve) => { resolveGraphs = resolve; });
+ }));
+ shoppingCart.items = [cartItem(A)];
+ const { isFetching, products } = setup();
+ await flush();
+ expect(isFetching.value).toBe(true);
+ shoppingCart.items = [];
+ await flush();
+ resolveGraphs({ ok: true, json: async () => graphRows([B]) });
+ await flush();
+ expect(isFetching.value).toBe(false);
+ expect(products.value).toEqual([]);
+ expect(apiGet).not.toHaveBeenCalled();
+ });
+});
+
+describe('fetchRecommendedIds', () => {
+ test('dedupes concurrent and repeated calls with in-memory cache', async () => {
+ graphsResponses[`${A}/recommended`] = [B];
+ const [ids1, ids2] = await Promise.all([
+ fetchRecommendedIds(A as any, 'recommended'),
+ fetchRecommendedIds(A as any, 'recommended'),
+ ]);
+ const ids3 = await fetchRecommendedIds(A as any, 'recommended');
+ expect(ids1).toEqual([B]);
+ expect(ids2).toEqual([B]);
+ expect(ids3).toEqual([B]);
+ expect(fetchCalls).toHaveLength(1);
+ });
+
+ test('degrades to empty list on Graphs API error, allowing retry later', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ fetchCalls.push(url);
+ return { ok: false, status: 500, json: async () => ({}) };
+ }));
+ const ids = await fetchRecommendedIds(A as any, 'recommended');
+ expect(ids).toEqual([]);
+ // failed response must not be cached, so it can be retried
+ graphsResponses[`${A}/recommended`] = [B];
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ fetchCalls.push(url);
+ return { ok: true, json: async () => graphRows(graphsResponses[`${A}/recommended`]) };
+ }));
+ const retriedIds = await fetchRecommendedIds(A as any, 'recommended');
+ expect(retriedIds).toEqual([B]);
+ expect(fetchCalls).toHaveLength(2);
+ consoleError.mockRestore();
+ });
+});
diff --git a/packages/storefront/vitest.config.ts b/packages/storefront/vitest.config.ts
new file mode 100644
index 000000000..0f6df21ae
--- /dev/null
+++ b/packages/storefront/vitest.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from 'node:url';
+import { join as joinPath } from 'node:path';
+import { defineConfig } from 'vitest/config';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
+
+export default defineConfig({
+ resolve: {
+ alias: [
+ { find: '@@i18n', replacement: '@cloudcommerce/i18n/src/pt_br.ts' },
+ { find: '@@sf', replacement: joinPath(__dirname, 'src/lib') },
+ ],
+ },
+ test: {
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e2cdd5de..fbcf708a1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1350,6 +1350,9 @@ importers:
'@types/react':
specifier: ^18.3.31
version: 18.3.31
+ jsdom:
+ specifier: ^25.0.1
+ version: 25.0.1
packages/test-base:
dependencies: