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
3 changes: 2 additions & 1 deletion apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export async function ProductDetailView({
? bayesianSummary.bayesianRating
: rawUserRating;

const keySpecLine = buildKeySpecLine(product);
const keySpecLine = buildKeySpecLine(product, categorySlug);
const mainImage = firstImageUrl(product.images);

const canonicalUrl = `https://rate.vn${categoryBasePath}/${product.slug}`;
Expand Down Expand Up @@ -1562,6 +1562,7 @@ export async function ProductDetailView({
brand={product.brand}
specifications={specs || {}}
keySpecs={keySpecs || {}}
categorySlug={categorySlug}
radarSignal={radarSignal}
/>
<span className="badge bg-dark-subtle text-dark rounded-pill px-3 py-2">
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/v1/rate/compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@/server/rate';
import { computeProductRadarSignal } from '@/server/rate/deals';
import { radarExportFields } from '@/lib/deal-radar';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import {
PUBLIC_API_CORS_HEADERS,
jsonApiError,
Expand Down Expand Up @@ -61,7 +62,8 @@ export async function GET(request: NextRequest) {
data: products.map((p) => {
const offers = offersByMasterId.get(p.id) ?? [];
return {
...p,
// The category's spec view, as /compare shows it (DOS/Rate#1166).
...applyCategorySpecTemplate(lockedCategory, p),
canonical_href: getMasterProductHref(p),
deal_radar: radarExportFields(computeProductRadarSignal(p, offers)),
};
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/app/api/v1/rate/products/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from '@/server/rate';
import { computeProductRadarSignal } from '@/server/rate/deals';
import { isOfferUnavailable } from '@/lib/product-market-stats';
import { getMasterProductCategorySlug } from '@/lib/rate-domain';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import {
PUBLIC_API_CORS_HEADERS,
jsonApiError,
Expand All @@ -29,6 +31,9 @@ export async function OPTIONS() {
* Returns canonical Master Product (10 Field Groups), cross-platform merchant offers,
* time-series Price History + Fake Discount Detection, and JReviews Dual-Rating & AI Review Summary.
* Designed for Rate Web UI, Rate Browser Extension (MV3), and ecosystem consumers.
* The product carries the category's spec view, as rate.vn shows it: no
* screen, RAM, storage or camera for earbuds and no 5G for a watch
* (DOS/Rate#1166).
*/
export async function GET(_request: NextRequest, context: RouteContext) {
try {
Expand All @@ -47,15 +52,19 @@ export async function GET(_request: NextRequest, context: RouteContext) {
);
}

const product = await getCachedMasterProductBySlug(cleanSlug);
const storedProduct = await getCachedMasterProductBySlug(cleanSlug);

if (!product) {
if (!storedProduct) {
return NextResponse.json(
{ ok: false, error: 'Product not found', code: 'NOT_FOUND' },
{ status: 404, headers: PUBLIC_API_CORS_HEADERS }
);
}

const product = applyCategorySpecTemplate(
getMasterProductCategorySlug(storedProduct),
storedProduct
);
const [offers, reviews] = await Promise.all([
getCachedMasterProductOffers(product.id),
getCachedMasterProductReviews(product.id),
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/api/v1/rate/products/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from '@/server/rate';
import { computeProductRadarSignal } from '@/server/rate/deals';
import { radarExportFields } from '@/lib/deal-radar';
import { getMasterProductCategorySlug } from '@/lib/rate-domain';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import {
PUBLIC_API_CORS_HEADERS,
jsonApiError,
Expand Down Expand Up @@ -48,6 +50,8 @@ const VALID_PRODUCT_SORTS = new Set<
* GET /api/v1/rate/products
* Structured JSON Backend API for Web Client, Browser Extension MV3, and Partners.
* Supports 10-Field-Groups multi-criteria filtering, sorting, and optional directory facets.
* Each product carries its category's spec view, as rate.vn shows it
* (DOS/Rate#1166); filters still match the stored columns.
*/
export async function GET(request: NextRequest) {
try {
Expand Down Expand Up @@ -145,7 +149,7 @@ export async function GET(request: NextRequest) {
data: products.map((p) => {
const offers = offersByMasterId.get(p.id) ?? [];
return {
...p,
...applyCategorySpecTemplate(getMasterProductCategorySlug(p), p),
deal_radar: radarExportFields(computeProductRadarSignal(p, offers)),
};
}),
Expand Down
77 changes: 69 additions & 8 deletions apps/web/src/components/compare/CompareWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import type {
MasterProduct,
ProductSpecifications,
} from '@repo/shared-data';
import {
applyCategorySpecTemplate,
getCategorySpecTemplate,
specFieldApplies,
type CategorySpecTemplate,
type SpecGroupKey,
} from '@repo/shared-data/rules';
import {
type CanonicalCategorySlug,
MAX_VALID_VND_PRICE,
Expand Down Expand Up @@ -55,7 +62,11 @@ import {
setCompareTray,
} from './compare-tray';
import { upsertWatchlistItem } from '@/components/watchlist/watchlist-store';
import { getSpecUiDictionary, localizeSpecValue } from '@/lib/spec-localizer';
import {
getSpecUiDictionary,
localizeSpecValue,
specGroupHeading,
} from '@/lib/spec-localizer';
import ContentText from '@/components/elementary/ContentText';
import { productImageUrl } from '@/lib/rate-domain-core';

Expand All @@ -75,6 +86,8 @@ interface SpecRowDef {
renderValue: (p: MasterProduct) => string;
numericValue?: (p: MasterProduct) => number | null;
higherIsBetter?: boolean;
/** Whether the category's spec template has this row (DOS/Rate#1166). */
appliesTo?: (template: CategorySpecTemplate) => boolean;
}

interface SpecGroupDef {
Expand Down Expand Up @@ -167,7 +180,7 @@ function buildCategoryTabs(t: CompareTranslator): Array<{
];
}

function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
return [
{
id: 'market_trust',
Expand Down Expand Up @@ -295,6 +308,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
{
id: 'display_size',
label: t('rowDisplaySize'),
appliesTo: (tpl) => tpl.screen,
renderValue: (p) => {
const size = resolveCompareDisplaySizeInch(p);
return size != null ? `${size}"` : '-';
Expand Down Expand Up @@ -373,6 +387,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
},
{
id: 'hw_cpu_gpu',
appliesTo: (tpl) => specFieldApplies(tpl, 'hardware', 'cpu'),
label: t('rowHwCpuGpu'),
renderValue: (p) => {
const specs = (p.specifications || {}) as ProductSpecifications;
Expand All @@ -384,6 +399,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
},
{
id: 'hw_ram',
appliesTo: (tpl) => tpl.ram,
label: t('rowHwRam'),
renderValue: (p) => {
const ram = resolveCompareRamGb(p);
Expand All @@ -394,6 +410,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
},
{
id: 'hw_storage',
appliesTo: (tpl) => tpl.storage,
label: t('rowHwStorage'),
renderValue: (p) => {
const rom = resolveCompareStorageGb(p);
Expand All @@ -405,6 +422,9 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
},
{
id: 'hw_os_exp',
appliesTo: (tpl) =>
specFieldApplies(tpl, 'hardware', 'os') ||
specFieldApplies(tpl, 'hardware', 'storage_expansion'),
label: t('rowHwOsExp'),
renderValue: (p) => {
const specs = (p.specifications || {}) as ProductSpecifications;
Expand Down Expand Up @@ -599,6 +619,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
rows: [
{
id: 'cell_5g',
appliesTo: (tpl) => tpl.has5g,
label: t('rowCell5g'),
renderValue: (p) => {
const specs = (p.specifications || {}) as ProductSpecifications;
Expand Down Expand Up @@ -655,6 +676,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
},
{
id: 'conn_nfc',
appliesTo: (tpl) => specFieldApplies(tpl, 'connectivity', 'nfc'),
label: t('rowConnNfc'),
renderValue: (p) => {
const specs = (p.specifications || {}) as ProductSpecifications;
Expand Down Expand Up @@ -768,6 +790,28 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] {
];
}

/**
* The spec groups and rows a category's template has, in its order, so an
* earbud shows no screen, RAM or camera row and a watch no 5G row
* (DOS/Rate#1142, DOS/Rate#1166). The market group always comes first.
*/
export function filterSpecGroupsForCategory(
groups: readonly SpecGroupDef[],
categorySlug: string
): SpecGroupDef[] {
const template = getCategorySpecTemplate(categorySlug);
const order = (id: string) =>
id === 'market_trust' ? -1 : template.groups.indexOf(id as SpecGroupKey);
return groups
.filter((group) => order(group.id) !== -1 || group.id === 'market_trust')
.sort((a, b) => order(a.id) - order(b.id))
.map((group) => ({
...group,
rows: group.rows.filter((row) => row.appliesTo?.(template) ?? true),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}))
.filter((group) => group.rows.length > 0);
}

export default function CompareWorkspace({
initialProducts,
catalogCandidates,
Expand All @@ -779,14 +823,14 @@ export default function CompareWorkspace({
const router = useRouter();
const specDict = useMemo(() => getSpecUiDictionary(locale), [locale]);
const categoryTabs = useMemo(() => buildCategoryTabs(t), [t]);
const specGroups = useMemo(() => buildSpecGroups(t), [t]);
const allSpecGroups = useMemo(() => buildSpecGroups(t), [t]);
const [activeCategory, setActiveCategory] = useState<CanonicalCategorySlug>(
() =>
initialProducts.length > 0
? getMasterProductCategorySlug(initialProducts[0]!)
: initialCategory
);
const [products, setProducts] = useState<MasterProduct[]>(() => {
const [storedProducts, setProducts] = useState<MasterProduct[]>(() => {
const targetCat =
initialProducts.length > 0
? getMasterProductCategorySlug(initialProducts[0]!)
Expand All @@ -806,6 +850,17 @@ export default function CompareWorkspace({
const [highlightWinners, setHighlightWinners] = useState(true);
const [copiedLink, setCopiedLink] = useState(false);
const radarIndex = useDealRadarIndex();
// Every row, verdict and export reads the category's view of a product,
// like the product page (DOS/Rate#1166).
const products = useMemo(
() =>
storedProducts.map((p) => applyCategorySpecTemplate(activeCategory, p)),
[storedProducts, activeCategory]
);
const specGroups = useMemo(
() => filterSpecGroupsForCategory(allSpecGroups, activeCategory),
[allSpecGroups, activeCategory]
);

const searchInputRef = useRef<HTMLInputElement | null>(null);
const searchBoxRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -2202,7 +2257,7 @@ export default function CompareWorkspace({
<td className="bg-light text-muted small">-</td>
)}
</tr>
{specGroups.map((group) => {
{specGroups.map((group, groupIndex) => {
// Filter rows when `showDiffOnly` is enabled
const visibleRows = group.rows.filter((row) => {
if (!showDiffOnly || products.length < 2) return true;
Expand All @@ -2222,11 +2277,17 @@ 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
? specDict.groups[
group.id as keyof typeof specDict.groups
]
? specGroupHeading(
specDict,
group.id as SpecGroupKey,
groupIndex,
getCategorySpecTemplate(activeCategory).has5g
)
: group.title;

return (
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/leaderboard/LeaderboardExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useDealRadarIndex } from '@/components/radar/use-deal-radar';
import CompareQuickToggle from '@/components/compare/CompareQuickToggle';
import { escapeCsvCell } from '@/lib/csv-safe';
import type { KeySpecs } from '@repo/shared-data';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import {
BsArrowLeftRight,
BsAwardFill,
Expand Down Expand Up @@ -409,10 +410,12 @@ export default function LeaderboardExplorer({
return map;
}, [products]);

// Rows read the category's view of a product, like the product page: no
// RAM or storage pill for earbuds (DOS/Rate#1166).
const categoryProducts = useMemo(() => {
return products.filter(
(p) => getMasterProductCategorySlug(p) === activeCategory
);
return products
.filter((p) => getMasterProductCategorySlug(p) === activeCategory)
.map((p) => applyCategorySpecTemplate(activeCategory, p));
}, [products, activeCategory]);

const availableBrands = useMemo(() => {
Expand Down
Loading
Loading