From 84b5d49a2f3eef8d9ad23ab6b4943255347056ae Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:54:47 +0700 Subject: [PATCH 1/2] fix(web): the product page shows specs and reviews before prices and sellers The product comes first: after the hero and scoreboard the page reads the spec groups, then the reviews, the price history, and the sellers last. The jump row follows the same order. It stays in the phone's first screen (DOS/Rate#1517), as does the price strip with the best price. The unknown-size listings stay with the sellers. The item pages share this view, so they follow too. Closes #1530 Co-Authored-By: Claude Opus 5.5 --- .../e2e/item-model/item-product-page.spec.ts | 4 +- apps/web/e2e/product-section-order.spec.ts | 12 +- .../app/[locale]/smartphones/[slug]/page.tsx | 136 +++++++++--------- .../__tests__/core-rate-architecture.test.ts | 12 +- 4 files changed, 85 insertions(+), 79 deletions(-) diff --git a/apps/web/e2e/item-model/item-product-page.spec.ts b/apps/web/e2e/item-model/item-product-page.spec.ts index 77762d27f..9ae0813fe 100644 --- a/apps/web/e2e/item-model/item-product-page.spec.ts +++ b/apps/web/e2e/item-model/item-product-page.spec.ts @@ -9,10 +9,10 @@ const ITEM = '/smartphones/e2e-item-phone'; const BIG = '/smartphones/e2e-item-big'; const NO_DATA_MERCHANT = '17e00000-0000-4000-8000-0000000d1484'; const ORDER = [ - 'offers-table', - 'pdv-price-history', 'specs-section', 'jreviews-section', + 'pdv-price-history', + 'offers-table', ]; /** Clicks once the page is hydrated: a click before it does nothing. */ diff --git a/apps/web/e2e/product-section-order.spec.ts b/apps/web/e2e/product-section-order.spec.ts index 94afd8f05..5fdf37098 100644 --- a/apps/web/e2e/product-section-order.spec.ts +++ b/apps/web/e2e/product-section-order.spec.ts @@ -1,19 +1,19 @@ import { expect, test } from '@playwright/test'; -// A shopper reaches the sellers first: the page reads price box, sellers, -// price history, specs, then reviews, with a jump row to each, and phones -// show the first three reviews until expanded (DOS/Rate#1469). +// The product comes first: the page reads price box, specs, reviews, price +// history, then the sellers last, with a jump row to each (DOS/Rate#1469, +// #1530), and phones show the first three reviews until expanded. const PRODUCT = '/smartphones/iphone-16-pro-max-256gb'; // Six seeded reviews (seed.sql, DOS/Rate#566). const MANY_REVIEWS = '/smartphones/e2e-review-labels-phone'; const ORDER = [ - 'offers-table', - 'pdv-price-history', 'specs-section', 'jreviews-section', + 'pdv-price-history', + 'offers-table', ]; -test('sellers come first, then price history, specs and reviews', async ({ +test('specs come first, then reviews, price history and sellers', async ({ page, }) => { await page.goto(PRODUCT); diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 8acda22da..193ec94c9 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1026,23 +1026,23 @@ export async function ProductDetailView({

)} - {/* Where to buy comes first, then the price history, specs and - reviews; this row jumps to each (DOS/Rate#1469). It sits under the - price, before the scoreboard, so a phone shows it in the first - screen (DOS/Rate#1517). */} + {/* The specs come first, then the reviews, the price history and + the sellers; this row jumps to each (DOS/Rate#1469, #1530). It + sits under the price, before the scoreboard, so a phone shows it + in the first screen (DOS/Rate#1517). */} {/* #1 Core Focus Above the Fold: JReviews Dual-Rating & 5-Criteria Scoreboard (DESIGN.md v3.0) */}
@@ -1356,65 +1356,10 @@ export async function ProductDetailView({
- {/* Neutral Merchant Reference & Trust Audit Table */} -
- {isItemGroup - ? renderSizePanels('offers', (v) => - renderOffersBody(variantPanelInput(v)) - ) - : renderOffersBody({ - product, - offers, - priceHistory, - radarSignal, - emptyText: pageEmptyOffersText, - })} -
- - {/* Listings whose size is not stated: their own group, never part - of the "from" price (Q2, DOS/Rate#1154). */} - {itemPage && itemPage.unknownOffers.length > 0 && ( -
-

- {t('unknownSizeTitle', { - count: itemPage.unknownOffers.length, - })} -

-

{t('unknownSizeNote')}

- -
- )} - - {/* Time-Series Price History Chart, Fake Discount Detector & Neutral - Merchant Audit. The wrapper is the jump row's target: a lazy size - panel has no #price-history-section until it loads (#1469). */} -
- {isItemGroup - ? renderSizePanels('history', (v) => - renderPriceHistory(variantPanelInput(v)) - ) - : renderPriceHistory({ - product, - priceHistory, - minPrice: currentMinPrice, - radarSignal, - })} -
- - {/* Spec groups, per category template (DOS/Rate#1142), before - the reviews (DOS/Rate#1436) */} + {/* The product first: spec groups, per category template + (DOS/Rate#1142), then the reviews, the price history and the sellers + last (DOS/Rate#1530). The price strip above keeps the best price in + the first screen. */} {(() => { const specUi = getSpecUiDictionary(locale); // Rows with no value and groups with no row are left out @@ -1521,6 +1466,63 @@ export async function ProductDetailView({ /> + {/* Time-Series Price History Chart, Fake Discount Detector & Neutral + Merchant Audit. The wrapper is the jump row's target: a lazy size + panel has no #price-history-section until it loads (#1469). */} +
+ {isItemGroup + ? renderSizePanels('history', (v) => + renderPriceHistory(variantPanelInput(v)) + ) + : renderPriceHistory({ + product, + priceHistory, + minPrice: currentMinPrice, + radarSignal, + })} +
+ + {/* Neutral Merchant Reference & Trust Audit Table */} +
+ {isItemGroup + ? renderSizePanels('offers', (v) => + renderOffersBody(variantPanelInput(v)) + ) + : renderOffersBody({ + product, + offers, + priceHistory, + radarSignal, + emptyText: pageEmptyOffersText, + })} +
+ + {/* Listings whose size is not stated: their own group, never part + of the "from" price (Q2, DOS/Rate#1154). */} + {itemPage && itemPage.unknownOffers.length > 0 && ( +
+

+ {t('unknownSizeTitle', { + count: itemPage.unknownOffers.length, + })} +

+

{t('unknownSizeNote')}

+ +
+ )} + {/* Interactive Value Efficiency & 2-Year Ownership Calculator (#62) */} {(() => { const lbScored = scoreMasterProductForLeaderboard( diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index ff8098b25..9d1c28695 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -563,8 +563,8 @@ async function runCoreArchitectureSuite() { 'earbuds' ); - // ProductDetailView reads: sellers (#offers-table), then specs, then reviews - // (DOS/Rate#1436, #1469). + // ProductDetailView reads: specs, then reviews, then the price history and + // the sellers (#offers-table) last (DOS/Rate#1436, #1469, #1530). const detailPagePath = path.resolve( __dirname, '../../../app/[locale]/smartphones/[slug]/page.tsx' @@ -572,10 +572,14 @@ async function runCoreArchitectureSuite() { const detailPageSrc = fs.readFileSync(detailPagePath, 'utf8'); const idxJReviews = detailPageSrc.indexOf(' 0 && idxSpecs > idxOffers && idxJReviews > idxSpecs, - 'ProductDetailView must render #offers-table before #specs-section, and #specs-section before ' + idxSpecs > 0 && + idxJReviews > idxSpecs && + idxHistory > idxJReviews && + idxOffers > idxHistory, + 'ProductDetailView must render #specs-section, , #pdv-price-history, then #offers-table' ); console.log( From 1283d38a7f38c50341234e3cf2936524c095f7a5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:00:45 +0700 Subject: [PATCH 2/2] test(web): the section order tests name the new order Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/item-model/item-product-page.spec.ts | 2 +- apps/web/e2e/product-above-fold.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/item-model/item-product-page.spec.ts b/apps/web/e2e/item-model/item-product-page.spec.ts index 9ae0813fe..5e39e9468 100644 --- a/apps/web/e2e/item-model/item-product-page.spec.ts +++ b/apps/web/e2e/item-model/item-product-page.spec.ts @@ -72,7 +72,7 @@ test('the size picker replaces the quick specs and links each size', async ({ // The lazy Item also with another size loaded on demand. for (const path of [ITEM, BIG, `${BIG}?v=256gb`]) { - test(`${path}: price, jump row, sellers, history, specs, reviews`, async ({ + test(`${path}: price, jump row, specs, reviews, history, sellers`, async ({ page, }) => { await page.goto(path); diff --git a/apps/web/e2e/product-above-fold.spec.ts b/apps/web/e2e/product-above-fold.spec.ts index f258e0244..cddf57461 100644 --- a/apps/web/e2e/product-above-fold.spec.ts +++ b/apps/web/e2e/product-above-fold.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; // On a phone the product name, the price line and the user score are in the // first screen, not behind a full-screen photo (DOS/Rate#605). The score value // itself, not only its label, must fit (DOS/Rate#630). So does the whole jump row to -// the sellers, history, specs and reviews (DOS/Rate#1517). +// the specs, reviews, history and sellers (DOS/Rate#1517, #1530). const PRODUCT = '/smartphones/iphone-16-pro-max-256gb'; for (const viewport of [