diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 7eeae4a..98fd601 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1281,6 +1281,38 @@ footer { .shop-sidebar { display: none; } } +/* ── "Show more" under the grid ──────────────────────────────────── + The grid used to render every product at once — 86 cards, a ~9,000px page on + a phone — with How It Works, the quote form and the footer buried beneath. + Twelve at a time is three desktop rows or six phone rows; the button says + how many are left so nobody wonders whether that was everything. */ +.shop-more { + grid-column: 2; /* under the grid, not under the sidebar */ + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-top: 1.75rem; +} +.shop-more[hidden] { display: none; } +.shop-more .btn-secondary { font-size: 0.92rem; padding: 0.7rem 1.6rem; } +.shop-more-count { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0; +} +/* Server-rendered cards past the first page. main.js re-renders the grid + from /api/products, so this only shapes the first paint — and keeps every + product link in the HTML for a crawler. */ +.product-card.is-overflow { display: none; } + +/* No sidebar below 1100px (see .shop-layout above), so there is only the one + column to sit in. After the base rule on purpose: same specificity, and the + later declaration is the one that wins. */ +@media (max-width: 1100px) { + .shop-more { grid-column: 1; } +} + .product-grid { display: grid; /* 195px, arrived at by measuring rather than picking a round number. diff --git a/public/assets/js/main.js b/public/assets/js/main.js index 49cc215..980a063 100644 --- a/public/assets/js/main.js +++ b/public/assets/js/main.js @@ -941,6 +941,46 @@ let shopQuery = ''; let shopCategory = new URLSearchParams(location.search).get('cat') || 'all'; let shopPriceBand = 'all'; +// The grid shows this many cards, then a "Show more" button. It rendered all of +// them before — 86 cards, a ~9,000px page on a phone, with How It Works, the +// quote form and the footer buried under the catalogue. Twelve is three rows on +// a desktop and six on a phone: enough to browse, not so much that the rest of +// the page disappears. +const SHOP_PAGE = 12; +let shopLimit = SHOP_PAGE; +let shopFilterKey = null; +let lastShown = []; + +function renderShowMore(total, showing) { + const box = document.getElementById('shopMore'); + if (!box) return; + const left = total - showing; + if (left <= 0) { box.hidden = true; box.innerHTML = ''; return; } + + box.innerHTML = ''; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-secondary'; + btn.id = 'shopMoreBtn'; + btn.textContent = `Show ${Math.min(SHOP_PAGE, left)} more`; + btn.addEventListener('click', () => { + const firstNew = showing; + shopLimit += SHOP_PAGE; + renderProducts(); + // Put focus on the first card that just appeared, so a keyboard or + // screen-reader user continues from where the button was rather than + // being dropped at the top of the page. + const card = productGrid?.querySelectorAll('.product-card')[firstNew]; + card?.querySelector('.product-name')?.focus?.({ preventScroll: true }); + card?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + const count = document.createElement('p'); + count.className = 'shop-more-count'; + count.textContent = `Showing ${showing} of ${total}`; + box.append(btn, count); + box.hidden = false; +} + // Price bands for the sidebar filter. // // Chosen from the actual catalogue rather than round numbers: prices run ₹99 to @@ -1182,6 +1222,15 @@ function renderProducts() { const shown = visibleProducts() .sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))); updateResultCount(shown.length); + lastShown = shown; + + // A new filter or search starts back at the first page. Keyed on the filter + // state rather than reset by every caller, because renderProducts() is called + // from a dozen places and only some of them change what is being shown. + const filterKey = `${shopCategory}|${shopPriceBand}|${shopQuery.trim().toLowerCase()}`; + if (filterKey !== shopFilterKey) { shopFilterKey = filterKey; shopLimit = SHOP_PAGE; } + const page = shown.slice(0, shopLimit); + renderShowMore(shown.length, page.length); // A search that matches nothing is a dead end unless we offer a way out. if (!shown.length) { @@ -1209,7 +1258,7 @@ function renderProducts() { return; } - for (const p of shown) { + for (const p of page) { const card = document.createElement('div'); card.className = 'product-card'; @@ -1500,8 +1549,18 @@ function openSharedProduct() { const slug = meta && meta.getAttribute('content'); if (!slug) return; - const card = productGrid?.querySelector(`.product-card[data-slug="${CSS.escape(slug)}"]`); - if (!card) return; + let card = productGrid?.querySelector(`.product-card[data-slug="${CSS.escape(slug)}"]`); + if (!card) { + // Past the first page of the grid. Extend the page to include it rather than + // silently doing nothing — this is the one case where the visitor arrived + // asking for a specific card. + const at = lastShown.findIndex((p) => p.slug === slug); + if (at < 0) return; + shopLimit = Math.ceil((at + 1) / SHOP_PAGE) * SHOP_PAGE; + renderProducts(); + card = productGrid?.querySelector(`.product-card[data-slug="${CSS.escape(slug)}"]`); + if (!card) return; + } card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.classList.add('product-linked'); diff --git a/public/index.html b/public/index.html index 766a3fb..acea37f 100644 --- a/public/index.html +++ b/public/index.html @@ -233,6 +233,9 @@
Showing ${SHOP_PAGE} of ${products.length}
` + : ""; // The three hero photos are real products. Keyed on image FILENAME rather than a // hardcoded slug: the photos were chosen because they compose well together, and @@ -365,6 +378,13 @@ export function rewriteHome(env, response, products, url, promo = null) { .on("#productGrid", { element(el) { el.setInnerContent(grid, { html: true }); }, }) + .on("#shopMore", { + element(el) { + if (!showMore) return; + el.removeAttribute("hidden"); + el.setInnerContent(showMore, { html: true }); + }, + }) // The promo banner, in the HTML rather than filled in by JS after // /api/products lands. main.js still owns dismissal and the "already // redeemed" case; it reads data-promo-code to hide a dismissed banner before