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
6 changes: 3 additions & 3 deletions apps/web/e2e/item-model/item-product-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/product-above-fold.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
12 changes: 6 additions & 6 deletions apps/web/e2e/product-section-order.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
136 changes: 69 additions & 67 deletions apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,23 +1026,23 @@ export async function ProductDetailView({
</p>
)}

{/* 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). */}
<nav
className="rate-pdv-jumpnav"
aria-label={t('jumpNavAria')}
data-testid="pdv-jump-nav"
>
<a href="#offers-table">
{t('jumpNavOffers', { count: offers.length })}
</a>
<a href="#pdv-price-history">{t('jumpNavPriceHistory')}</a>
<a href="#specs-section">{t('jumpNavSpecs')}</a>
<a href="#jreviews-section">
{t('jumpNavReviews', { count: authenticCount })}
</a>
<a href="#pdv-price-history">{t('jumpNavPriceHistory')}</a>
<a href="#offers-table">
{t('jumpNavOffers', { count: offers.length })}
</a>
</nav>
{/* #1 Core Focus Above the Fold: JReviews Dual-Rating & 5-Criteria Scoreboard (DESIGN.md v3.0) */}
<div className="rate-scoreboard-dark rate-surface-dark rate-pdv-board mb-4">
Expand Down Expand Up @@ -1356,65 +1356,10 @@ export async function ProductDetailView({
</div>
</div>

{/* Neutral Merchant Reference & Trust Audit Table */}
<div
id="offers-table"
className="card rounded-4 p-md-5 mb-5 border-0 p-4 shadow-sm"
>
{isItemGroup
? renderSizePanels('offers', (v) =>
renderOffersBody(variantPanelInput(v))
)
: renderOffersBody({
product,
offers,
priceHistory,
radarSignal,
emptyText: pageEmptyOffersText,
})}
</div>

{/* Listings whose size is not stated: their own group, never part
of the "from" price (Q2, DOS/Rate#1154). */}
{itemPage && itemPage.unknownOffers.length > 0 && (
<div
id="offers-unknown-size"
className="card rounded-4 p-md-5 mb-5 border-0 p-4 shadow-sm"
data-testid="pdv-unknown-size-offers"
>
<h2 className="h5 fw-bold mb-1">
{t('unknownSizeTitle', {
count: itemPage.unknownOffers.length,
})}
</h2>
<p className="text-muted small mb-3">{t('unknownSizeNote')}</p>
<ProductOffersExplorer
productName={displayName}
productSlug={itemPage.item.slug}
offers={itemPage.unknownOffers}
byOffer={itemPage.unknownByOffer}
/>
</div>
)}

{/* 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). */}
<div id="pdv-price-history">
{isItemGroup
? renderSizePanels('history', (v) =>
renderPriceHistory(variantPanelInput(v))
)
: renderPriceHistory({
product,
priceHistory,
minPrice: currentMinPrice,
radarSignal,
})}
</div>

{/* 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
Expand Down Expand Up @@ -1521,6 +1466,63 @@ export async function ProductDetailView({
/>
</div>

{/* 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). */}
<div id="pdv-price-history">
{isItemGroup
? renderSizePanels('history', (v) =>
renderPriceHistory(variantPanelInput(v))
)
: renderPriceHistory({
product,
priceHistory,
minPrice: currentMinPrice,
radarSignal,
})}
</div>

{/* Neutral Merchant Reference & Trust Audit Table */}
<div
id="offers-table"
className="card rounded-4 p-md-5 mb-5 border-0 p-4 shadow-sm"
>
{isItemGroup
? renderSizePanels('offers', (v) =>
renderOffersBody(variantPanelInput(v))
)
: renderOffersBody({
product,
offers,
priceHistory,
radarSignal,
emptyText: pageEmptyOffersText,
})}
</div>

{/* Listings whose size is not stated: their own group, never part
of the "from" price (Q2, DOS/Rate#1154). */}
{itemPage && itemPage.unknownOffers.length > 0 && (
<div
id="offers-unknown-size"
className="card rounded-4 p-md-5 mb-5 border-0 p-4 shadow-sm"
data-testid="pdv-unknown-size-offers"
>
<h2 className="h5 fw-bold mb-1">
{t('unknownSizeTitle', {
count: itemPage.unknownOffers.length,
})}
</h2>
<p className="text-muted small mb-3">{t('unknownSizeNote')}</p>
<ProductOffersExplorer
productName={displayName}
productSlug={itemPage.item.slug}
offers={itemPage.unknownOffers}
byOffer={itemPage.unknownByOffer}
/>
</div>
)}

{/* Interactive Value Efficiency & 2-Year Ownership Calculator (#62) */}
{(() => {
const lbScored = scoreMasterProductForLeaderboard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,19 +563,23 @@ 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'
);
const detailPageSrc = fs.readFileSync(detailPagePath, 'utf8');
const idxJReviews = detailPageSrc.indexOf('<JReviewsSection');
const idxSpecs = detailPageSrc.indexOf('id="specs-section"');
const idxHistory = detailPageSrc.indexOf('id="pdv-price-history"');
const idxOffers = detailPageSrc.indexOf('id="offers-table"');
assert.ok(
idxOffers > 0 && idxSpecs > idxOffers && idxJReviews > idxSpecs,
'ProductDetailView must render #offers-table before #specs-section, and #specs-section before <JReviewsSection />'
idxSpecs > 0 &&
idxJReviews > idxSpecs &&
idxHistory > idxJReviews &&
idxOffers > idxHistory,
'ProductDetailView must render #specs-section, <JReviewsSection />, #pdv-price-history, then #offers-table'
);

console.log(
Expand Down
Loading