diff --git a/app/(pages)/where-to-buy-fixture/page.tsx b/app/(pages)/where-to-buy-fixture/page.tsx index 27ca3e7..0e833db 100644 --- a/app/(pages)/where-to-buy-fixture/page.tsx +++ b/app/(pages)/where-to-buy-fixture/page.tsx @@ -5,7 +5,7 @@ import { WHERE_TO_BUY_FIXTURE_BEERS, WHERE_TO_BUY_FIXTURE_VENUES, } from "@/lib/where-to-buy-fixture"; -import { carriedBeerOptions } from "@/lib/venue-filters"; +import { carriedBeerOptions, venueIsStocked } from "@/lib/venue-filters"; // Test-only rendering of the /where-to-buy filter experience for the // Playwright smoke suite. The env var is a server-only check evaluated per @@ -27,7 +27,9 @@ export default function WhereToBuyFixturePage() { notFound(); } - const venues = WHERE_TO_BUY_FIXTURE_VENUES; + // Mirror the production page's stocked-venue exclusion so the fixture + // exercises the same public-display rule (Issue #130). + const venues = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked); const beers = WHERE_TO_BUY_FIXTURE_BEERS; const beerNameBySlug = Object.fromEntries( beers.map((beer) => [beer.slug, beer.name]) diff --git a/app/(pages)/where-to-buy/page.tsx b/app/(pages)/where-to-buy/page.tsx index 100103b..f453a2e 100644 --- a/app/(pages)/where-to-buy/page.tsx +++ b/app/(pages)/where-to-buy/page.tsx @@ -4,7 +4,11 @@ import { getBeers } from "@/lib/beers"; import { VenueDirectory } from "@/components/venue-directory"; import { serializeJsonLd } from "@/lib/json-ld"; import { buildBreweryJsonLd } from "@/lib/brewery-json-ld"; -import { carriedBeerOptions, distinctIslands } from "@/lib/venue-filters"; +import { + carriedBeerOptions, + distinctIslands, + venueIsStocked, +} from "@/lib/venue-filters"; export const metadata: Metadata = { title: "Where to Buy", @@ -46,7 +50,11 @@ export const metadata: Metadata = { }; export default async function WhereToBuyPage() { - const [venues, beers] = await Promise.all([getVenues(), getBeers()]); + const [fetchedVenues, beers] = await Promise.all([getVenues(), getBeers()]); + // Exclude venues with no current On Tap / In Can beer before any grouping, + // option generation, or counting — a venue showing no inventory never + // renders publicly (Issue #130). + const venues = fetchedVenues.filter(venueIsStocked); const beerNameBySlug = Object.fromEntries(beers.map((beer) => [beer.slug, beer.name])); const beerOptions = carriedBeerOptions(venues, beers); const islands = distinctIslands(venues); diff --git a/components/venue-card.tsx b/components/venue-card.tsx index 0787e03..b02aeab 100644 --- a/components/venue-card.tsx +++ b/components/venue-card.tsx @@ -17,7 +17,7 @@ export function VenueCard({ venue, beerNameBySlug }: VenueCardProps) { const island = venue.locationName ?? "Saba"; return ( -
+

{venue.name}

@@ -50,7 +50,7 @@ export function VenueCard({ venue, beerNameBySlug }: VenueCardProps) { )} {(venue.links.website || venue.links.maps || venue.links.instagram || venue.links.facebook || venue.links.untappd) && ( -
+
{venue.links.maps && ( - (venue.tapBeerSlugs?.length ?? 0) > 0 || - (venue.canBeerSlugs?.length ?? 0) > 0 + return venues.some(venueIsStocked); +} + +/** + * True when the venue lists at least one beer under a serving format — the + * same "has beer" definition the card uses to render its On Tap / In Can + * lists. `carriesBeerSlugs` alone does not count: a beer recorded there + * without a format membership produces no visible inventory line, so the + * card would still show no beer. Public /where-to-buy pages exclude + * unstocked venues before grouping, filter options, and counts are derived. + */ +export function venueIsStocked(venue: Venue): boolean { + return ( + (venue.tapBeerSlugs?.length ?? 0) > 0 || + (venue.canBeerSlugs?.length ?? 0) > 0 ); } diff --git a/lib/where-to-buy-fixture.ts b/lib/where-to-buy-fixture.ts index 6821a6b..29ee262 100644 --- a/lib/where-to-buy-fixture.ts +++ b/lib/where-to-buy-fixture.ts @@ -80,7 +80,9 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [ canBeerSlugs: ["fort-bay-ipa"], isPublic: true, sortOrder: 2, - links: {}, + links: { + website: "https://example.com/bottle-shop", + }, }, { name: "Fixture Harbor Bar", @@ -99,11 +101,31 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [ slug: "fixture-quiet-cafe", type: "bar_restaurant", locationName: "Windwardside / The Bottom, Saba", - carriesBeerSlugs: [], - tapBeerSlugs: [], + carriesBeerSlugs: ["saba-suds-pilsner"], + tapBeerSlugs: ["saba-suds-pilsner"], canBeerSlugs: [], isPublic: true, sortOrder: 4, links: {}, }, + { + // Island Flavor-style regression venue (Issue #130): valid + // name/location/type and directions + social links, but no beer under + // either format. Must never render publicly — and as the only venue on + // Statia it must not create an island group or filter option either. + name: "Fixture Empty Cantina", + slug: "fixture-empty-cantina", + type: "bar_restaurant", + locationName: "Oranjestad, Statia", + carriesBeerSlugs: [], + tapBeerSlugs: [], + canBeerSlugs: [], + isPublic: true, + sortOrder: 5, + links: { + website: "https://example.com/empty-cantina", + maps: "https://maps.example.com/empty-cantina", + instagram: "https://instagram.com/emptycantina", + }, + }, ]; diff --git a/smoke-tests/where-to-buy-filters.spec.ts b/smoke-tests/where-to-buy-filters.spec.ts index 573ab3c..4e2568e 100644 --- a/smoke-tests/where-to-buy-filters.spec.ts +++ b/smoke-tests/where-to-buy-filters.spec.ts @@ -6,10 +6,11 @@ import { test, expect } from "./fixtures"; // also proves filtering works with analytics consent declined. // // Fixture data (lib/where-to-buy-fixture.ts): -// Fixture Tavern Fort Bay, Saba tap: pilsner can: ipa -// Fixture Bottle Shop Windwardside, Saba can: ipa -// Fixture Harbor Bar SXM tap: pilsner + ipa -// Fixture Quiet Cafe Windwardside / The Bottom, Saba carries nothing +// Fixture Tavern Fort Bay, Saba tap: pilsner can: ipa +// Fixture Bottle Shop Windwardside, Saba can: ipa +// Fixture Harbor Bar SXM tap: pilsner + ipa +// Fixture Quiet Cafe Windwardside / The Bottom, Saba tap: pilsner +// Fixture Empty Cantina Oranjestad, Statia carries nothing — hidden (#130) // Flat Point Amber is a public beer carried by no venue — never an option. // The three Saba venues deliberately use distinct ", Saba" // locationNames so this suite covers the island-grouping regression from @@ -70,6 +71,8 @@ test("renders filters and all venues unfiltered", async ({ page }) => { "Fixture Quiet Cafe", "Fixture Harbor Bar", ]); + // The empty Statia venue never renders (#130). + await expect(page.getByText("Fixture Empty Cantina")).toHaveCount(0); // Availability caveat is always present. await expect( page.getByText("Availability can change", { exact: false }) @@ -78,9 +81,10 @@ test("renders filters and all venues unfiltered", async ({ page }) => { test("beer filter narrows to venues carrying that beer", async ({ page }) => { await beerSelect(page).selectOption("saba-suds-pilsner"); - await expect(statusText(page)).toHaveText("2 venues shown"); + await expect(statusText(page)).toHaveText("3 venues shown"); expect(await venueNames(page)).toEqual([ "Fixture Tavern", + "Fixture Quiet Cafe", "Fixture Harbor Bar", ]); await expect(page).toHaveURL(/beer=saba-suds-pilsner/); @@ -100,9 +104,10 @@ test("format chips filter by On Tap / In Can", async ({ page }) => { await expect(page).toHaveURL(/format=can/); await formatButton(page, "On Tap").click(); - await expect(statusText(page)).toHaveText("2 venues shown"); + await expect(statusText(page)).toHaveText("3 venues shown"); expect(await venueNames(page)).toEqual([ "Fixture Tavern", + "Fixture Quiet Cafe", "Fixture Harbor Bar", ]); @@ -223,14 +228,14 @@ test("stale or invalid URL params fall back to unfiltered", async ({ test("back/forward navigates between filter states", async ({ page }) => { await beerSelect(page).selectOption("saba-suds-pilsner"); - await expect(statusText(page)).toHaveText("2 venues shown"); + await expect(statusText(page)).toHaveText("3 venues shown"); await page.goBack(); await expect(statusText(page)).toHaveText("4 venues shown"); await expect(beerSelect(page)).toHaveValue(""); await page.goForward(); - await expect(statusText(page)).toHaveText("2 venues shown"); + await expect(statusText(page)).toHaveText("3 venues shown"); }); test("filter changes emit beer_filter events with facet labels", async ({ @@ -263,6 +268,55 @@ test("filter changes emit beer_filter events with facet labels", async ({ }); }); +test("venues with no beer inventory are hidden entirely", async ({ page }) => { + // Issue #130: Fixture Empty Cantina has a valid name/location/type and + // directions + social links but zero On Tap / In Can beer — it must not + // render, and as the only Statia venue it must create no island group or + // filter option. + await expect(page.getByText("Fixture Empty Cantina")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: /Statia/ }) + ).toHaveCount(0); + await expect( + islandSelect(page).locator("option") + ).toHaveText(["All islands", "Saba", "Sint Maarten / Saint Martin"]); + await expect(statusText(page)).toHaveText("4 venues shown"); + + // Query strings cannot resurrect it — "statia" is not an allowed island, + // so the filter sanitizes to unfiltered rather than showing the venue. + await page.goto(`${ROUTE}?island=statia`); + await expect(statusText(page)).toHaveText("4 venues shown"); + await expect(islandSelect(page)).toHaveValue(""); + await expect(page.getByText("Fixture Empty Cantina")).toHaveCount(0); +}); + +test("action rows align across cards with different beer-list heights", async ({ + page, +}) => { + // Issue #130: cards in one grid row bottom-align their action rows + // regardless of inventory-copy length. Tavern lists two format lines, + // Bottle Shop one — at 1280px all three Saba cards share a row. + await page.setViewportSize({ width: 1280, height: 900 }); + await page.reload(); + + const card = (name: string) => + page + .locator("main section .grid > div") + .filter({ has: page.getByRole("heading", { name, level: 3 }) }); + const actionRowTop = (name: string) => + card(name) + .locator("a") + .first() + .evaluate( + (link) => + (link.parentElement as HTMLElement).getBoundingClientRect().top + ); + + const tavernTop = await actionRowTop("Fixture Tavern"); + const bottleTop = await actionRowTop("Fixture Bottle Shop"); + expect(Math.abs(tavernTop - bottleTop)).toBeLessThanOrEqual(1); +}); + test("directions link survives filtering and keeps its tracking attrs", async ({ page, }) => { diff --git a/tests/lib/venue-filters.test.ts b/tests/lib/venue-filters.test.ts index 753a3be..5efd6b4 100644 --- a/tests/lib/venue-filters.test.ts +++ b/tests/lib/venue-filters.test.ts @@ -13,6 +13,7 @@ import { sanitizeVenueFilters, venueCarriesBeer, venueFiltersToSearch, + venueIsStocked, venueOffersFormat, EMPTY_VENUE_FILTERS, } from "../../lib/venue-filters"; @@ -166,8 +167,10 @@ describe("filterVenues", () => { // Tavern: Fort Bay, Saba — pilsner on tap + ipa in can // Bottle Shop: Windwardside, Saba — ipa in can // Harbor Bar: SXM — pilsner + ipa on tap - // Quiet Cafe: Windwardside / The Bottom, Saba — carries nothing - const venues = WHERE_TO_BUY_FIXTURE_VENUES; + // Quiet Cafe: Windwardside / The Bottom, Saba — pilsner on tap + // (Empty Cantina — Statia, no beer — is excluded up front by + // venueIsStocked, mirroring the page layer; see the describe below.) + const venues = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked); const names = (list: Venue[]) => list.map((v) => v.slug); it("no filters returns all public venues in order", () => { @@ -184,7 +187,7 @@ describe("filterVenues", () => { names( filterVenues(venues, { ...EMPTY_VENUE_FILTERS, beer: "saba-suds-pilsner" }) ), - ["fixture-tavern", "fixture-harbor-bar"] + ["fixture-tavern", "fixture-harbor-bar", "fixture-quiet-cafe"] ); }); @@ -217,7 +220,7 @@ describe("filterVenues", () => { it("format-only filtering checks any beer in that format", () => { assert.deepEqual( names(filterVenues(venues, { ...EMPTY_VENUE_FILTERS, format: "tap" })), - ["fixture-tavern", "fixture-harbor-bar"] + ["fixture-tavern", "fixture-harbor-bar", "fixture-quiet-cafe"] ); assert.deepEqual( names(filterVenues(venues, { ...EMPTY_VENUE_FILTERS, format: "can" })), @@ -250,7 +253,7 @@ describe("filterVenues", () => { island: "saba", }) ), - ["fixture-tavern"] + ["fixture-tavern", "fixture-quiet-cafe"] ); assert.deepEqual( names( @@ -298,7 +301,8 @@ describe("distinctIslands / groupVenuesByIsland", () => { it("offers one saba option and one saba group for all Saba localities", () => { // Issue #116 regression: distinct ", Saba" locationNames // must not fragment into per-locality island options or headings. - const venues = WHERE_TO_BUY_FIXTURE_VENUES; + // Stocked filter mirrors the public page (Empty Cantina drops out). + const venues = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked); assert.deepEqual(distinctIslands(venues), ["saba", "sxm"]); const groups = groupVenuesByIsland(venues); assert.deepEqual( @@ -349,6 +353,49 @@ describe("carriedBeerOptions", () => { }); }); +describe("venueIsStocked", () => { + it("is true for On Tap only, In Can only, and both", () => { + assert.equal(venueIsStocked(venue({ tapBeerSlugs: ["ipa"] })), true); + assert.equal(venueIsStocked(venue({ canBeerSlugs: ["ipa"] })), true); + assert.equal( + venueIsStocked(venue({ tapBeerSlugs: ["ipa"], canBeerSlugs: ["x"] })), + true + ); + }); + + it("is false when neither format lists a beer", () => { + assert.equal(venueIsStocked(venue({})), false); + assert.equal( + venueIsStocked(venue({ tapBeerSlugs: [], canBeerSlugs: [] })), + false + ); + // A beer recorded only under carriesBeerSlugs produces no visible + // On Tap / In Can line, so it does not count as stocked. + assert.equal( + venueIsStocked(venue({ carriesBeerSlugs: ["ipa"] })), + false + ); + }); + + it("removes unstocked venues from islands, beer options, and counts", () => { + // Mirrors the page wiring: the stocked list feeds every downstream + // derivation, so the sole unstocked Statia venue contributes nothing. + const stocked = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked); + assert.equal(stocked.length, 4); + assert.deepEqual(distinctIslands(stocked), ["saba", "sxm"]); + assert.deepEqual( + groupVenuesByIsland(stocked).map((g) => g.key), + ["saba", "sxm"] + ); + assert.deepEqual( + carriedBeerOptions(stocked, WHERE_TO_BUY_FIXTURE_BEERS).map( + (o) => o.slug + ), + ["saba-suds-pilsner", "fort-bay-ipa"] + ); + }); +}); + describe("hasAnyFormatData", () => { it("is false when no venue lists formats, true otherwise", () => { assert.equal(hasAnyFormatData([venue({})]), false);