Spaces:
Running
Running
fix: eurogold/gold false-hit + feature top keyword hits & fresh-added products larger/on-top
#4
by bep40 - opened
- dist/assets/index.js +0 -0
- dist/assets/worker-nnOdWzlm.js +0 -0
- src/ImageField.jsx +8 -2
- src/catalogue.js +5 -0
- src/merge.js +48 -9
- src/product-search.js +7 -1
dist/assets/index.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-nnOdWzlm.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/ImageField.jsx
CHANGED
|
@@ -80,8 +80,14 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 80 |
share.addEventListener('click', event => event.stopPropagation());
|
| 81 |
button.append(share);
|
| 82 |
button._share = share;
|
| 83 |
-
// Stack order belongs to the photograph, never to its search rank
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
button.style.zIndex = baseZ;
|
| 86 |
root.append(button);
|
| 87 |
const home = initial ? target : edgePosition(image, state.width, state.height, target);
|
|
|
|
| 80 |
share.addEventListener('click', event => event.stopPropagation());
|
| 81 |
button.append(share);
|
| 82 |
button._share = share;
|
| 83 |
+
// Stack order belongs to the photograph, never to its search rank -- EXCEPT
|
| 84 |
+
// that a featured tile -- the exact answer, a top keyword hit, or a just
|
| 85 |
+
// added avatar photograph -- must sit visibly ON TOP of the field around
|
| 86 |
+
// it, which is the whole point of enlarging it. So featured tiles draw in
|
| 87 |
+
// a layer above every ordinary tile (which can at most reach 100; 300 is
|
| 88 |
+
// safely clear of the 1..100 seed range and below the 400 reserved for the
|
| 89 |
+
// search pill glass in index.css).
|
| 90 |
+
const baseZ = image.featured ? 300 + Math.floor(seed(image.id) * 40) : 1 + Math.floor(seed(image.id) * 100);
|
| 91 |
button.style.zIndex = baseZ;
|
| 92 |
root.append(button);
|
| 93 |
const home = initial ? target : edgePosition(image, state.width, state.height, target);
|
src/catalogue.js
CHANGED
|
@@ -253,6 +253,11 @@ export function normalizeProduct(record) {
|
|
| 253 |
specs: specsOf(record.specs),
|
| 254 |
feats: featuresOf(record),
|
| 255 |
addedBy: text(record._addedBy, record._source),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
};
|
| 257 |
}
|
| 258 |
|
|
|
|
| 253 |
specs: specsOf(record.specs),
|
| 254 |
feats: featuresOf(record),
|
| 255 |
addedBy: text(record._addedBy, record._source),
|
| 256 |
+
// The recency of an add via vai-avatar's "Thêm SP". Preserved so the field
|
| 257 |
+
// can enlarge recently-added products the same way it enlarges recently
|
| 258 |
+
// published interiors; a brand-new Grandx/Garis row is as much "new" as the
|
| 259 |
+
// newest avatar interior is.
|
| 260 |
+
addedAt: record._added_at || record.addedAt || null,
|
| 261 |
};
|
| 262 |
}
|
| 263 |
|
src/merge.js
CHANGED
|
@@ -267,6 +267,16 @@ export function mergeResults(interiors, products, options = {}) {
|
|
| 267 |
// result, enlarged and ringed".
|
| 268 |
const exact = exactMatches(interiors ?? [], options.query ?? '', products ?? []);
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
for (const item of products ?? []) {
|
| 271 |
const product = unwrap(item);
|
| 272 |
const src = leadImage(product);
|
|
@@ -275,17 +285,46 @@ export function mergeResults(interiors, products, options = {}) {
|
|
| 275 |
if (seen.has(key)) continue;
|
| 276 |
seen.add(key);
|
| 277 |
const isExact = exact.has(key);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
hits.push({
|
| 279 |
-
...product,
|
| 280 |
-
...tileDefaults(product),
|
| 281 |
-
src,
|
| 282 |
-
// A decisive
|
| 283 |
-
// same emphasis a fresh vai-avatar interior gets: larger than the
|
| 284 |
-
// nearest the search pill.
|
| 285 |
-
...(
|
| 286 |
});
|
| 287 |
-
|
| 288 |
-
}
|
| 289 |
if (!hits.length) return out;
|
| 290 |
|
| 291 |
/**
|
|
|
|
| 267 |
// result, enlarged and ringed".
|
| 268 |
const exact = exactMatches(interiors ?? [], options.query ?? '', products ?? []);
|
| 269 |
|
| 270 |
+
// Keyword hits that are genuinely the answer deserve size too, not just the
|
| 271 |
+
// ONE decisive winner. A query like "xoong noi" matches four Eurogold racks
|
| 272 |
+
// at equal score (1017) and vai-avatar's hasDecisiveWinner() refuses to pick
|
| 273 |
+
// between them, so none of them is `exact` -- yet all four ARE what was typed.
|
| 274 |
+
// We therefore feature the top KEYWORD hits as well: the top `keywordFeatured`
|
| 275 |
+
// by score, provided they cleared a real-match floor. This is what makes
|
| 276 |
+
// Eurogold/brands findable "par mot-clé" and puts them on top of the field.
|
| 277 |
+
const keywordFeatured = options.keywordFeatured ?? 3;
|
| 278 |
+
const KEYWORD_FLOOR = options.keywordFloor ?? 300;
|
| 279 |
+
const scoredHits = [];
|
| 280 |
for (const item of products ?? []) {
|
| 281 |
const product = unwrap(item);
|
| 282 |
const src = leadImage(product);
|
|
|
|
| 285 |
if (seen.has(key)) continue;
|
| 286 |
seen.add(key);
|
| 287 |
const isExact = exact.has(key);
|
| 288 |
+
scoredHits.push({ product, src, key, isExact, score: Number.isFinite(item?.score) ? item.score : 0 });
|
| 289 |
+
if (scoredHits.length >= productLimit) break;
|
| 290 |
+
}
|
| 291 |
+
// Products arrive already ranked by the worker; the score field is the
|
| 292 |
+
// vai-avatar-scored one, so the first `keywordFeatured` entries above the
|
| 293 |
+
// floor are the strongest keyword answer.
|
| 294 |
+
// Recently-added products get size too. "Thêm SP" on vai-avatar writes
|
| 295 |
+
// `_added_at`; a product that arrived this way is a brand-new line (Grandx,
|
| 296 |
+
// Garis, Higold, Boss...) that should stand out the way a freshly published
|
| 297 |
+
// interior does, as long as it also matched the query. The first
|
| 298 |
+
// `freshAdded` such rows (by recency, newest first) are featured at the same
|
| 299 |
+
// scale as the top keyword hits.
|
| 300 |
+
const freshAdded = options.freshAdded ?? 4;
|
| 301 |
+
scoredHits.sort((a, b) => b.score - a.score);
|
| 302 |
+
// Newest first: numeric timestamps sort descending naturally; ISO-8601
|
| 303 |
+
// strings sort newest first lexically as well, which is exactly the order the
|
| 304 |
+
// UI wants for "what arrived most recently".
|
| 305 |
+
const freshOrdered = scoredHits
|
| 306 |
+
.filter(hit => {
|
| 307 |
+
const at = hit.product?.addedAt;
|
| 308 |
+
return typeof at === 'string' ? at.length > 0 : Number.isFinite(at) && at > 0;
|
| 309 |
+
})
|
| 310 |
+
.sort((a, b) => String(b.product?.addedAt || '').localeCompare(String(a.product?.addedAt || '')));
|
| 311 |
+
const freshSkus = new Set(freshOrdered.slice(0, freshAdded).map(hit => hit.product.sku));
|
| 312 |
+
scoredHits.forEach((hit, rank) => {
|
| 313 |
+
const featuredByKeyword = rank < keywordFeatured && hit.score >= KEYWORD_FLOOR;
|
| 314 |
+
const fresh = freshSkus.has(hit.product.sku);
|
| 315 |
+
const featured = hit.isExact || featuredByKeyword || fresh;
|
| 316 |
+
const scale = hit.isExact ? (options.exactScale ?? 1.55)
|
| 317 |
+
: (fresh ? (options.freshScale ?? 1.45) : (options.keywordScale ?? 1.4));
|
| 318 |
hits.push({
|
| 319 |
+
...hit.product,
|
| 320 |
+
...tileDefaults(hit.product),
|
| 321 |
+
src: hit.src,
|
| 322 |
+
// A decisive winner or a top keyword hit is the answer to the query, so it
|
| 323 |
+
// gets the same emphasis a fresh vai-avatar interior gets: larger than the
|
| 324 |
+
// rest and nearest the search pill.
|
| 325 |
+
...(featured ? { featured: true, featuredScale: scale } : {}),
|
| 326 |
});
|
| 327 |
+
});
|
|
|
|
| 328 |
if (!hits.length) return out;
|
| 329 |
|
| 330 |
/**
|
src/product-search.js
CHANGED
|
@@ -132,7 +132,13 @@ export function scoreProduct(product, query, terms) {
|
|
| 132 |
if (compactSku === compactQuery) score += 1000;
|
| 133 |
else if (compactName === compactQuery) score += 900;
|
| 134 |
else if (compactName.includes(compactQuery) || compactSku.includes(compactQuery)) score += 550;
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
// Every meaningful term present in the title is a strong signal too.
|
| 138 |
const significantTerms = terms.filter(term => term.length > 1 && !/^\d{2,4}mm/.test(term));
|
|
|
|
| 132 |
if (compactSku === compactQuery) score += 1000;
|
| 133 |
else if (compactName === compactQuery) score += 900;
|
| 134 |
else if (compactName.includes(compactQuery) || compactSku.includes(compactQuery)) score += 550;
|
| 135 |
+
// The sku-as-substring tier must not fire for a TRIVIALLY SHORT sku. A
|
| 136 |
+
// query "eurogold" must not award 400 to a Grob product whose sku is
|
| 137 |
+
// "GOLD" (4 chars) just because "eurogold" happens to contain "gold" --
|
| 138 |
+
// that is why brand searches kept surfacing unrelated "gold" items above
|
| 139 |
+
// the Eurogold ones. A sku is a specific identity only when it is 5+
|
| 140 |
+
// characters (or strictly longer than half the query), so require both.
|
| 141 |
+
else if (compactSku.length >= Math.max(5, compactQuery.length / 2) && compactQuery.includes(compactSku)) score += 400;
|
| 142 |
}
|
| 143 |
// Every meaningful term present in the title is a strong signal too.
|
| 144 |
const significantTerms = terms.filter(term => term.length > 1 && !/^\d{2,4}mm/.test(term));
|