Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/storefront/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
}
34 changes: 34 additions & 0 deletions packages/storefront/src/lib/components/CartRecommendations.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<section v-if="products.length" class="w-full py-4">
<div class="ui-section">
<div class="mx-auto mb-2 max-w-prose text-center">
<h3 class="ui-text-brand text-2xl text-base-700">
{{ title }}
</h3>
</div>
<ul class="grid grid-cols-2 md:grid-cols-4">
<li v-for="product in products" :key="product._id">
<ProductCard :product :list-name="title" />
</li>
</ul>
</div>
</section>
</template>

<script setup lang="ts">
import { i19recommendedForYou } from '@@i18n';
import {
type Props as UseCartRecommendationsProps,
useCartRecommendations,
} from '@@sf/composables/use-cart-recommendations';
// Each theme has its own card, so the showcase renders with the store identity
import ProductCard from '~/components/ProductCard.vue';

export type Props = UseCartRecommendationsProps & {
title?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: i19recommendedForYou,
});
const { products } = useCartRecommendations(props);
</script>
219 changes: 219 additions & 0 deletions packages/storefront/src/lib/composables/use-cart-recommendations.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<ResourceId[]>> = {};

/** 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<CartItem[] | null>(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<string, any>;
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<ResourceId[]>([]);
const products = shallowRef<SearchItem[]>([]);
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 };
31 changes: 31 additions & 0 deletions packages/storefront/src/lib/scripts/cart-recommendations.ts
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions packages/storefront/src/lib/scripts/vbeta-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<recommended-items>` 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
Expand Down
Loading