From b54243f520f63706bb5121d53ddd6d337c5cdeed Mon Sep 17 00:00:00 2001 From: Aswinmcw Date: Thu, 3 Sep 2026 09:15:32 +0000 Subject: [PATCH] shop: show twelve cards at a time with a "Show more" button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid rendered every product at once — 80 cards, a ~9,000px stretch of a phone screen — and buried How It Works, the quote form and the footer under the catalogue. Twelve at a time is three desktop rows or six on a phone; the button says how many are left and how many are shown. - renderProducts() slices to shopLimit; a change of filter or search resets to the first page (keyed on the filter state, since renderProducts has a dozen callers). "Show more" focuses the first newly-added card. - openSharedProduct() extends the page to reach a /p/ handoff that sits past the first twelve rather than doing nothing. - The Worker renders the same first page: cards past SHOP_PAGE carry .is-overflow (display: none) so every product link stays in the HTML for crawlers, and #shopMore is pre-filled so the first paint matches what main.js redraws. Co-authored-by: Cursor --- public/assets/css/style.css | 32 ++++++++++++++++++ public/assets/js/main.js | 65 +++++++++++++++++++++++++++++++++++-- public/index.html | 3 ++ src/seo.js | 26 +++++++++++++-- 4 files changed, 120 insertions(+), 6 deletions(-) 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 @@

Price

Loading the catalogue…
+ + diff --git a/src/seo.js b/src/seo.js index 7be29c9..06ed4b1 100644 --- a/src/seo.js +++ b/src/seo.js @@ -267,7 +267,12 @@ function cdnImage(path, width, resize) { return `/cdn-cgi/image/width=${width},format=auto,onerror=redirect/${p}`; } -function cardHtml(env, p, resize) { +// Must match SHOP_PAGE in main.js: the server draws the first paint and the +// script redraws the same grid, so a different page size here would make the +// grid grow or shrink the instant JS runs. +export const SHOP_PAGE = 12; + +function cardHtml(env, p, resize, overflow = false) { const base = baseUrl(env); const priceLabel = p.price_paise > 0 ? "₹" + Math.round(p.price_paise / 100).toLocaleString("en-IN") @@ -281,7 +286,7 @@ function cardHtml(env, p, resize) { ? `${esc(p.name)}` : `
${esc(p.name)}
`; - return `
` + + return `
` + `
` + `${esc(p.name)}` + `
` + @@ -337,7 +342,15 @@ export function rewriteHome(env, response, products, url, promo = null) { // /cdn-cgi/ is an edge feature; under wrangler dev it 404s, so the whole grid // would render broken while developing. const resize = !/^(localhost|127\.0\.0\.1)$/.test(url.hostname); - const grid = products.map((p) => cardHtml(env, p, resize)).join(""); + // Every product is in the HTML — that is the crawler's route to all the + // product pages — but only the first page is visible, the same twelve + // main.js will draw. The rest carry .is-overflow (display: none). + const grid = products.map((p, i) => cardHtml(env, p, resize, i >= SHOP_PAGE)).join(""); + const left = products.length - SHOP_PAGE; + const showMore = left > 0 + ? `` + + `

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