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);