fix: eurogold/gold false-hit + feature top keyword hits & fresh-added products larger/on-top

#4
by bep40 - opened
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
- const baseZ = 1 + Math.floor(seed(image.id) * 100);
 
 
 
 
 
 
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 product winner is the answer to the query, so it gets the
283
- // same emphasis a fresh vai-avatar interior gets: larger than the rest and
284
- // nearest the search pill.
285
- ...(isExact ? { featured: true, featuredScale: options.exactScale ?? 1.55 } : {}),
286
  });
287
- if (hits.length >= productLimit) break;
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
- else if (compactSku.length >= 4 && compactQuery.includes(compactSku)) score += 400;
 
 
 
 
 
 
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));