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 @@ + + + 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: