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
32 changes: 32 additions & 0 deletions public/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 62 additions & 3 deletions public/assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ <h3>Price</h3>
<div class="product-grid" id="productGrid" aria-live="polite">
<div class="shop-loading">Loading the catalogue…</div>
</div>
<!-- "Show more" for the grid. Filled in by renderProducts() in main.js (and
by the Worker for the first paint); hidden when everything fits. -->
<div class="shop-more" id="shopMore" hidden></div>
</div>
</section>

Expand Down
26 changes: 23 additions & 3 deletions src/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -281,7 +286,7 @@ function cardHtml(env, p, resize) {
? `<a class="product-name" href="${esc(href)}">${esc(p.name)}</a>`
: `<div class="product-name">${esc(p.name)}</div>`;

return `<div class="product-card">` +
return `<div class="product-card${overflow ? " is-overflow" : ""}">` +
`<div class="product-media">` +
`<img src="${esc(cdnImage(p.image, 480, resize))}" alt="${esc(p.name)}" loading="lazy" decoding="async" width="400" height="400">` +
`</div>` +
Expand Down Expand Up @@ -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
? `<button type="button" class="btn-secondary" id="shopMoreBtn">Show ${Math.min(SHOP_PAGE, left)} more</button>` +
`<p class="shop-more-count">Showing ${SHOP_PAGE} of ${products.length}</p>`
: "";

// 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
Expand Down Expand Up @@ -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
Expand Down
Loading