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
36 changes: 27 additions & 9 deletions app/Views/frontend/book-detail.php
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,16 @@
}

/* Related Books Section */
/* Keep the (max 3) related cards grouped and centred instead of spread
across the theme's ultra-wide container. ~3 cards of 280px + gutters.
plain wrapper div (not the Bootstrap .row) so it doesn't fight .row's
negative gutter margins — no !important needed. */
.related-books-wrap {
max-width: 960px;
margin-left: auto;
margin-right: auto;
}

.related-book-card {
background: var(--white);
border-radius: 16px;
Expand All @@ -1482,6 +1492,14 @@
height: 100%;
display: flex;
flex-direction: column;
/* Deliberate book-sane cap, centred in its column, so the cover (140%
of the card width) never balloons on wide screens where each column
is very wide. 280px is the catalog grid's minmax() floor; here it's a
hard cap (the catalog stretches beyond it, this doesn't) — related
cards read slightly narrower than catalog cards, which is fine. */
max-width: 280px;
margin-left: auto;
margin-right: auto;
}

.related-book-card:hover {
Expand Down Expand Up @@ -1602,12 +1620,10 @@
color: white;
}

@media (max-width: 768px) {
.related-book-card {
max-width: 400px;
margin: 0 auto;
}
}
/* No mobile-only max-width override: a wider cap on mobile (≤768px) than on
desktop (280px) caused an inverted jump — widening the window past 768px
*shrank* the card. The uniform 280px cap above keeps the transition
monotonic across every breakpoint. */

/* Favorites button custom styling */
.btn-fav-custom {
Expand Down Expand Up @@ -2329,9 +2345,10 @@ class="badge bg-light text-dark border px-3 py-2 text-decoration-none keyword-ch
<i class="fas fa-lightbulb"></i>
<?= __("Potrebbero interessarti") ?>
</h2>
<div class="row g-4">
<div class="related-books-wrap">
<div class="row g-4 justify-content-center">
<?php foreach($related_books as $related): ?>
<div class="col-lg-4 col-md-6">
<div class="col-12 col-sm-6 col-lg-4">
<div class="related-book-card">
<div class="related-book-image-container">
<?php
Expand Down Expand Up @@ -2392,7 +2409,8 @@ class="btn-related-view">
</div>
</div>
<?php endforeach; ?>
</div>
</div><!-- /.row -->
</div><!-- /.related-books-wrap -->
</div>
</section>
<?php endif; ?>
Expand Down
132 changes: 132 additions & 0 deletions tests/related-books-responsive-278.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @ts-check
/**
* E2E — Related-books ("Potrebbero interessarti") responsive layout (#278).
*
* Guards the fix that stopped the related-book covers ballooning on large
* screens. Reusable regression coverage for:
* - the card is capped to a book-sane width on desktop (never full-column);
* - NO size inversion across the 768px boundary (widening must not shrink
* the card within the same column count);
* - responsive column count grows 1 → 2 → 3 with the viewport;
* - the row is grouped/centred (bounded width) on ultra-wide screens.
*
* Book detail is public — no login needed. The spec finds a book that actually
* renders related books (an author with ≥2 catalogued books) so it never
* silently passes on a page with no section.
*
* Run: /tmp/run-e2e.sh tests/related-books-responsive-278.spec.js --config=tests/playwright.config.js --workers=1
*/

const { test, expect } = require('@playwright/test');
const { execFileSync } = require('child_process');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
const DB_USER = process.env.E2E_DB_USER || '';
const DB_PASS = process.env.E2E_DB_PASS || '';
const DB_NAME = process.env.E2E_DB_NAME || '';
const DB_SOCKET = process.env.E2E_DB_SOCKET || '';
const DB_HOST = process.env.E2E_DB_HOST || '';
const DB_PORT = process.env.E2E_DB_PORT || '';

test.skip(!DB_USER || !DB_NAME, 'Missing E2E env: DB_USER / DB_NAME required to locate a book with related books');

function dbQuery(sql) {
const args = [];
if (DB_HOST) { args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); }
else if (DB_SOCKET) { args.push('-S', DB_SOCKET); }
args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql);
return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim();
}

/** A book whose author has ≥2 catalogued books → getRelatedBooks() returns rows. */
function findBookWithRelated() {
const row = dbQuery(
"SELECT la.libro_id FROM libri_autori la " +
"JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL " +
"WHERE la.ruolo = 'principale' " +
"AND la.autore_id IN (" +
" SELECT autore_id FROM libri_autori la2 " +
" JOIN libri l2 ON l2.id = la2.libro_id AND l2.deleted_at IS NULL " +
" WHERE la2.ruolo = 'principale' GROUP BY autore_id HAVING COUNT(DISTINCT la2.libro_id) >= 2" +
") LIMIT 1"
);
return parseInt(row, 10) || 0;
}

/** Measure the related-books section at a given viewport width. */
async function measure(page, bookId, width) {
await page.setViewportSize({ width, height: 900 });
await page.goto(`${BASE}/libro/${bookId}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.related-book-card'));
if (cards.length === 0) return { present: false };
const rects = cards.map((c) => c.getBoundingClientRect());
const top0 = Math.round(rects[0].top);
const perRow = rects.filter((r) => Math.abs(Math.round(r.top) - top0) < 5).length;
const wrap = document.querySelector('.related-books-wrap');
return {
present: true,
count: cards.length,
cardW: Math.round(rects[0].width),
perRow,
wrapW: wrap ? Math.round(wrap.getBoundingClientRect().width) : null,
};
});
}

test.describe.serial('Related books — responsive layout (#278)', () => {
/** @type {number} */
let bookId = 0;

test.beforeAll(() => {
bookId = findBookWithRelated();
});

test('section renders for a book that has related books', async ({ page }) => {
test.skip(bookId === 0, 'no book with related books in the catalog');
const m = await measure(page, bookId, 1440);
expect(m.present).toBe(true);
expect(m.count).toBeGreaterThan(0);
});

test('card is capped to a book-sane width on desktop (never balloons)', async ({ page }) => {
test.skip(bookId === 0, 'no book with related books');
for (const w of [1440, 1920, 2560]) {
const m = await measure(page, bookId, w);
// The card must never exceed the 280px cap on desktop, no matter how
// wide the viewport/column is. Pre-fix it grew to ~440px+.
expect(m.cardW, `card width at ${w}px`).toBeLessThanOrEqual(285);
}
});

test('no size inversion across the 768px boundary', async ({ page }) => {
test.skip(bookId === 0, 'no book with related books');
// Bug: a mobile rule capped the card wider (400px) than the desktop cap
// (280px), so widening past 768px SHRANK the card. Widening must never
// shrink the card.
const below = await measure(page, bookId, 760);
const above = await measure(page, bookId, 820);
expect(above.cardW, 'widening past 768px must not shrink the card').toBeGreaterThanOrEqual(below.cardW - 1);
});

test('column count grows with the viewport (1 → 2 → 3)', async ({ page }) => {
test.skip(bookId === 0, 'no book with related books');
const narrow = await measure(page, bookId, 480); // col-12 → 1 per row
const mid = await measure(page, bookId, 700); // col-sm-6 → 2 per row
const wide = await measure(page, bookId, 1400); // col-lg-4 → up to 3 per row
expect(narrow.perRow).toBe(1);
expect(mid.perRow).toBeLessThanOrEqual(2);
// wide shows as many columns as there are cards, up to 3.
expect(wide.perRow).toBeGreaterThanOrEqual(Math.min(3, wide.count));
});

test('row is grouped/centred (bounded) on ultra-wide screens', async ({ page }) => {
test.skip(bookId === 0, 'no book with related books');
const m = await measure(page, bookId, 2560);
// The .related-books-wrap caps the row so the cards don't spread across
// the whole 2560px container.
expect(m.wrapW, 'wrap width on 2560px').not.toBeNull();
expect(m.wrapW).toBeLessThanOrEqual(1000);
});
});
Loading