From 622a3086e176897ec35470928a0f2978b37f8007 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:30:15 +0700 Subject: [PATCH 1/3] fix(extension): price list, badge, empty and error states, labels The side panel orders in-stock offers first and marks sold-out, discontinued and bait rows (the lookup now flags bait offers), shows a short state for not-found and accessory pages, an error card with retry for failed lookups, and no 0.0 stars or 100% genuine without reviews. The badge and pill warn on a bait price and never say OK for an unchosen size or an accessory; labels are Vietnamese and the panel is labelled for screen readers. Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 2 +- .../src/background/service-worker.ts | 149 ++++--- apps/extension/src/content/content-script.ts | 58 +-- apps/extension/src/sidepanel/panel-view.ts | 322 ++++++++++++++ apps/extension/src/sidepanel/sidepanel.html | 37 +- apps/extension/src/sidepanel/sidepanel.ts | 195 ++++----- apps/extension/src/test-extension-pipeline.ts | 392 ++++++++++++++++++ apps/extension/src/types.ts | 12 + apps/extension/src/verdict-display.ts | 110 +++++ apps/web/src/server/rate/extension.ts | 23 +- 10 files changed, 1084 insertions(+), 216 deletions(-) create mode 100644 apps/extension/src/sidepanel/panel-view.ts create mode 100644 apps/extension/src/verdict-display.ts diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index 8b5bed549..da1b3c0e1 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -45,7 +45,7 @@ This table lists exactly what `manifest.json` requests; `test-extension-pipeline 1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, `www.tiktok.com`, `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page; it never opens or fetches pages the user did not open. 2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account or personal data. -3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (Opt-In)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly. +3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly. 4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search and their lookup results (`chrome.storage.session`). 5. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API. 6. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension. diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index e2ca34e9a..b8295dc7e 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -17,6 +17,7 @@ import { sanitizeExtensionProductSignal, selectFreshestSessionSignal, } from '../security-guards.js'; +import { resolveActionBadge } from '../verdict-display.js'; const DEFAULT_SETTINGS: ExtensionSettings = { apiBaseUrl: DEFAULT_API_BASE_URL, @@ -40,10 +41,46 @@ async function getSettings(): Promise { } } +/** + * A lookup either answers (data may be null: nothing matched) or fails with + * the HTTP status, `null` for a network error or timeout, so the panel can + * say which (DOS/Rate#1300). + */ +type LookupOutcome = + | { ok: true; data: ExtensionLookupData | null } + | { ok: false; status: number | null }; + +async function requestLookup(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Rate-Extension-Version': '2.0.0', + }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + return { ok: false, status: null }; + } + if (!response.ok) return { ok: false, status: response.status }; + try { + const json = (await response.json()) as { + ok?: boolean; + data?: ExtensionLookupData; + }; + if (json.ok === false) return { ok: false, status: response.status }; + return { ok: true, data: json.data ?? null }; + } catch { + return { ok: false, status: response.status }; + } +} + async function fetchExtensionLookup( rawSignal: ExtractedPageProductSignal, mode: 'pill' | 'full' -): Promise { +): Promise { const signal = sanitizeExtensionProductSignal(rawSignal); const settings = await getSettings(); const params = new URLSearchParams(); @@ -63,22 +100,9 @@ async function fetchExtensionLookup( params.set('originalPrice', String(signal.originalPrice)); } - const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': '2.0.0', - }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) return null; - const json = (await response.json()) as { - ok?: boolean; - data?: ExtensionLookupData; - }; - return json.ok && json.data ? json.data : null; + return requestLookup( + `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}` + ); } const SUPPORTED_CROWD_PLATFORMS = new Set([ @@ -129,23 +153,13 @@ async function updateActionBadge( ): Promise { if (!tabId) return; try { - if (!lookup || lookup.matchStatus === 'not_found') { - await chrome.action.setBadgeText({ text: '', tabId }); - return; - } - const verdict = lookup.priceAssessment.verdict; - if (verdict === 'inflated_fake_sale') { - await chrome.action.setBadgeText({ text: 'ẢO!', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#d83030', tabId }); - } else if (verdict === 'genuine_deal') { - await chrome.action.setBadgeText({ text: 'TỐT', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#2dba69', tabId }); - } else if (verdict === 'above_market') { - await chrome.action.setBadgeText({ text: 'CAO', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#f59e0b', tabId }); - } else { - await chrome.action.setBadgeText({ text: 'OK', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId }); + const badge = resolveActionBadge(lookup); + await chrome.action.setBadgeText({ text: badge.text, tabId }); + if (badge.color) { + await chrome.action.setBadgeBackgroundColor({ + color: badge.color, + tabId, + }); } } catch { // Ignore badge errors if tab closed @@ -218,8 +232,18 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } await chrome.storage.session.set(signalPayload); - const lookup = await fetchExtensionLookup(signal, 'pill'); + const outcome = await fetchExtensionLookup(signal, 'pill'); + // A failed lookup clears the previous product's badge (#1300). + const lookup = outcome.ok ? outcome.data : null; await updateActionBadge(tabId, lookup); + if (!outcome.ok) { + sendResponse({ + ok: false, + status: outcome.status, + error: 'Lookup failed', + }); + return; + } if (lookup) { const lookupPayload: Record = { @@ -236,8 +260,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({ ok: true, data: lookup }); } catch (err) { + await updateActionBadge(sender.tab?.id, null); sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Lookup failed', }); } @@ -335,16 +361,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } } - const fullLookup = await fetchExtensionLookup(effectiveSignal, 'full'); - sendResponse({ - ok: true, - settings, - signal: effectiveSignal, - data: fullLookup, - }); + const outcome = await fetchExtensionLookup(effectiveSignal, 'full'); + sendResponse( + outcome.ok + ? { + ok: true, + settings, + signal: effectiveSignal, + data: outcome.data, + } + : { + ok: false, + status: outcome.status, + settings, + signal: effectiveSignal, + } + ); } catch (err) { sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Full lookup failed', }); } @@ -417,31 +453,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } params.set('title', rawQuery); - const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': '2.0.0', - }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { + const outcome = await requestLookup( + `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}` + ); + if (!outcome.ok) { + // The panel tells a rate limit, a server error and a network + // error apart (DOS/Rate#1300). sendResponse({ - ok: true, + ok: false, + status: outcome.status, settings, signal: syntheticSignal, - data: null, }); return; } - - const json = (await response.json()) as { - ok?: boolean; - data?: ExtensionLookupData; - }; - const fullLookup = json.ok && json.data ? json.data : null; + const fullLookup = outcome.data; if (fullLookup?.platform) { syntheticSignal.platform = fullLookup.platform; @@ -465,6 +491,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } catch (err) { sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Quick query lookup failed', }); diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 5e6c57eb4..f0b429498 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -6,6 +6,7 @@ import { shouldRenderTrustPill, } from '../security-guards.js'; import type { ExtensionLookupData } from '../types.js'; +import { buildPillAriaLabel, resolvePillTheme } from '../verdict-display.js'; const HOST_ID = 'rate-vn-trust-pill-host'; @@ -27,39 +28,6 @@ function escapeHtml(raw: string): string { .replace(/'/g, '''); } -function getVerdictTheme(verdict: string): { - bg: string; - border: string; - dot: string; -} { - switch (verdict) { - case 'inflated_fake_sale': - return { - bg: 'rgba(216, 48, 48, 0.16)', - border: '#d83030', - dot: '#d83030', - }; - case 'genuine_deal': - return { - bg: 'rgba(45, 186, 105, 0.16)', - border: '#2dba69', - dot: '#2dba69', - }; - case 'above_market': - return { - bg: 'rgba(245, 158, 11, 0.16)', - border: '#f59e0b', - dot: '#f59e0b', - }; - default: - return { - bg: 'rgba(59, 130, 246, 0.16)', - border: '#3b82f6', - dot: '#3b82f6', - }; - } -} - /** * Render an isolated Shadow DOM Floating Trust Pill on supported e-commerce pages. * Zero host CSS pollution; clicking opens the Chrome Native Side Panel. @@ -80,14 +48,22 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' }); const assessment = lookup.priceAssessment; - const theme = getVerdictTheme(assessment.verdict); + // A bait price uses the warning theme, not the neutral one (#1298). + const theme = resolvePillTheme( + assessment.verdict, + assessment.implausiblyLow + ); const rawRating = lookup.masterProduct?.user_rating_weighted || lookup.masterProduct?.average_rating || null; const trustedRating = normalizeFiveStarRating(rawRating); - const authenticRatio = lookup.reviewSummary?.authentic_pct ?? null; + // No share of genuine reviews when none was analysed (#1301). + const authenticRatio = + (lookup.reviewSummary?.total_analyzed ?? 0) > 0 + ? (lookup.reviewSummary?.authentic_pct ?? null) + : null; const cheaperSummary = assessment.cheaperOfferCount > 0 && assessment.maxSavingsAmount > 0 @@ -206,7 +182,7 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { flex-shrink: 0; } -
+
@@ -215,7 +191,7 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { ${radarChipHtml} ${ trustedRating - ? `★ ${formatSafeNumber(trustedRating, { min: 0, max: 5, decimals: 1 })} (${formatSafeNumber(authenticRatio ?? 100, { min: 0, max: 100, fallback: 100 })}% thật)` + ? `★ ${formatSafeNumber(trustedRating, { min: 0, max: 5, decimals: 1 })}${authenticRatio == null ? '' : ` (${formatSafeNumber(authenticRatio, { min: 0, max: 100 })}% thật)`}` : '' }
@@ -264,11 +240,15 @@ async function inspectCurrentPage(): Promise { if (response?.ok && response.data) { renderShadowTrustPill(response.data); - } else if (response?.ok && !response.data) { + } else { + // Nothing matched or the lookup failed: never leave the previous + // product's pill on a single-page shop (DOS/Rate#1300). removeTrustPillHost(); } } catch { - // Ignore transient messaging errors during page unload + // Messaging failed (page unloading, worker restarting): drop the pill + // rather than show a stale one (DOS/Rate#1300). + removeTrustPillHost(); } }); } diff --git a/apps/extension/src/sidepanel/panel-view.ts b/apps/extension/src/sidepanel/panel-view.ts new file mode 100644 index 000000000..a71da5afc --- /dev/null +++ b/apps/extension/src/sidepanel/panel-view.ts @@ -0,0 +1,322 @@ +/** + * Pure HTML builders for the side panel: no DOM and no chrome APIs, so the + * test suite can render them. Every interpolated value goes through + * escapeHtml or a formatSafe* helper. + */ +import type { + ExtensionLookupData, + ExtensionOfferItem, + ExtractedPageProductSignal, +} from '../types.js'; +import { + escapeSafeExternalHref, + formatSafeExtensionVnd, + formatSafeNumber, + normalizeFiveStarRating, + resolveSafeApiRelativeUrl, +} from '../security-guards.js'; + +export function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +type PanelOffer = Pick< + ExtensionOfferItem, + 'id' | 'price_current' | 'availability' | 'implausiblyLow' +>; + +export function isOfferSoldOut(offer: PanelOffer): boolean { + return ( + offer.availability === 'out_of_stock' || + offer.availability === 'discontinued' + ); +} + +/** Whether the row is a price someone can buy at now (DOS/Rate#1297). */ +export function isOfferBuyable(offer: PanelOffer): boolean { + return !isOfferSoldOut(offer) && !offer.implausiblyLow; +} + +function hasValidPrice(offer: PanelOffer): boolean { + return ( + typeof offer.price_current === 'number' && + Number.isFinite(offer.price_current) && + offer.price_current > 0 + ); +} + +/** + * In-stock, plausible offers first, then bait prices, then sold-out and + * discontinued rows; each group by price, ties in server order + * (DOS/Rate#1297). + */ +export function orderOffersForPanel(offers: T[]): T[] { + const group = (o: T) => (isOfferSoldOut(o) ? 2 : o.implausiblyLow ? 1 : 0); + return offers + .map((offer, index) => ({ offer, index })) + .sort((a, b) => { + const byGroup = group(a.offer) - group(b.offer); + if (byGroup !== 0) return byGroup; + const aValid = hasValidPrice(a.offer); + const bValid = hasValidPrice(b.offer); + if (aValid !== bValid) return aValid ? -1 : 1; + const byPrice = aValid + ? a.offer.price_current - b.offer.price_current + : 0; + return byPrice !== 0 ? byPrice : a.index - b.index; + }) + .map(({ offer }) => offer); +} + +/** The cheapest row a shopper can buy at; never sold out or bait (#1297). */ +export function cheapestBuyableOfferId(offers: PanelOffer[]): string | null { + const buyable = orderOffersForPanel(offers).find( + (o) => isOfferBuyable(o) && hasValidPrice(o) + ); + return buyable ? buyable.id : null; +} + +/** The marks the product page shows on a row (DOS/Rate#1297). */ +export function offerMarks(offer: PanelOffer): string[] { + const marks: string[] = []; + if (offer.availability === 'out_of_stock') marks.push('Hết hàng'); + if (offer.availability === 'discontinued') marks.push('Ngừng bán'); + if (offer.implausiblyLow) marks.push('Giá thấp bất thường'); + return marks; +} + +/** A star rating, or null when unknown or zero (DOS/Rate#1301). */ +export function ratingStarText(raw: unknown): string | null { + const rating = normalizeFiveStarRating(raw); + return rating == null + ? null + : `★ ${formatSafeNumber(rating, { min: 0, max: 5, decimals: 1 })}`; +} + +const OFFICIAL_BADGE_HTML = + 'Chính hãng'; + +function renderOfferRowHtml( + off: ExtensionOfferItem, + cheapest: boolean +): string { + const marks = offerMarks(off); + const muted = !isOfferBuyable(off); + const star = ratingStarText(off.rating_star); + const meta = [ + escapeHtml(String(off.platform ?? '').toUpperCase()), + star, + // The combined score, as the product page labels it (DOS/Rate#1280). + `Rủi ro tổng hợp: ${formatSafeNumber(off.merchant.risk_score, { min: 0, max: 100 })}/100`, + ].filter(Boolean); + const marksHtml = [ + ...(cheapest + ? ['Rẻ nhất'] + : []), + ...marks.map( + (m) => + `${escapeHtml(m)}` + ), + ].join(' '); + const priceTitle = isOfferSoldOut(off) + ? ' title="Giá ghi nhận lần cuối, không phải giá đang bán"' + : ''; + return ` +
+
+
+ ${escapeHtml(off.merchant.name)} + ${off.merchant.is_official ? OFFICIAL_BADGE_HTML : ''} + ${marksHtml} +
+
+ ${meta.join(' - ')} +
+
+ + ${formatSafeExtensionVnd(off.price_current)} + +
+ `; +} + +/** The store price list card, or '' with no offer (DOS/Rate#1297). */ +export function renderOffersCardHtml(offers: ExtensionOfferItem[]): string { + if (offers.length === 0) return ''; + const ordered = orderOffersForPanel(offers); + const cheapestId = cheapestBuyableOfferId(ordered); + return ` +
+
So Sánh Giá Các Gian Hàng (${formatSafeNumber(offers.length, { min: 0 })})
+ ${ordered + .slice(0, 5) + .map((off) => renderOfferRowHtml(off, off.id === cheapestId)) + .join('')} +
+ `; +} + +/** + * The review card. With nothing analysed it says so instead of "100% genuine + * of 0 reviews", and an unknown rating shows no star (DOS/Rate#1301). + */ +export function renderReviewCardHtml( + reviews: ExtensionLookupData['reviewSummary'], + master: Pick< + NonNullable, + 'user_rating_weighted' | 'average_rating' + > | null +): string { + if (!reviews) return ''; + const star = ratingStarText( + master?.user_rating_weighted || master?.average_rating + ); + const analysed = Number(reviews.total_analyzed); + const hasReviews = Number.isFinite(analysed) && analysed > 0; + return ` +
+
Chất lượng đánh giá (đã lọc đánh giá ảo)
+
+
+
Điểm người dùng (đã lọc)
+
${star ? `${star} / 5` : 'Chưa có'}
+
+
+
Tỷ Lệ Đánh Giá Thật
+ ${ + hasReviews + ? `
${formatSafeNumber(reviews.authentic_pct, { min: 0, max: 100 })}% (${formatSafeNumber(analysed, { min: 0 })} đánh giá)
` + : '
Chưa có đánh giá
' + } +
+
+ ${ + hasReviews && reviews.ai_summary + ? `

${escapeHtml(reviews.ai_summary)}

` + : '' + } +
+ `; +} + +/** + * The lookup found no product, or the page is an accessory: the panel shows + * a short state, not a product card (DOS/Rate#1299). + */ +export function isLookupWithoutProduct( + lookup: Pick< + ExtensionLookupData, + 'matchStatus' | 'isAccessoryOrPart' | 'priceAssessment' + > | null +): boolean { + if (!lookup) return true; + return ( + lookup.matchStatus === 'not_found' || + lookup.matchStatus === 'accessory_skip' || + lookup.isAccessoryOrPart || + lookup.priceAssessment?.verdict === 'accessory_skip' + ); +} + +export function renderNotFoundHtml( + signal: Pick, + apiBaseUrl: string, + accessory: boolean +): string { + const query = String(signal.title ?? '').trim(); + const searchUrl = resolveSafeApiRelativeUrl( + apiBaseUrl, + query ? `/inspect?q=${encodeURIComponent(query)}` : '/inspect', + '/inspect' + ); + return ` +
+
Chưa có sản phẩm này trên Rate.vn
+ ${query ? `
${escapeHtml(query)}
` : ''} +

+ ${ + accessory + ? 'Đây có vẻ là phụ kiện hoặc linh kiện. Rate.vn chưa so sánh giá cho loại hàng này.' + : 'Rate.vn chưa có dữ liệu giá và đánh giá cho sản phẩm này.' + } +

+ +
+ `; +} + +export type LookupFailureKind = 'rate_limited' | 'server' | 'network' | 'other'; + +/** `null` status: the request never got an answer (DOS/Rate#1300). */ +export function classifyLookupFailure( + status: number | null | undefined +): LookupFailureKind { + if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) { + return 'network'; + } + if (status === 429) return 'rate_limited'; + if (status >= 500) return 'server'; + return 'other'; +} + +const LOOKUP_FAILURE_TEXT: Record = { + rate_limited: 'Bạn tra cứu hơi nhanh, thử lại sau ít phút.', + server: 'Rate.vn đang gặp sự cố, thử lại sau.', + network: 'Không kết nối được Rate.vn. Kiểm tra mạng rồi thử lại.', + other: 'Không tra cứu được sản phẩm này lúc này.', +}; + +/** An error card with a retry button (`data-action="retry"`), #1300. */ +export function renderLookupErrorHtml( + kind: LookupFailureKind, + productTitle?: string | null +): string { + const title = String(productTitle ?? '').trim(); + return ` + + `; +} + +export interface PanelActionLink { + href: string; + label: string; + primary?: boolean; +} + +/** + * The product card's links, two per row, each once: no rival means no + * versus link, not a second "Kiểm tra chi tiết" (DOS/Rate#1299). `href` + * values are already escaped by resolveSafeApiRelativeUrl. + */ +export function renderActionRowsHtml(links: PanelActionLink[]): string { + const rows: string[] = []; + for (let i = 0; i < links.length; i += 2) { + const cells = links + .slice(i, i + 2) + .map( + (l) => + `${escapeHtml(l.label)}` + ) + .join('\n '); + rows.push( + `
0 ? ' style="margin-top: 6px;"' : ''}> + ${cells} +
` + ); + } + return rows.join('\n '); +} diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html index 2fc056ca1..4d83deef8 100644 --- a/apps/extension/src/sidepanel/sidepanel.html +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -182,6 +182,31 @@ color: #34d399; white-space: nowrap; } + /* Sold-out, discontinued and bait rows: a last price, not an offer (DOS/Rate#1297). */ + .offer-price-muted { + color: var(--text-muted); + font-weight: 600; + } + .offer-mark { + display: inline-block; + font-size: 12px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + margin-left: 4px; + } + .offer-mark-muted { + background: rgba(156, 163, 175, 0.18); + color: #d1d5db; + } + .offer-mark-warn { + background: rgba(245, 158, 11, 0.18); + color: #fbbf24; + } + .offer-mark-best { + background: rgba(45, 186, 105, 0.18); + color: #34d399; + } .btn-row { display: flex; gap: 8px; @@ -213,6 +238,9 @@ text-align: center; text-decoration: none; } + button.btn-secondary { + font-family: inherit; + } .privacy-row { display: flex; align-items: flex-start; @@ -237,7 +265,7 @@
- Rate.vn Intelligence + Rate.vn
@@ -247,6 +275,7 @@
-
+

Đang đồng bộ thông tin sản phẩm từ trang đang mở...

@@ -273,12 +302,12 @@
- +
diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index 78c848455..bff9d17d8 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -10,24 +10,25 @@ import { filterValidSparklinePrices, formatSafeExtensionVnd, formatSafeNumber, - normalizeFiveStarRating, resolveSafeApiRelativeUrl, sanitizeVerdictClassSuffix, } from '../security-guards.js'; +import { + classifyLookupFailure, + escapeHtml, + isLookupWithoutProduct, + renderActionRowsHtml, + renderLookupErrorHtml, + renderNotFoundHtml, + renderOffersCardHtml, + renderReviewCardHtml, + type PanelActionLink, +} from './panel-view.js'; function formatVnd(val: number | null | undefined): string { return formatSafeExtensionVnd(val); } -function escapeHtml(raw: string): string { - return raw - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - function buildSvgSparkline(points: ExtensionPriceHistoryPoint[]): string { if (!points || points.length < 2) { return `
Đang tích lũy thêm các mốc thời gian lịch sử giá.
`; @@ -160,7 +161,7 @@ async function fetchAndRenderDealRadar(
${escapeHtml(s.productName)}
- ${escapeHtml(s.cheapestPlatform.toUpperCase())} (${formatVnd(s.minPrice)}) vs ${escapeHtml(s.priciestPlatform.toUpperCase())} (${formatVnd(s.maxPrice)}) + ${escapeHtml(s.cheapestPlatform.toUpperCase())} (${formatVnd(s.minPrice)}) so với ${escapeHtml(s.priciestPlatform.toUpperCase())} (${formatVnd(s.maxPrice)})
@@ -178,7 +179,7 @@ async function fetchAndRenderDealRadar( traps.length > 0 ? `
${escapeHtml(m.platform.toUpperCase())} (${formatSafeNumber(m.product_count, { min: 0 })} SP) - Rủi ro lừa đảo: ${formatSafeNumber(m.dosafe_risk_score, { min: 0, max: 100 })} | Rủi ro đánh giá: ${reviewRiskText(m.review_risk_score)}
- Trust ${formatSafeNumber(m.trust_score, { min: 0, max: 100 })}/100 + Tin cậy ${formatSafeNumber(m.trust_score, { min: 0, max: 100 })}/100
`; @@ -326,15 +327,18 @@ function renderPanelContent( return; } - if (!lookup) { + // Not found or an accessory: a short state with a search link, no product + // card and no product buttons (DOS/Rate#1299). A failed lookup never gets + // here; it renders an error card (DOS/Rate#1300). + if (!lookup || isLookupWithoutProduct(lookup)) { + const accessory = Boolean( + lookup && + (lookup.isAccessoryOrPart || + lookup.matchStatus === 'accessory_skip' || + lookup.priceAssessment?.verdict === 'accessory_skip') + ); root.innerHTML = ` -
-
Không kết nối được Rate API
-
${escapeHtml(signal.title)}
-

- Kiểm tra lại địa chỉ máy chủ Rate API (${escapeHtml(settings.apiBaseUrl)}). -

-
+ ${renderNotFoundHtml(signal, settings.apiBaseUrl, accessory)}
`; @@ -408,66 +412,17 @@ function renderPanelContent( const offersList = lookup.allOffers.length > 0 ? lookup.allOffers : lookup.topCheaperOffers; - const offersHtml = - offersList.length > 0 - ? ` -
-
So Sánh Giá Các Gian Hàng (${offersList.length})
- ${offersList - .slice(0, 5) - .map( - (off) => ` -
-
-
- ${escapeHtml(off.merchant.name)} - ${off.merchant.is_official ? '[Mall]' : ''} -
-
- ${escapeHtml(off.platform.toUpperCase())} - ★ ${formatSafeNumber(off.rating_star, { min: 0, max: 5, decimals: 1 })} - Rủi ro shop: ${formatSafeNumber(off.merchant.risk_score, { min: 0, max: 100 })}/100 -
-
- - ${formatVnd(off.price_current)} - -
- ` - ) - .join('')} -
- ` - : ''; + const offersHtml = renderOffersCardHtml(offersList); - const reviewHtml = reviews - ? ` -
-
Chất lượng đánh giá (đã lọc đánh giá ảo)
-
-
-
Điểm User Đã Lọc
-
★ ${formatSafeNumber(normalizeFiveStarRating(master?.user_rating_weighted || master?.average_rating) ?? 0, { min: 0, max: 5, decimals: 1 })} / 5
-
-
-
Tỷ Lệ Đánh Giá Thật
-
${formatSafeNumber(reviews.authentic_pct, { min: 0, max: 100 })}% (${formatSafeNumber(reviews.total_analyzed, { min: 0 })} đánh giá)
-
-
- ${ - reviews.ai_summary - ? `

${escapeHtml(reviews.ai_summary)}

` - : '' - } -
- ` - : ''; + const reviewHtml = renderReviewCardHtml(reviews, master); const merchantRiskHtml = merchant ? `
-
Độ Tin Cậy Gian Hàng (Dual Risk Score)
+
Độ Tin Cậy Gian Hàng
-
Trust Score
+
Điểm tin cậy
${formatSafeNumber(merchant.trust_score, { min: 0, max: 100 })}/100
@@ -565,7 +520,8 @@ function renderPanelContent( const compareUrl = resolveSafeApiRelativeUrl( settings.apiBaseUrl, lookup.compareUrl, - `/compare?cat=${categorySlug}` + // The compare page reads `category` (DOS/Rate#1299). + `/compare?category=${categorySlug}` ); const leaderboardUrl = resolveSafeApiRelativeUrl( settings.apiBaseUrl, @@ -576,7 +532,7 @@ function renderPanelContent( ? resolveSafeApiRelativeUrl( settings.apiBaseUrl, lookup.versusUrl, - `/compare?cat=${categorySlug}` + `/compare?category=${categorySlug}` ) : null; const inspectUrl = resolveSafeApiRelativeUrl( @@ -609,6 +565,17 @@ function renderPanelContent( : '' : ''; + // Each link once; with no rival the versus slot is simply absent + // (DOS/Rate#1299). + const actionLinks: PanelActionLink[] = [ + { href: detailUrl, label: 'Xem Trên Rate.vn', primary: true }, + { href: compareUrl, label: 'So Sánh Cấu Hình' }, + { href: leaderboardUrl, label: '🏆 Bảng Xếp Hạng' }, + ...(versusUrl ? [{ href: versusUrl, label: '⚔️ Đối Đầu 1v1' }] : []), + { href: inspectUrl, label: '🔬 Kiểm tra chi tiết' }, + { href: watchlistUrl, label: '🔔 Canh Giá Thật' }, + ]; + root.innerHTML = `
@@ -627,22 +594,7 @@ function renderPanelContent(
${formatVnd(assessment.marketMedianPrice)}
- - - + ${renderActionRowsHtml(actionLinks)}
${leaderboardCardHtml} ${variantButtonsHtml} @@ -687,6 +639,7 @@ async function loadSidePanel(selectedVariant?: string): Promise { settings?: ExtensionSettings; signal?: ExtractedPageProductSignal | null; data?: ExtensionLookupData | null; + status?: number | null; }; const settings: ExtensionSettings = res?.settings || { @@ -701,6 +654,13 @@ async function loadSidePanel(selectedVariant?: string): Promise { inpApiBase.value = settings.apiBaseUrl; } + if (res?.ok === false) { + renderLookupError(root, res.status, res.signal?.title, () => { + void loadSidePanel(selectedVariant); + }); + return; + } + renderPanelContent( root, res?.signal || null, @@ -710,16 +670,32 @@ async function loadSidePanel(selectedVariant?: string): Promise { void loadSidePanel(variantLabel); } ); - } catch (err) { - root.innerHTML = ` -
-
Lỗi tải dữ liệu
-

${escapeHtml(err instanceof Error ? err.message : 'Không thể tải dữ liệu.')}

-
- `; + } catch { + renderLookupError(root, null, null, () => { + void loadSidePanel(selectedVariant); + }); } } +/** + * A failed lookup shows why and a retry button, never "not a product page" + * or the server settings hint (DOS/Rate#1300). + */ +function renderLookupError( + root: HTMLElement, + status: number | null | undefined, + productTitle: string | null | undefined, + retry: () => void +): void { + root.innerHTML = renderLookupErrorHtml( + classifyLookupFailure(status), + productTitle + ); + root + .querySelector('[data-action="retry"]') + ?.addEventListener('click', retry); +} + async function runQuickQuery(query: string): Promise { const root = document.getElementById('panel-root'); if (!root || !query.trim()) return; @@ -740,6 +716,7 @@ async function runQuickQuery(query: string): Promise { signal?: ExtractedPageProductSignal | null; data?: ExtensionLookupData | null; error?: string; + status?: number | null; }; const settings: ExtensionSettings = res?.settings || { @@ -747,6 +724,13 @@ async function runQuickQuery(query: string): Promise { crowdContributeOptIn: false, }; + if (res?.ok === false) { + renderLookupError(root, res.status, res.signal?.title ?? query, () => { + void runQuickQuery(query); + }); + return; + } + renderPanelContent( root, res?.signal || null, @@ -756,13 +740,10 @@ async function runQuickQuery(query: string): Promise { void loadSidePanel(variantLabel); } ); - } catch (err) { - root.innerHTML = ` -
-
Lỗi tra cứu nhanh
-

${escapeHtml(err instanceof Error ? err.message : 'Không thể kiểm tra sản phẩm.')}

-
- `; + } catch { + renderLookupError(root, null, query, () => { + void runQuickQuery(query); + }); } } diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index be40a34db..3ecdc75a7 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -25,6 +25,11 @@ import { selectFreshestSessionSignal, shouldRenderTrustPill, } from './security-guards.js'; +import type { + ExtensionLookupData, + ExtensionOfferItem, + ExtensionPriceVerdict, +} from './types.js'; import * as extModule from '../../web/src/server/rate/extension.js'; import * as rateApiRootMod from '../../web/src/lib/rate-api.js'; const { @@ -3670,6 +3675,393 @@ async function runTests() { ); } + // Side panel price list: order and marks (DOS/Rate#1297) + { + const { + cheapestBuyableOfferId, + offerMarks, + orderOffersForPanel, + renderOffersCardHtml, + } = await import('./sidepanel/panel-view.js'); + const offer = ( + id: string, + price: number, + extra: Partial = {} + ): ExtensionOfferItem => ({ + id, + platform: 'shopee', + title: id, + price_current: price, + price_original: null, + rating_star: 4.8, + rating_count: 10, + historical_sold: 0, + url: `https://shopee.vn/${id}-i.1.2`, + merchant: { + id: `m-${id}`, + name: `Shop ${id}`, + is_official: false, + risk_score: 12, + dosafe_risk_score: 5, + review_risk_score: null, + }, + ...extra, + }); + const offers = [ + offer('sold', 9_000_000, { availability: 'out_of_stock' }), + offer('bait', 2_000_000, { implausiblyLow: true }), + offer('b', 21_000_000), + offer('gone', 8_000_000, { availability: 'discontinued' }), + offer('a', 20_000_000, { availability: 'in_stock' }), + offer('a2', 20_000_000), + ]; + assert.deepEqual( + orderOffersForPanel(offers).map((o) => o.id), + ['a', 'a2', 'b', 'bait', 'gone', 'sold'], + 'in-stock plausible offers first, by price, ties in server order' + ); + assert.equal(cheapestBuyableOfferId(offers), 'a'); + assert.equal( + cheapestBuyableOfferId([offers[0]!, offers[1]!]), + null, + 'a sold-out or bait row is never the cheapest' + ); + assert.deepEqual(offerMarks(offers[0]!), ['Hết hàng']); + assert.deepEqual(offerMarks(offers[3]!), ['Ngừng bán']); + assert.deepEqual(offerMarks(offers[1]!), ['Giá thấp bất thường']); + assert.deepEqual(offerMarks(offers[2]!), []); + + const html = renderOffersCardHtml(offers); + assert.ok(html.includes('So Sánh Giá Các Gian Hàng (6)')); + assert.ok( + html.indexOf('Shop a\n') < html.indexOf('Shop bait'), + 'the cheapest real offer is listed before the bait row' + ); + assert.ok(html.includes('Giá thấp bất thường')); + assert.ok(html.includes('Ngừng bán')); + assert.ok( + !html.includes('Shop sold'), + 'only the first five rows show; the sold-out row is last' + ); + assert.equal((html.match(/Rẻ nhất/g) || []).length, 1); + assert.ok( + /Shop a\s+(?:]*>Rẻ nhất)/.test(html), + 'the cheapest mark sits on the in-stock row' + ); + assert.equal((html.match(/offer-price-muted/g) || []).length, 2); + assert.ok(html.includes('Rủi ro tổng hợp: 12/100'), '#1280 wording'); + assert.ok(!html.includes('Rủi ro shop')); + assert.ok(!html.includes('[Mall]')); + assert.equal(renderOffersCardHtml([]), ''); + const escaped = renderOffersCardHtml([ + offer('x', 1_000_000, { + merchant: { ...offer('x', 1).merchant, name: '' }, + }), + ]); + assert.ok(!escaped.includes(' = {}, + implausiblyLow?: boolean + ) => + ({ + matchStatus: 'exact_listing', + isAccessoryOrPart: false, + priceAssessment: { verdict, implausiblyLow }, + ...extra, + }) as unknown as ExtensionLookupData; + assert.deepEqual(resolveActionBadge(null), { text: '', color: null }); + assert.deepEqual( + resolveActionBadge(lookup('insufficient_data', {}, true)), + { text: '!', color: '#f59e0b' }, + 'a bait price warns' + ); + assert.equal(resolveActionBadge(lookup('insufficient_data')).text, '?'); + assert.equal(resolveActionBadge(lookup('accessory_skip')).text, ''); + assert.equal( + resolveActionBadge( + lookup('fair_market_price', { matchStatus: 'accessory_skip' }) + ).text, + '' + ); + assert.equal( + resolveActionBadge( + lookup('fair_market_price', { isAccessoryOrPart: true }) + ).text, + '' + ); + assert.equal( + resolveActionBadge(lookup('genuine_deal', { matchStatus: 'not_found' })) + .text, + '' + ); + assert.equal(resolveActionBadge(lookup('inflated_fake_sale')).text, 'ẢO!'); + assert.equal(resolveActionBadge(lookup('genuine_deal')).text, 'TỐT'); + assert.equal(resolveActionBadge(lookup('above_market')).text, 'CAO'); + assert.equal(resolveActionBadge(lookup('fair_market_price')).text, 'OK'); + for (const verdict of [ + 'insufficient_data', + 'accessory_skip', + ] as ExtensionPriceVerdict[]) { + assert.notEqual(resolveActionBadge(lookup(verdict)).text, 'OK', verdict); + } + assert.equal( + resolvePillTheme('insufficient_data', true).border, + '#f59e0b', + 'a bait price uses the warning pill' + ); + assert.equal(resolvePillTheme('insufficient_data').border, '#3b82f6'); + assert.equal(resolvePillTheme('inflated_fake_sale').border, '#d83030'); + console.log(' ✅ [Badge and pill per verdict (#1298)] Verified'); + } + + // Not found and accessory states, action links (DOS/Rate#1299) + { + const { isLookupWithoutProduct, renderActionRowsHtml, renderNotFoundHtml } = + await import('./sidepanel/panel-view.js'); + const base = { + matchStatus: 'exact_listing', + isAccessoryOrPart: false, + priceAssessment: { verdict: 'fair_market_price' }, + } as unknown as ExtensionLookupData; + assert.equal(isLookupWithoutProduct(null), true); + assert.equal(isLookupWithoutProduct(base), false); + assert.equal( + isLookupWithoutProduct({ ...base, matchStatus: 'not_found' }), + true + ); + assert.equal( + isLookupWithoutProduct({ ...base, matchStatus: 'accessory_skip' }), + true + ); + assert.equal( + isLookupWithoutProduct({ ...base, isAccessoryOrPart: true }), + true + ); + assert.equal( + isLookupWithoutProduct({ + ...base, + priceAssessment: { + ...base.priceAssessment, + verdict: 'accessory_skip', + }, + }), + true + ); + const notFound = renderNotFoundHtml( + { title: 'Ốp lưng iPhone 16 & ' }, + 'https://rate.vn', + false + ); + assert.ok(notFound.includes('Chưa có sản phẩm này trên Rate.vn')); + assert.ok( + notFound.includes( + `https://rate.vn/inspect?q=${encodeURIComponent('Ốp lưng iPhone 16 & ').replace(/&/g, '&')}` + ), + 'search link carries the page title' + ); + assert.ok(!notFound.includes(''), 'the title is escaped'); + for (const product of [ + 'Trung Vị Thị Trường', + 'smartphones', + 'Bảng Xếp Hạng', + 'Canh Giá', + ]) { + assert.ok(!notFound.includes(product), `no product UI: ${product}`); + } + assert.ok( + renderNotFoundHtml( + { title: 'Cáp sạc' }, + 'https://rate.vn', + true + ).includes('phụ kiện') + ); + const rows = renderActionRowsHtml([ + { href: 'https://rate.vn/p', label: 'Xem Trên Rate.vn', primary: true }, + { href: 'https://rate.vn/compare?category=tablets', label: 'So Sánh' }, + { href: 'https://rate.vn/leaderboard', label: 'Bảng Xếp Hạng' }, + { href: 'https://rate.vn/inspect?q=x', label: '🔬 Kiểm tra chi tiết' }, + { href: 'https://rate.vn/watchlist', label: '🔔 Canh Giá Thật' }, + ]); + assert.equal((rows.match(/Kiểm tra chi tiết/g) || []).length, 1); + assert.equal((rows.match(/class="btn-row"/g) || []).length, 3); + assert.ok(rows.includes('Canh Giá Thật')); + const panelSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.ts'), + 'utf8' + ); + assert.ok( + !/\/compare\?cat=/.test(panelSource), + 'the compare page reads ?category=' + ); + console.log( + ' ✅ [Not found and accessory states, single inspect link (#1299)] Verified' + ); + } + + // Lookup error states (DOS/Rate#1300) + { + const { classifyLookupFailure, renderLookupErrorHtml } = + await import('./sidepanel/panel-view.js'); + assert.equal(classifyLookupFailure(429), 'rate_limited'); + assert.equal(classifyLookupFailure(500), 'server'); + assert.equal(classifyLookupFailure(503), 'server'); + assert.equal(classifyLookupFailure(null), 'network'); + assert.equal(classifyLookupFailure(undefined), 'network'); + assert.equal(classifyLookupFailure(404), 'other'); + const limited = renderLookupErrorHtml('rate_limited', 'iPhone 16'); + assert.ok(limited.includes('Bạn tra cứu hơi nhanh, thử lại sau ít phút')); + assert.ok(limited.includes('data-action="retry"')); + assert.ok(limited.includes('iPhone 16')); + assert.ok( + renderLookupErrorHtml('server').includes('Rate.vn đang gặp sự cố') + ); + assert.ok( + renderLookupErrorHtml('network').includes('Không kết nối được Rate.vn') + ); + for (const kind of ['rate_limited', 'server', 'network'] as const) { + const html = renderLookupErrorHtml(kind); + assert.ok(!html.includes('Chưa nhận diện trang sản phẩm'), kind); + assert.ok(!/máy chủ|Rate API/.test(html), `${kind}: no settings hint`); + } + // The content script drops the pill and the worker clears the badge on + // any failure, not only on an empty answer. + const contentSource = fs.readFileSync( + path.resolve(__dirname, 'content/content-script.ts'), + 'utf8' + ); + assert.ok(!contentSource.includes('response?.ok && !response.data')); + const workerSource = fs.readFileSync( + path.resolve(__dirname, 'background/service-worker.ts'), + 'utf8' + ); + assert.ok(workerSource.includes('status: outcome.status')); + assert.ok( + workerSource.includes('await updateActionBadge(sender.tab?.id, null)') + ); + console.log( + ' ✅ [Lookup error states and stale pill clearing (#1300)] Verified' + ); + } + + // Empty reviews and unknown ratings (DOS/Rate#1301) + { + const { ratingStarText, renderReviewCardHtml } = + await import('./sidepanel/panel-view.js'); + const empty = renderReviewCardHtml( + { + total_analyzed: 0, + authentic_pct: 100, + suspicious_pct: 0, + fake_pct: 0, + pros: [], + cons: [], + ai_summary: null, + }, + { user_rating_weighted: 0, average_rating: 0 } + ); + assert.ok(empty.includes('Chưa có đánh giá')); + assert.ok(!empty.includes('100%'), 'no percentage when nothing analysed'); + assert.ok(!empty.includes('★'), 'no star for an unknown rating'); + assert.ok(!empty.includes('0.0')); + const filled = renderReviewCardHtml( + { + total_analyzed: 40, + authentic_pct: 85, + suspicious_pct: 10, + fake_pct: 5, + pros: [], + cons: [], + ai_summary: null, + }, + { user_rating_weighted: 4.6, average_rating: 4.5 } + ); + assert.ok(filled.includes('★ 4.6 / 5')); + assert.ok(filled.includes('85% (40 đánh giá)')); + assert.equal(ratingStarText(0), null); + assert.equal(ratingStarText(null), null); + assert.equal(ratingStarText(4.75), '★ 4.8'); + console.log(' ✅ [Empty reviews and unknown ratings (#1301)] Verified'); + } + + // Vietnamese labels and accessible names (DOS/Rate#1302) + { + const { buildPillAriaLabel } = await import('./verdict-display.js'); + const panelHtml = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.html'), + 'utf8' + ); + const panelSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.ts'), + 'utf8' + ); + const viewSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/panel-view.ts'), + 'utf8' + ); + for (const english of [ + 'Rate.vn Intelligence', + 'Dual Risk Score', + 'Trust Score', + 'Trust ${', + 'Fake Sale', + '[Mall]', + 'Rate API BFF', + 'Rủi ro shop', + 'Opt-In', + 'Điểm User', + ]) { + for (const [name, text] of [ + ['sidepanel.html', panelHtml], + ['sidepanel.ts', panelSource], + ['panel-view.ts', viewSource], + ] as const) { + assert.ok(!text.includes(english), `${name} still says "${english}"`); + } + } + assert.ok(panelSource.includes('Điểm tin cậy')); + assert.ok( + /
/.test(panelHtml), + 'results are announced' + ); + const quickInput = panelHtml.slice( + panelHtml.indexOf('id="inp-quick-query"'), + panelHtml.indexOf('/>', panelHtml.indexOf('id="inp-quick-query"')) + ); + assert.ok( + /aria-label="[^"]+"/.test(quickInput), + 'quick lookup is labelled' + ); + const pillLabel = buildPillAriaLabel( + 'Giá thấp bất thường', + 'Đáy thị trường: 20.000.000 ₫' + ); + assert.ok(pillLabel.includes('Giá thấp bất thường'), 'verdict is spoken'); + assert.ok(pillLabel.includes('Mở bảng phân tích giá')); + const contentSource = fs.readFileSync( + path.resolve(__dirname, 'content/content-script.ts'), + 'utf8' + ); + assert.ok( + contentSource.includes( + 'aria-label="${escapeHtml(buildPillAriaLabel(assessment.badgeLabel, cheaperSummary))}"' + ), + 'the pill names its verdict' + ); + console.log( + ' ✅ [Vietnamese labels and accessible names (#1302)] Verified' + ); + } + console.log('🎉 All Rate Browser Extension MV3 v2 tests passed!'); } diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index b63ed7f9d..50cda687a 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -48,6 +48,11 @@ export interface ExtensionPriceAssessment { historicalMaxPrice: number | null; cheaperOfferCount: number; maxSavingsAmount: number; + /** + * Set with `insufficient_data` when the page's price is far below every + * other store (DOS/Rate#797): a warning, never "OK" (DOS/Rate#1298). + */ + implausiblyLow?: boolean; } export interface ExtensionOfferItem { @@ -60,6 +65,13 @@ export interface ExtensionOfferItem { rating_count: number; historical_sold: number; url: string; + /** + * A sold-out or discontinued listing keeps its last price + * (DOS/Rate#1141, #1297); absent when the server did not read it. + */ + availability?: 'in_stock' | 'out_of_stock' | 'discontinued' | null; + /** A bait price far below every other in-stock store (DOS/Rate#1297). */ + implausiblyLow?: boolean; merchant: { id: string; name: string; diff --git a/apps/extension/src/verdict-display.ts b/apps/extension/src/verdict-display.ts new file mode 100644 index 000000000..f2953a8c1 --- /dev/null +++ b/apps/extension/src/verdict-display.ts @@ -0,0 +1,110 @@ +import type { ExtensionLookupData } from './types.js'; + +type BadgeLookup = Pick< + ExtensionLookupData, + 'matchStatus' | 'isAccessoryOrPart' | 'priceAssessment' +>; + +export interface ActionBadge { + text: string; + /** `null`: leave the colour as it is (the badge is empty). */ + color: string | null; +} + +const WARNING_COLOR = '#f59e0b'; + +/** + * The toolbar badge for a lookup. A bait price warns, and an unchosen size + * or an accessory never reads "OK" (DOS/Rate#1298). + */ +export function resolveActionBadge( + lookup: BadgeLookup | null | undefined +): ActionBadge { + if (!lookup || lookup.matchStatus === 'not_found') { + return { text: '', color: null }; + } + const verdict = lookup.priceAssessment?.verdict; + if ( + lookup.isAccessoryOrPart || + lookup.matchStatus === 'accessory_skip' || + verdict === 'accessory_skip' + ) { + return { text: '', color: null }; + } + if (lookup.priceAssessment?.implausiblyLow) { + return { text: '!', color: WARNING_COLOR }; + } + switch (verdict) { + case 'inflated_fake_sale': + return { text: 'ẢO!', color: '#d83030' }; + case 'genuine_deal': + return { text: 'TỐT', color: '#2dba69' }; + case 'above_market': + return { text: 'CAO', color: WARNING_COLOR }; + case 'fair_market_price': + return { text: 'OK', color: '#3b82f6' }; + default: + return { text: '?', color: '#6b7280' }; + } +} + +export interface PillTheme { + bg: string; + border: string; + dot: string; +} + +/** The trust pill's colours; a bait price uses the warning theme (#1298). */ +export function resolvePillTheme( + verdict: string, + implausiblyLow?: boolean | null +): PillTheme { + if (implausiblyLow) { + return { + bg: 'rgba(245, 158, 11, 0.16)', + border: WARNING_COLOR, + dot: WARNING_COLOR, + }; + } + switch (verdict) { + case 'inflated_fake_sale': + return { + bg: 'rgba(216, 48, 48, 0.16)', + border: '#d83030', + dot: '#d83030', + }; + case 'genuine_deal': + return { + bg: 'rgba(45, 186, 105, 0.16)', + border: '#2dba69', + dot: '#2dba69', + }; + case 'above_market': + return { + bg: 'rgba(245, 158, 11, 0.16)', + border: WARNING_COLOR, + dot: WARNING_COLOR, + }; + default: + return { + bg: 'rgba(59, 130, 246, 0.16)', + border: '#3b82f6', + dot: '#3b82f6', + }; + } +} + +/** + * The pill's accessible name: the verdict and the summary a sighted user + * reads, then what activating it does (DOS/Rate#1302). Plain text; the + * caller escapes it for the attribute. + */ +export function buildPillAriaLabel( + badgeLabel: string, + summary: string +): string { + const parts = [`Rate.vn: ${badgeLabel.trim()}`]; + if (summary.trim()) parts.push(summary.trim()); + parts.push('Mở bảng phân tích giá'); + return parts.join('. '); +} diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index 892925eac..0fb43165e 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -2,6 +2,7 @@ import 'server-only'; import { applyCategorySpecTemplate, getCategorySpecTemplate, + implausiblyLowOfferIds, isImplausiblyLowPrice, minimumListingPriceVnd, plausibleOffers, @@ -211,6 +212,13 @@ export interface ExtensionPriceAssessment { implausiblyLow?: true; } +/** + * An offer in the lookup's lists. `implausiblyLow` marks a bait price far + * below every other in-stock store, so the side panel can flag the row + * (DOS/Rate#797, #1297). + */ +export type ExtensionOffer = MerchantOffer & { implausiblyLow?: true }; + export interface ExtensionLookupResponse { matchStatus: ExtensionMatchStatus; platform: SupportedEcommercePlatform | null; @@ -224,8 +232,8 @@ export interface ExtensionLookupResponse { priceAssessment: ExtensionPriceAssessment; masterProduct: MasterProduct | null; matchedOffer: MerchantOffer | null; - topCheaperOffers: MerchantOffer[]; - allOffers: MerchantOffer[]; + topCheaperOffers: ExtensionOffer[]; + allOffers: ExtensionOffer[]; merchantProfile: MerchantSummary | null; reviewSummary: ReviewAnalysisSummary | null; priceHistory: MasterProductPriceHistorySummary | null; @@ -1440,6 +1448,13 @@ export async function lookupExtensionProduct( : null; const radarSignal = computeProductRadarSignal(matchedMaster, offers); + // Bait prices are judged among the in-stock listings, as the market numbers + // are; the flag lets the side panel mark the row (DOS/Rate#1297). + const baitIds = implausiblyLowOfferIds(inStockOffers); + const flagBait = (o: MerchantOffer): ExtensionOffer => + baitIds.has(o.id) ? { ...o, implausiblyLow: true } : o; + const flaggedCheaperOffers = topCheaperOffers.map(flagBait); + return { matchStatus, platform, @@ -1456,8 +1471,8 @@ export async function lookupExtensionProduct( // storage or camera, no watch 5G (DOS/Rate#1184). masterProduct: applyCategorySpecTemplate(categoryPath, matchedMaster), matchedOffer, - topCheaperOffers, - allOffers: mode === 'full' ? offers : topCheaperOffers, + topCheaperOffers: flaggedCheaperOffers, + allOffers: mode === 'full' ? offers.map(flagBait) : flaggedCheaperOffers, merchantProfile, reviewSummary, priceHistory: mode === 'full' ? priceHistory : null, From d09d9c7993843dce68066d1c80f94347a99c723b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:38:55 +0700 Subject: [PATCH 2/3] fix(extension): keep the crowd signal and ignore late lookup answers Review of the previous commit: the opt-in crowd signal is sent whatever the lookup answered, as before (#1175); only the tab's latest lookup may set or clear its pill, badge and panel, so a late failure for an earlier product no longer clears the current one; an open side panel reloads when the page's lookup fails; a bait price shows the amber verdict style in the panel too. Co-Authored-By: Claude Opus 5.5 --- .../src/background/service-worker.ts | 27 ++++++++++++++++--- apps/extension/src/content/content-script.ts | 9 ++++++- apps/extension/src/sidepanel/sidepanel.html | 3 ++- apps/extension/src/sidepanel/sidepanel.ts | 7 ++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index b8295dc7e..2c2fe31ec 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -118,6 +118,9 @@ const SUPPORTED_CROWD_PLATFORMS = new Set([ 'phongvu', ]); +// The latest product lookup per tab (DOS/Rate#1300). +let detectRequestCounter = 0; +const latestDetectByTab = new Map(); async function maybeSendOptInCrowdSignal( rawSignal: ExtractedPageProductSignal ): Promise { @@ -205,6 +208,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (msgType === 'RATE_PRODUCT_DETECTED') { + const requestId = ++detectRequestCounter; + if (sender.tab?.id) latestDetectByTab.set(sender.tab.id, requestId); + // Only the tab's latest lookup may set its badge, stored lookup or + // panel: an earlier one can answer after a later one (DOS/Rate#1300). + const isLatest = () => + !sender.tab?.id || latestDetectByTab.get(sender.tab.id) === requestId; (async () => { try { const signal = sanitizeExtensionProductSignal( @@ -233,10 +242,23 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { await chrome.storage.session.set(signalPayload); const outcome = await fetchExtensionLookup(signal, 'pill'); + // Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it. + // Sent whatever the lookup answered, as before (#1175): the error + // states below change only what the user sees (#1300). + void maybeSendOptInCrowdSignal(signal); // A failed lookup clears the previous product's badge (#1300). const lookup = outcome.ok ? outcome.data : null; + if (!isLatest()) { + sendResponse({ ok: outcome.ok, data: lookup, stale: true }); + return; + } await updateActionBadge(tabId, lookup); if (!outcome.ok) { + // An open side panel reloads for this page instead of keeping the + // previous product (it listens to this key). + if (!isBackgroundTab) { + await chrome.storage.session.set({ lookup_tab_latest: null }); + } sendResponse({ ok: false, status: outcome.status, @@ -255,12 +277,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { await chrome.storage.session.set(lookupPayload); } - // Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it - void maybeSendOptInCrowdSignal(signal); - sendResponse({ ok: true, data: lookup }); } catch (err) { - await updateActionBadge(sender.tab?.id, null); + if (isLatest()) await updateActionBadge(sender.tab?.id, null); sendResponse({ ok: false, status: null, diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index f0b429498..effc4688d 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -217,6 +217,9 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { }); } +// Lookups sent from this page, so a late answer for an earlier product is +// ignored (DOS/Rate#1300). +let detectGeneration = 0; let lastProcessedSignature = ''; async function inspectCurrentPage(): Promise { @@ -231,6 +234,9 @@ async function inspectCurrentPage(): Promise { const signature = `${signal.canonicalUrl}|${signal.selectedVariant || ''}|${signal.price || ''}`; if (signature === lastProcessedSignature) return; lastProcessedSignature = signature; + // Only the latest page's answer may draw or clear the pill: on a + // single-page shop an earlier lookup can answer after a later one. + const generation = ++detectGeneration; try { const response = (await chrome.runtime.sendMessage({ @@ -238,6 +244,7 @@ async function inspectCurrentPage(): Promise { signal, })) as { ok?: boolean; data?: ExtensionLookupData | null }; + if (generation !== detectGeneration) return; if (response?.ok && response.data) { renderShadowTrustPill(response.data); } else { @@ -248,7 +255,7 @@ async function inspectCurrentPage(): Promise { } catch { // Messaging failed (page unloading, worker restarting): drop the pill // rather than show a stale one (DOS/Rate#1300). - removeTrustPillHost(); + if (generation === detectGeneration) removeTrustPillHost(); } }); } diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html index 4d83deef8..6478cb6e1 100644 --- a/apps/extension/src/sidepanel/sidepanel.html +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -97,7 +97,8 @@ color: #34d399; border: 1px solid rgba(45, 186, 105, 0.45); } - .verdict-above_market { + .verdict-above_market, + .verdict-implausibly_low { background: rgba(245, 158, 11, 0.18); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.45); diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index bff9d17d8..f24045b6d 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -579,7 +579,12 @@ function renderPanelContent( root.innerHTML = `
- ${escapeHtml(assessment.badgeLabel)} + ${escapeHtml(assessment.badgeLabel)} ${radarPillHtml}
${escapeHtml((sizeUnknown ? lookup.item?.name : master?.name) || signal.title)}
From 792c250e4c692a4d925eed25a7ecf47b738c3a24 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:39:27 +0700 Subject: [PATCH 3/3] fix(extension): no crowd signal after a network error, as before A network error used to throw before the opt-in crowd signal; the lookup now reports it as a status-less failure, so the signal is skipped there to keep what the extension sends unchanged (#1175). Co-Authored-By: Claude Opus 5.5 --- apps/extension/src/background/service-worker.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 2c2fe31ec..eaa7fcd5a 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -243,9 +243,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const outcome = await fetchExtensionLookup(signal, 'pill'); // Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it. - // Sent whatever the lookup answered, as before (#1175): the error - // states below change only what the user sees (#1300). - void maybeSendOptInCrowdSignal(signal); + // Sent whenever the server answered, as before (#1175): a network + // error threw before it then, so it still sends nothing (#1300). + if (outcome.ok || outcome.status !== null) { + void maybeSendOptInCrowdSignal(signal); + } // A failed lookup clears the previous product's badge (#1300). const lookup = outcome.ok ? outcome.data : null; if (!isLatest()) {