From 0323c8d5883fe5e9300e73b5de5de16975ba71f5 Mon Sep 17 00:00:00 2001 From: Vitor Date: Thu, 20 Aug 2026 21:12:19 -0300 Subject: [PATCH 1/2] feat(storefront): Show recommended products on cart and checkout The shelf the legacy checkout SPA already renders never shows anything: it looks products up by `{terms: {_id}}` on `search/_els`, which returns zero hits on the current index, so the section silently hides itself. Recommendations now come from a core composable that reads the cart (bridging `window.ecomCart` on `/app/` pages), fetches the co-purchase graph with a `related` fallback, and looks the products up by `_id` on `search/v1`, which does work. Mounting happens from `vbeta-app`, lazily and only on the cart, checkout and confirmation routes, so no theme or store file has to change. Stores opt out with `window.propsCartRecommendations = false`, or tweak it by assigning props to that same global. Co-Authored-By: Claude Opus 5 --- .../lib/components/CartRecommendations.vue | 34 +++ .../composables/use-cart-recommendations.ts | 209 ++++++++++++++++++ .../src/lib/scripts/cart-recommendations.ts | 31 +++ .../storefront/src/lib/scripts/vbeta-app.ts | 21 ++ .../tests/cart-recommendations.test.ts | 169 ++++++++++++++ packages/storefront/vitest.config.ts | 17 ++ 6 files changed, 481 insertions(+) create mode 100644 packages/storefront/src/lib/components/CartRecommendations.vue create mode 100644 packages/storefront/src/lib/composables/use-cart-recommendations.ts create mode 100644 packages/storefront/src/lib/scripts/cart-recommendations.ts create mode 100644 packages/storefront/tests/cart-recommendations.test.ts create mode 100644 packages/storefront/vitest.config.ts diff --git a/packages/storefront/src/lib/components/CartRecommendations.vue b/packages/storefront/src/lib/components/CartRecommendations.vue new file mode 100644 index 000000000..370a92fa7 --- /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..f1a12e950 --- /dev/null +++ b/packages/storefront/src/lib/composables/use-cart-recommendations.ts @@ -0,0 +1,209 @@ +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 res = await fetch(`${GRAPHS_BASE_URI}/products/${productId}/${matchType}.json`, { + headers: { 'X-Store-ID': `${globalThis.window?.ECOM_STORE_ID}` }, + }); + 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; + 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 = []; + 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..b72c7fea7 --- /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'; +// Entrypoint Vue do próprio tema, para a vitrine herdar os globais ($t, ALink, AImg…) +import createThemeApp from '~/pages/_vue'; + +const CONTAINER_ID = 'cart-recommendations'; + +/** + * Monta a vitrine de recomendados logo abaixo do SPA legado em `/app/`. + * O tema pode customizar por `window.propsCartRecommendations` ou desligar + * atribuindo `false`, sem precisar tocar em nenhum arquivo da loja. + */ +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'], + ...(typeof props === 'object' ? 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..fe934b5fc 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -285,6 +285,27 @@ if (!import.meta.env.SSR) { }, { immediate: true, }); + // Vitrine de recomendados no carrinho e checkout, substituindo a do SPA legado + // (que hoje nunca renderiza: filtrar por `_id` no `search/_els` retorna vazio). + // A loja desliga com `window.propsCartRecommendations = false`. + if ((window as any).propsCartRecommendations !== false) { + (window as any).propsEcCheckout = { + canRecommendItems: false, + ...(window as any).propsEcCheckout, + }; + 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..115932ba9 --- /dev/null +++ b/packages/storefront/tests/cart-recommendations.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom +/** + * Recomendações de produtos no carrinho e checkout. + * Prova o caminho completo: itens do carrinho -> Graphs API (`recommended` com + * fallback para `related`) -> busca dos produtos por `_id` no `search/v1`. + * + * A regressão que motivou este módulo: o componente legado do storefront-app + * buscava os produtos por `{terms: {_id: [...]}}` no `search/_els`, que hoje + * responde 0 hits, deixando a vitrine sempre vazia e sem erro no console. + */ +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, + 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'; + +let graphsResponses: Record; +let fetchCalls: string[]; +let scope: ReturnType | undefined; + +// Cada instância vive no seu escopo para os watchers não vazarem entre os testes. +const setup = (props?: Parameters[0]) => { + scope = effectScope(); + return scope.run(() => useCartRecommendations(props))!; +}; + +// Deixa todas as promises pendentes (graphs + search) resolverem. +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('não busca nada com o carrinho vazio', async () => { + const { products, productIds } = setup(); + await flush(); + expect(fetchCalls).toHaveLength(0); + expect(apiGet).not.toHaveBeenCalled(); + expect(productIds.value).toEqual([]); + expect(products.value).toEqual([]); + }); + + test('busca os produtos recomendados por `_id` no 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}`); + // ordenado pela relevância do grafo, não pela ordem da resposta da busca + expect(products.value.map(({ name }) => name)).toEqual(['Segundo', 'Terceiro']); + }); + + test('cai para `related` quando `recommended` volta vazio', 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('exclui itens já no carrinho e produtos indisponíveis ou sem estoque', 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('usa os itens de maior quantidade como origem, limitado por 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('respeita o gate de rota do /app/', 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([]); + }); +}); diff --git a/packages/storefront/vitest.config.ts b/packages/storefront/vitest.config.ts new file mode 100644 index 000000000..957f64fbd --- /dev/null +++ b/packages/storefront/vitest.config.ts @@ -0,0 +1,17 @@ +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: '@@sf', replacement: joinPath(__dirname, 'src/lib') }, + ], + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); From 361e68fefc33fa86a143eb66e82d67591cc25c76 Mon Sep 17 00:00:00 2001 From: Vitor Date: Fri, 21 Aug 2026 10:11:50 -0300 Subject: [PATCH 2/2] fix(storefront): Harden cart recommendations showcase after review - Reset `isFetching` when the cart is emptied while requests are in flight, so theme skeletons bound to it can't spin forever - Timeout Graphs API requests after 5s to prevent late layout shift on checkout, and only send `X-Store-ID` header when the store ID global is set - Skip polling for legacy `window.ecomCart` outside the legacy app document - Respect themes explicitly opting the legacy showcase back in (`canRecommendItems: true`) before hiding it with CSS - Guard `window.propsCartRecommendations` against non-object values - Wire tests to CI: `test` script on the package, `jsdom` declared, and new coverage for Graphs cache dedup/retry, API error fallback, `minIds` short-circuit and the `isFetching` race Co-Authored-By: Claude Opus 5 --- packages/storefront/package.json | 6 +- .../lib/components/CartRecommendations.vue | 4 +- .../composables/use-cart-recommendations.ts | 12 ++- .../src/lib/scripts/cart-recommendations.ts | 10 +- .../storefront/src/lib/scripts/vbeta-app.ts | 16 ++-- .../tests/cart-recommendations.test.ts | 96 ++++++++++++++++--- packages/storefront/vitest.config.ts | 1 + pnpm-lock.yaml | 3 + 8 files changed, 117 insertions(+), 31 deletions(-) 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 index 370a92fa7..e101a7d17 100644 --- a/packages/storefront/src/lib/components/CartRecommendations.vue +++ b/packages/storefront/src/lib/components/CartRecommendations.vue @@ -2,7 +2,7 @@
-

+

{{ title }}

@@ -21,7 +21,7 @@ import { type Props as UseCartRecommendationsProps, useCartRecommendations, } from '@@sf/composables/use-cart-recommendations'; -// Cada tema tem o seu card, então a vitrine sai com a identidade da loja +// Each theme has its own card, so the showcase renders with the store identity import ProductCard from '~/components/ProductCard.vue'; export type Props = UseCartRecommendationsProps & { diff --git a/packages/storefront/src/lib/composables/use-cart-recommendations.ts b/packages/storefront/src/lib/composables/use-cart-recommendations.ts index f1a12e950..d8f74e035 100644 --- a/packages/storefront/src/lib/composables/use-cart-recommendations.ts +++ b/packages/storefront/src/lib/composables/use-cart-recommendations.ts @@ -50,8 +50,12 @@ export const fetchRecommendedIds = async ( 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: { 'X-Store-ID': `${globalThis.window?.ECOM_STORE_ID}` }, + 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}`); @@ -83,6 +87,9 @@ 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; @@ -150,6 +157,9 @@ const useCartRecommendations = (props: Props = {}) => { 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; diff --git a/packages/storefront/src/lib/scripts/cart-recommendations.ts b/packages/storefront/src/lib/scripts/cart-recommendations.ts index b72c7fea7..5b23cb0de 100644 --- a/packages/storefront/src/lib/scripts/cart-recommendations.ts +++ b/packages/storefront/src/lib/scripts/cart-recommendations.ts @@ -1,14 +1,14 @@ import { createApp } from 'vue'; import CartRecommendations from '@@sf/components/CartRecommendations.vue'; -// Entrypoint Vue do próprio tema, para a vitrine herdar os globais ($t, ALink, AImg…) +// Theme own Vue entrypoint, so the showcase inherits globals ($t, ALink, AImg…) import createThemeApp from '~/pages/_vue'; const CONTAINER_ID = 'cart-recommendations'; /** - * Monta a vitrine de recomendados logo abaixo do SPA legado em `/app/`. - * O tema pode customizar por `window.propsCartRecommendations` ou desligar - * atribuindo `false`, sem precisar tocar em nenhum arquivo da loja. + * 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; @@ -20,7 +20,7 @@ const mountCartRecommendations = () => { const props = (window as any).propsCartRecommendations; const app = createApp(CartRecommendations, { onRoutes: ['cart', 'checkout', 'confirmation'], - ...(typeof props === 'object' ? props : null), + ...(props && typeof props === 'object' && !Array.isArray(props) ? props : null), }); createThemeApp(app); app.mount(container); diff --git a/packages/storefront/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts index fe934b5fc..c6e0f188d 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -285,17 +285,21 @@ if (!import.meta.env.SSR) { }, { immediate: true, }); - // Vitrine de recomendados no carrinho e checkout, substituindo a do SPA legado - // (que hoje nunca renderiza: filtrar por `_id` no `search/_els` retorna vazio). - // A loja desliga com `window.propsCartRecommendations = false`. + // 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, }; - const style = document.createElement('style'); - style.textContent = '#storefront-app .recommended-items{display:none}'; - document.head.appendChild(style); + 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; diff --git a/packages/storefront/tests/cart-recommendations.test.ts b/packages/storefront/tests/cart-recommendations.test.ts index 115932ba9..6ae1999bf 100644 --- a/packages/storefront/tests/cart-recommendations.test.ts +++ b/packages/storefront/tests/cart-recommendations.test.ts @@ -1,12 +1,13 @@ // @vitest-environment jsdom /** - * Recomendações de produtos no carrinho e checkout. - * Prova o caminho completo: itens do carrinho -> Graphs API (`recommended` com - * fallback para `related`) -> busca dos produtos por `_id` no `search/v1`. + * 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`. * - * A regressão que motivou este módulo: o componente legado do storefront-app - * buscava os produtos por `{terms: {_id: [...]}}` no `search/_els`, que hoje - * responde 0 hits, deixando a vitrine sempre vazia e sem erro no console. + * 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, @@ -23,6 +24,7 @@ vi.mock('@@sf/state/shopping-cart', () => ({ shoppingCart })); const { useCartRecommendations, + fetchRecommendedIds, clearRecommendationsCache, } = await import('@@sf/composables/use-cart-recommendations'); @@ -50,18 +52,19 @@ 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; -// Cada instância vive no seu escopo para os watchers não vazarem entre os testes. +// 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))!; }; -// Deixa todas as promises pendentes (graphs + search) resolverem. +// 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 @@ -94,7 +97,7 @@ afterEach(() => { }); describe('useCartRecommendations', () => { - test('não busca nada com o carrinho vazio', async () => { + test('fetches nothing with an empty cart', async () => { const { products, productIds } = setup(); await flush(); expect(fetchCalls).toHaveLength(0); @@ -103,7 +106,7 @@ describe('useCartRecommendations', () => { expect(products.value).toEqual([]); }); - test('busca os produtos recomendados por `_id` no search/v1', async () => { + 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' })] }, @@ -115,11 +118,11 @@ describe('useCartRecommendations', () => { const [endpoint] = apiGet.mock.calls[0]; expect(endpoint).toContain('search/v1?'); expect(endpoint).toContain(`_id=${B},${C}`); - // ordenado pela relevância do grafo, não pela ordem da resposta da busca + // sorted by graph relevance, not by search response order expect(products.value.map(({ name }) => name)).toEqual(['Segundo', 'Terceiro']); }); - test('cai para `related` quando `recommended` volta vazio', async () => { + 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)]; @@ -130,7 +133,16 @@ describe('useCartRecommendations', () => { expect(productIds.value).toEqual([B]); }); - test('exclui itens já no carrinho e produtos indisponíveis ou sem estoque', async () => { + 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({ @@ -148,7 +160,7 @@ describe('useCartRecommendations', () => { expect(products.value).toEqual([]); }); - test('usa os itens de maior quantidade como origem, limitado por maxSourceItems', async () => { + 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 }); @@ -157,7 +169,7 @@ describe('useCartRecommendations', () => { expect(fetchCalls.every((url) => url.includes(`/products/${B}/`))).toBe(true); }); - test('respeita o gate de rota do /app/', async () => { + test('respects the /app/ route gate', async () => { graphsResponses[`${A}/recommended`] = [B]; shoppingCart.items = [cartItem(A)]; globalThis.location.hash = '#/confirmation/123'; @@ -166,4 +178,58 @@ describe('useCartRecommendations', () => { 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 index 957f64fbd..0f6df21ae 100644 --- a/packages/storefront/vitest.config.ts +++ b/packages/storefront/vitest.config.ts @@ -7,6 +7,7 @@ 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') }, ], }, 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: