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 77762d27..5e39e946 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. */ @@ -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 f258e024..cddf5746 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 [ diff --git a/apps/web/e2e/product-section-order.spec.ts b/apps/web/e2e/product-section-order.spec.ts index 94afd8f0..5fdf3709 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 8acda22d..193ec94c 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 ff8098b2..9d1c2869 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(