Skip to content
Merged
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
11 changes: 7 additions & 4 deletions apps/web/e2e/category-spec-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => {
await page.goto(BUD);
const specs = page.locator('#specs-section');
const headings = specs.locator('h3');
await expect(headings.first()).toHaveText(/^1\. Âm thanh/);
// One language and no number (DOS/Rate#1529).
await expect(headings.first()).toHaveText(/^Âm thanh/);
// Groups with no value are hidden (DOS/Rate#1186) and no sale status is
// invented (DOS/Rate#1193): the seeded bud has audio, battery and
// Bluetooth, nothing else.
await expect(headings).toHaveCount(3);
await expect(headings.nth(1)).toHaveText(/^2\. Pin/);
await expect(headings.nth(1)).toHaveText(/^Pin/);
await expect(specs).toContainText('E2E 10.5mm driver');
await expect(specs).toContainText('E2E ANC');
for (const text of [
Expand Down Expand Up @@ -62,11 +63,13 @@ test('a watch page never shows 5G', async ({ page }) => {
await expect(meta).toContainText('Apple');
await expect(meta).not.toContainText('5G');
const specs = page.locator('#specs-section');
await expect(specs).toContainText('Màn hình (Display)');
await expect(specs).toContainText('Màn hình');
// One language per page (DOS/Rate#1529).
await expect(specs).not.toContainText('(Display)');
// The seeded watch has no network value left once 5G is dropped, so the
// cellular group is hidden (DOS/Rate#1186); the 4G-only bands label is
// covered by product-spec-rows.test.ts.
await expect(specs).not.toContainText('Mạng Di động');
await expect(specs).not.toContainText('Mạng di động');
await expect(specs).not.toContainText('5G');
await expect(specs).not.toContainText('Camera');
});
12 changes: 6 additions & 6 deletions apps/web/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"catEarbudsUnit": "modelů sluchátek",
"boolYes": "Ano",
"boolNo": "Ne",
"group0Title": "0. Hodnocení a důvěryhodnost",
"group0Title": "Hodnocení a důvěryhodnost",
"rowRatingWeighted": "Skutečné hodnocení kupujících (bez falešných recenzí)",
"valAuthenticReviews": "★ {rating} / 5,0 ({count, plural, one {# ověřená recenze} few {# ověřené recenze} other {# ověřených recenzí}})",
"valNoReviews": "Zatím bez hodnocení",
Expand Down Expand Up @@ -229,7 +229,7 @@
"rowCamSecond": "Druhý fotoaparát (Ultraširokoúhlý)",
"rowCamThirdFourth": "Třetí a čtvrtý fotoaparát (Tele / Periskop)",
"rowCamVideo": "Záznam videa a blesk",
"rowCamFront": "Přední kamera (Selfie)",
"rowCamFront": "Přední kamera",
"rowBatCapacity": "Kapacita baterie",
"rowBatFastCharge": "Výkon rychlého nabíjení",
"rowBatDetails": "Technologie baterie, bezdrátové a reverzní nabíjení",
Expand Down Expand Up @@ -946,7 +946,7 @@
"listingsSectionSubtitle": "Porovnání cen u prodejce <value>{name}</value> s nejnižší tržní cenou a historickým mediánem",
"noListingsYet": "K tomuto obchodu zatím nejsou přiřazeny žádné produkty.",
"colListedProduct": "Nabízený produkt",
"colMasterProduct": "Standardizovaný model (Master Product)",
"colMasterProduct": "Standardizovaný model",
"colRecordedPrice": "Zaznamenaná cena",
"colPromoAudit": "Sleva (podle historie cen)",
"colAuditAndSource": "Kontrola a zdroj",
Expand Down Expand Up @@ -1796,11 +1796,11 @@
"jreviewsRatingHeader": "Hodnocení (JReviews)",
"ratingAll": "Vše",
"categoryHeader": "Kategorie zařízení",
"brandHeader": "Výrobce (Brand)",
"brandHeader": "Výrobce",
"priceRangeHeader": "Cenové rozpětí (Miliony VND)",
"ramHeader": "Kapacita RAM (Hardware)",
"ramHeader": "Kapacita RAM",
"storageHeader": "Interní úložiště (ROM)",
"featuresHeader": "Klíčové funkce (10 skupin parametrů)",
"featuresHeader": "Klíčové funkce",
"filteringBtn": "Filtrování dat...",
"applyFiltersBtn": "Použít filtry",
"clearAllFiltersBtn": "Vymazat všechny filtry",
Expand Down
8 changes: 4 additions & 4 deletions apps/web/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"catEarbudsUnit": "earbud models",
"boolYes": "Yes",
"boolNo": "No",
"group0Title": "0. Review scores and trust",
"group0Title": "Review scores and trust",
"rowRatingWeighted": "Verified buyer score (fake reviews filtered)",
"valAuthenticReviews": "★ {rating} / 5.0 ({count, plural, one {# verified review} other {# verified reviews}})",
"valNoReviews": "No reviews yet",
Expand Down Expand Up @@ -946,7 +946,7 @@
"listingsSectionSubtitle": "Comparing prices recorded at <value>{name}</value> against the market lowest price and historical median",
"noListingsYet": "No product listings linked to this merchant yet.",
"colListedProduct": "Listed product",
"colMasterProduct": "Canonical model (master product)",
"colMasterProduct": "Canonical model",
"colRecordedPrice": "Recorded price",
"colPromoAudit": "Discount (by price history)",
"colAuditAndSource": "Check and source",
Expand Down Expand Up @@ -1796,9 +1796,9 @@
"jreviewsRatingHeader": "Rating (JReviews)",
"ratingAll": "All",
"categoryHeader": "Device category",
"brandHeader": "Manufacturer (Brand)",
"brandHeader": "Manufacturer",
"priceRangeHeader": "Price range (million VND)",
"ramHeader": "RAM Capacity (Hardware)",
"ramHeader": "RAM Capacity",
"storageHeader": "Internal storage (ROM)",
"featuresHeader": "Key features",
"filteringBtn": "Filtering data...",
Expand Down
18 changes: 9 additions & 9 deletions apps/web/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"catEarbudsUnit": "mẫu tai nghe",
"boolYes": "Có",
"boolNo": "Không",
"group0Title": "0. Điểm đánh giá và độ tin cậy",
"group0Title": "Điểm đánh giá và độ tin cậy",
"rowRatingWeighted": "Điểm người mua thật (đã lọc đánh giá ảo)",
"valAuthenticReviews": "★ {rating} / 5.0 ({count} review thật)",
"valNoReviews": "Chưa có đánh giá",
Expand All @@ -219,7 +219,7 @@
"rowDisplayResolution": "Độ phân giải và mật độ điểm ảnh",
"rowDisplayRefresh": "Tần số quét",
"rowDisplayProtection": "Kính bảo vệ và tính năng hiển thị",
"rowHwChipset": "Vi xử lý (Chipset)",
"rowHwChipset": "Vi xử lý",
"rowHwCpuGpu": "Nhân CPU và đồ họa GPU",
"rowHwRam": "Dung lượng RAM",
"rowHwStorage": "Bộ nhớ trong (ROM)",
Expand All @@ -229,7 +229,7 @@
"rowCamSecond": "Camera thứ 2 (góc siêu rộng / phụ)",
"rowCamThirdFourth": "Camera thứ 3 và thứ 4 (tele / tiềm vọng)",
"rowCamVideo": "Quay video và đèn flash",
"rowCamFront": "Camera trước (selfie)",
"rowCamFront": "Camera trước",
"rowBatCapacity": "Dung lượng pin",
"rowBatFastCharge": "Công suất sạc nhanh",
"rowBatDetails": "Công nghệ pin, sạc không dây và sạc ngược",
Expand Down Expand Up @@ -946,7 +946,7 @@
"listingsSectionSubtitle": "Đối chiếu giá ghi nhận tại <value>{name}</value> với mức giá thấp nhất thị trường và trung vị lịch sử giá",
"noListingsYet": "Chưa có sản phẩm nào được liên kết với gian hàng này.",
"colListedProduct": "Sản phẩm niêm yết",
"colMasterProduct": "Dòng máy chuẩn hóa (master product)",
"colMasterProduct": "Dòng máy chuẩn hóa",
"colRecordedPrice": "Giá ghi nhận",
"colPromoAudit": "Khuyến mãi (theo lịch sử giá)",
"colAuditAndSource": "Kiểm tra và nguồn",
Expand Down Expand Up @@ -1423,7 +1423,7 @@
"pgPreset13Title": "Spec OpenAPI 3.1.0 (/openapi.json)",
"pgPreset13Desc": "Trả về spec OpenAPI 3.1.0 dạng JSON cho các endpoint REST API v1 của Rate.vn.",
"authPublic": "Công khai (CORS)",
"authOptIn": "Cần người dùng đồng ý (opt-in)",
"authOptIn": "Cần người dùng đồng ý",
"authBearer": "Bearer token (Supabase Auth)",
"cacheNoStoreQueue": "no-store (đưa vào hàng đợi rate.crawl_jobs)",
"cacheNoStoreRpc": "no-store (RPC rate.submit_community_review)",
Expand Down Expand Up @@ -1478,7 +1478,7 @@
"aboutQuickLinksTitle": "Liên kết nhanh trên hệ thống",
"aboutLinkSmartphones": "Danh mục điện thoại thông minh",
"aboutLinkLaptops": "Danh mục laptop & ultrabook",
"aboutLinkTablets": "Danh mục máy tính bảng (tablet)",
"aboutLinkTablets": "Danh mục máy tính bảng",
"aboutLinkFaq": "Câu hỏi thường gặp về phương pháp tính điểm",
"privacySeoTitle": "Chính sách bảo mật và dữ liệu | Rate.vn",
"privacySeoDesc": "Rate.vn không cần tài khoản để tra cứu. Khay so sánh lưu trên trình duyệt, bình chọn gắn với DOS ID và thông tin riêng của gian hàng không công khai.",
Expand Down Expand Up @@ -1796,11 +1796,11 @@
"jreviewsRatingHeader": "Điểm đánh giá (JReviews)",
"ratingAll": "Tất cả",
"categoryHeader": "Danh mục thiết bị",
"brandHeader": "Hãng sản xuất (Brand)",
"brandHeader": "Hãng sản xuất",
"priceRangeHeader": "Khoảng giá (Triệu VNĐ)",
"ramHeader": "Dung lượng RAM (Hardware)",
"ramHeader": "Dung lượng RAM",
"storageHeader": "Bộ nhớ trong (ROM)",
"featuresHeader": "Tính năng nổi bật (10 field groups)",
"featuresHeader": "Tính năng nổi bật",
"filteringBtn": "Đang lọc dữ liệu...",
"applyFiltersBtn": "Áp dụng bộ lọc",
"clearAllFiltersBtn": "Xóa tất cả bộ lọc",
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1465,16 +1465,11 @@ export async function ProductDetailView({
openOnHash="#specs-section"
collapsible={specGroups.length > 3}
>
{specGroups.map(({ group, rows }, i) => (
{specGroups.map(({ group, rows }) => (
<div key={group} className="col-md-6 col-12">
<div className="rounded-4 h-100 border bg-white p-4">
<h3 className="h6 fw-bold text-primary mb-3">
{specGroupHeading(
specUi,
group,
i + 1,
specTemplate.has5g
)}
{specGroupHeading(specUi, group, specTemplate.has5g)}
</h3>
<dl className="row small mb-0">
{rows.map(({ label, value, lang }) => (
Expand Down
33 changes: 15 additions & 18 deletions apps/web/src/components/compare/CompareWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1052,10 +1052,18 @@ export default function CompareWorkspace({
),
}))
.filter((group) => group.rows.length > 0);
// Spec groups are numbered from 1 after the market group.
const numberedSpecGroups = visibleSpecGroups.filter(
(group) => group.id !== 'market_trust'
);

// A spec group's heading in the page's language, for the table and the
// exports alike (DOS/Rate#1529); a watch's cellular heading has no "5G"
// (DOS/Rate#1166).
const groupHeading = (group: { id: string; title: string }) =>
group.id in specDict.groups
? specGroupHeading(
specDict,
group.id as SpecGroupKey,
getCategorySpecTemplate(activeCategory).has5g
)
: group.title;

const searchInputRef = useRef<HTMLInputElement | null>(null);
const searchBoxRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1479,7 +1487,7 @@ export default function CompareWorkspace({
const vals = products.map((p) => row.renderValue(p));
if (vals.every((v) => !v || v === '-')) continue;
rows.push(
[group.title, row.label, ...vals]
[groupHeading(group), row.label, ...vals]
.map((c) => escapeCsvCell(c))
.join(',')
);
Expand Down Expand Up @@ -1518,7 +1526,7 @@ export default function CompareWorkspace({
}),
matrix: specGroups.map((group) => ({
groupId: group.id,
groupTitle: group.title,
groupTitle: groupHeading(group),
// Like the CSV, no row that every product leaves empty.
rows: group.rows
.filter((row) =>
Expand Down Expand Up @@ -2559,18 +2567,7 @@ export default function CompareWorkspace({
1 +
(products.length < MAX_COMPARE_ITEMS ? 1 : 0);

// Numbered in the category's order, and a watch's
// cellular heading without "5G" (DOS/Rate#1166). The
// market group comes first, so spec groups start at 1.
const groupTitle =
group.id in specDict.groups
? specGroupHeading(
specDict,
group.id as SpecGroupKey,
numberedSpecGroups.indexOf(group) + 1,
getCategorySpecTemplate(activeCategory).has5g
)
: group.title;
const groupTitle = groupHeading(group);

return (
<React.Fragment key={group.id}>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/__tests__/product-detail-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ function main() {
'Hexa-core (2x4.05 GHz performance + 4x2.42 GHz efficiency)',
'vi'
),
'6 nhân (Hexa-core) (2x4.05 GHz hiệu năng + 4x2.42 GHz tiết kiệm điện)'
'6 nhân (2x4.05 GHz hiệu năng + 4x2.42 GHz tiết kiệm điện)'
);
assert.equal(
localizeSpecValue(
Expand Down
52 changes: 33 additions & 19 deletions apps/web/src/lib/__tests__/spec-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const readSource = (path: string) =>
const headings = (category: string, locale: string) => {
const ui = getSpecUiDictionary(locale);
const template = getCategorySpecTemplate(category);
return template.groups.map((group: SpecGroupKey, i: number) =>
specGroupHeading(ui, group, i + 1, template.has5g)
return template.groups.map((group: SpecGroupKey) =>
specGroupHeading(ui, group, template.has5g)
);
};

Expand Down Expand Up @@ -216,8 +216,11 @@ async function testSurfaces() {
// Compare headings come from the template too: no "5G" heading for a watch.
assert.match(
compare,
/specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*numberedSpecGroups\.indexOf\(group\) \+ 1,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/
/specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/
);
// The CSV and JSON exports use the same heading as the table.
assert.match(compare, /\[groupHeading\(group\), row\.label/);
assert.match(compare, /groupTitle: groupHeading\(group\)/);
const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx');
assert.match(detail, /categorySlug=\{categorySlug\}/);
assert.match(detail, /buildKeySpecLine\(product, categorySlug\)/);
Expand All @@ -229,12 +232,25 @@ async function testSurfaces() {
async function main() {
console.warn('🧪 Category spec template suite (DOS/Rate#1142)');

// Phones keep the ten numbered groups, worded as before.
// Phones keep the ten groups, each in the page's language only: no
// number and no English name in brackets (DOS/Rate#1529).
const phone = headings('smartphones', 'vi');
assert.equal(phone.length, 10);
assert.equal(phone[0], '1. Màn hình (Display)');
assert.equal(phone[5], '6. Mạng Di động & 5G (Cellular)');
assert.equal(phone[9], '10. Pháp lý, Chứng nhận & SAR (Regulatory)');
assert.equal(phone[0], 'Màn hình');
assert.equal(phone[1], 'Phần cứng & Hiệu năng');
assert.equal(phone[5], 'Mạng di động & 5G');
assert.equal(phone[9], 'Pháp lý, Chứng nhận & SAR');
assert.equal(headings('smartphones', 'en')[0], 'Display & panel');
for (const locale of ['vi', 'en', 'cs']) {
for (const h of [
...headings('smartphones', locale),
getSpecUiDictionary(locale).cellularNo5gGroup,
]) {
assert.doesNotMatch(h, /^\d/, `${locale}: ${h}`);
// Acronyms such as (MSRP) may stay; a Vietnamese label has none left.
if (locale === 'vi') assert.doesNotMatch(h, /\(/, h);
}
}
assert.deepEqual(headings('tablets', 'en'), headings('smartphones', 'en'));
assert.equal(
specSectionBadge(getSpecUiDictionary('vi'), 10),
Expand All @@ -247,25 +263,23 @@ async function main() {
const ui = getSpecUiDictionary(locale);
const buds = headings('earbuds', locale);
assert.equal(buds.length, 7);
assert.ok(buds[0]!.startsWith('1. '));
assert.ok(buds[0]!.endsWith(ui.groups.multimedia.replace(/^\d+\.\s*/, '')));
assert.equal(buds[0], ui.groups.multimedia);
for (const group of ['display', 'camera', 'cellular'] as const) {
const label = ui.groups[group].replace(/^\d+\.\s*/, '');
assert.ok(
!buds.some((h: string) => h.endsWith(label)),
!buds.includes(ui.groups[group]),
`earbuds (${locale}) must not show ${group}`
);
}
assert.equal(specSectionBadge(ui, buds.length).startsWith('7 '), true);
}
assert.deepEqual(headings('earbuds', 'en'), [
'1. Audio & multimedia',
'2. Battery & charging',
'3. Connectivity & sensors',
'4. Hardware & performance',
'5. Design & durability',
'6. Availability & MSRP',
'7. Regulatory, certifications & SAR',
'Audio & multimedia',
'Battery & charging',
'Connectivity & sensors',
'Hardware & performance',
'Design & durability',
'Availability & MSRP',
'Regulatory, certifications & SAR',
]);

// Smartwatches: no camera group and no "5G" anywhere in the headings.
Expand All @@ -277,7 +291,7 @@ async function main() {
watch.join(' | ')
);
}
assert.equal(headings('smartwatches', 'vi')[4], '5. Mạng Di động (Cellular)');
assert.equal(headings('smartwatches', 'vi')[4], 'Mạng di động');

// The card chips read only what the template leaves: an earbud has no
// "1GB RAM · 64GB" and a cellular watch no "5G".
Expand Down
Loading
Loading