Skip to content

Commit 85a776d

Browse files
feat: [SFCC-517][SEO-friedly URLs] Added stateToRoute and routeToState custom mapping (#275)
Replace InstantSearch's default `routing: true` (the built-in `simple` state mapping plus the default `history` router) in the SFRA `instantsearch-config.js` with a custom `router` and `stateMapping`. The storefront query string on category and search results pages becomes short and stable - for example `?q=jacket&brand=Apple&size=M&sort=price-asc&page=2` - instead of the index-prefixed, bracketed default. This PR covers the URL routing (state/route mapping) only. The `rel="canonical"` work is handled separately. The mapping is a sample covering the facets the cartridge ships with. Clients who add their own facet attributes extend the two functions. ## Before / after Category landing page, size `L` selected (master-level record model): Before (default `simple` mapping + default router): ``` .../s/RefArch/womens/clothing/tops/?zzgk_006_dx__RefArch__products__en_US[refinementList][variants.size][0]=L ``` After: ``` .../s/RefArch/womens/clothing/tops/?size=L ``` Pre-existing parameters in the URL are kept intact.
1 parent 42b3ce8 commit 85a776d

1 file changed

Lines changed: 111 additions & 1 deletion

File tree

cartridges/int_algolia_sfra/cartridge/static/default/js/algolia/instantsearch-config.js

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,121 @@ function enableInstantSearch(config) {
5252
query: config.urlQuery,
5353
};
5454

55+
// Custom URL routing: short, stable URL keys (q, category, brand, size, color, store, price, sort, page, etc.)
56+
// instead of the default `simple` mapping's index-prefixed keys, with `arrayFormat: 'repeat'
57+
// so `?brand=Apple&brand=Samsung` replaces `?brand[0]=Apple&brand[1]=Samsung`.
58+
const isMaster = algoliaData.recordModel === 'master-level' || algoliaData.recordModel === 'attribute-sliced';
59+
const sizeAttribute = isMaster ? 'variants.size' : 'size';
60+
const colorAttribute = isMaster ? 'variants.color' : 'color';
61+
const storeAttribute = isMaster ? 'variants.storeAvailability' : 'storeAvailability';
62+
const priceAttribute = isMaster ? 'variants.price.' + algoliaData.currencyCode : 'price.' + algoliaData.currencyCode;
63+
64+
/**
65+
* Compresses a hierarchicalMenu uiState slot (which stores cumulative paths at
66+
* every level) into one segment per level so the URL reads as one value per level.
67+
* Assumes category display names do not contain ' > '.
68+
* @param {string[]} levels Cumulative paths, e.g. ['Womens', 'Womens > Clothing']
69+
* @returns {string[]} One segment per level, e.g. ['Womens', 'Clothing']
70+
*/
71+
function compressBreadcrumb(levels) {
72+
return levels.map(function (level, i) {
73+
return i === 0 ? level : level.split(' > ').pop();
74+
});
75+
}
76+
77+
/**
78+
* Rebuilds the cumulative-path form the hierarchicalMenu widget expects in uiState
79+
* from the per-level segments carried in the URL. Inverse of compressBreadcrumb.
80+
* @param {string[]} segments One segment per level, e.g. ['Womens', 'Clothing']
81+
* @returns {string[]} Cumulative paths, e.g. ['Womens', 'Womens > Clothing']
82+
*/
83+
function expandBreadcrumb(segments) {
84+
return segments.map(function (_, i) {
85+
return segments.slice(0, i + 1).join(' > ');
86+
});
87+
}
88+
89+
// URL keys this mapping owns. Anything else already on the URL (lang, utm_*, ...) is
90+
// preserved across refinements so locale and marketing parameters are not stripped.
91+
const routeKeys = ['q', 'category', 'newArrivals', 'newArrival', 'brand', 'color', 'size', 'store', 'price', 'sort', 'page'];
92+
93+
const router = instantsearch.routers.history({
94+
createURL: function (params) {
95+
const qsModule = params.qsModule;
96+
const url = new URL(params.location.href);
97+
const current = qsModule.parse(url.search.slice(1));
98+
routeKeys.forEach(function (key) { delete current[key]; });
99+
const merged = Object.assign({}, current, params.routeState);
100+
url.search = qsModule.stringify(merged, { arrayFormat: 'repeat' });
101+
return url.href;
102+
}
103+
});
104+
105+
const stateMapping = {
106+
stateToRoute: function (uiState) {
107+
var indexUiState = uiState[productsIndex] || {};
108+
var route = {};
109+
if (indexUiState.query) route.q = indexUiState.query;
110+
if (indexUiState.page) route.page = indexUiState.page;
111+
if (indexUiState.sortBy === productsIndex) route.sort = 'best';
112+
if (indexUiState.sortBy === productsIndexPriceAsc) route.sort = 'price-asc';
113+
if (indexUiState.sortBy === productsIndexPriceDesc) route.sort = 'price-desc';
114+
if (indexUiState.hierarchicalMenu && indexUiState.hierarchicalMenu['__primary_category.0']) {
115+
route.category = compressBreadcrumb(indexUiState.hierarchicalMenu['__primary_category.0']);
116+
}
117+
if (indexUiState.hierarchicalMenu && indexUiState.hierarchicalMenu['newArrivalsCategory.0']) {
118+
route.newArrivals = compressBreadcrumb(indexUiState.hierarchicalMenu['newArrivalsCategory.0']);
119+
}
120+
if (indexUiState.toggle && indexUiState.toggle.newArrival) route.newArrival = '1';
121+
if (indexUiState.refinementList) {
122+
if (indexUiState.refinementList.brand) route.brand = indexUiState.refinementList.brand;
123+
if (indexUiState.refinementList[sizeAttribute]) route.size = indexUiState.refinementList[sizeAttribute];
124+
if (indexUiState.refinementList[colorAttribute]) route.color = indexUiState.refinementList[colorAttribute];
125+
if (indexUiState.refinementList[storeAttribute]) route.store = indexUiState.refinementList[storeAttribute];
126+
}
127+
if (indexUiState.range && indexUiState.range[priceAttribute]) route.price = indexUiState.range[priceAttribute];
128+
return route;
129+
},
130+
routeToState: function (route) {
131+
var indexUiState = {};
132+
if (route.q) indexUiState.query = route.q;
133+
if (route.page) indexUiState.page = Number(route.page);
134+
if (route.sort === 'best') indexUiState.sortBy = productsIndex;
135+
if (route.sort === 'price-asc') indexUiState.sortBy = productsIndexPriceAsc;
136+
if (route.sort === 'price-desc') indexUiState.sortBy = productsIndexPriceDesc;
137+
if (route.category) {
138+
indexUiState.hierarchicalMenu = indexUiState.hierarchicalMenu || {};
139+
indexUiState.hierarchicalMenu['__primary_category.0'] = expandBreadcrumb([].concat(route.category));
140+
}
141+
if (route.newArrivals) {
142+
indexUiState.hierarchicalMenu = indexUiState.hierarchicalMenu || {};
143+
indexUiState.hierarchicalMenu['newArrivalsCategory.0'] = expandBreadcrumb([].concat(route.newArrivals));
144+
}
145+
if (route.newArrival === '1') indexUiState.toggle = { newArrival: true };
146+
var refinementList = {};
147+
if (route.brand) refinementList.brand = [].concat(route.brand);
148+
if (route.size) refinementList[sizeAttribute] = [].concat(route.size);
149+
if (route.color) refinementList[colorAttribute] = [].concat(route.color);
150+
if (route.store) refinementList[storeAttribute] = [].concat(route.store);
151+
if (Object.keys(refinementList).length) indexUiState.refinementList = refinementList;
152+
if (route.price) {
153+
indexUiState.range = {};
154+
indexUiState.range[priceAttribute] = route.price;
155+
}
156+
var ui = {};
157+
ui[productsIndex] = indexUiState;
158+
if (route.q) {
159+
ui[contentsIndex] = { query: route.q };
160+
}
161+
return ui;
162+
}
163+
};
164+
55165
var search = instantsearch({
56166
indexName: productsIndex,
57167
searchClient: config.searchClient,
58168
initialUiState: initialUiState,
59-
routing: true,
169+
routing: { router: router, stateMapping: stateMapping },
60170
});
61171

62172
if (algoliaData.enableInsights) {

0 commit comments

Comments
 (0)