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
6 changes: 4 additions & 2 deletions app/(pages)/where-to-buy-fixture/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])
Expand Down
12 changes: 10 additions & 2 deletions app/(pages)/where-to-buy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions components/venue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function VenueCard({ venue, beerNameBySlug }: VenueCardProps) {
const island = venue.locationName ?? "Saba";

return (
<div className="rounded-lg border border-stone bg-paper p-6">
<div className="flex h-full flex-col rounded-lg border border-stone bg-paper p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold tracking-tight">{venue.name}</h3>
Expand Down Expand Up @@ -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) && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<div className="mt-auto flex flex-wrap items-center gap-3 pt-4">
{venue.links.maps && (
<a
href={venue.links.maps}
Expand Down
19 changes: 15 additions & 4 deletions lib/venue-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,21 @@ export function carriedBeerOptions(
/** True when any venue lists a beer under a format — otherwise the format
* control would only ever produce an empty result. */
export function hasAnyFormatData(venues: Venue[]): boolean {
return venues.some(
(venue) =>
(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
);
}

Expand Down
28 changes: 25 additions & 3 deletions lib/where-to-buy-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
},
},
];
70 changes: 62 additions & 8 deletions smoke-tests/where-to-buy-filters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<locality>, Saba"
// locationNames so this suite covers the island-grouping regression from
Expand Down Expand Up @@ -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 })
Expand All @@ -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/);
Expand All @@ -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",
]);

Expand Down Expand Up @@ -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 ({
Expand Down Expand Up @@ -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,
}) => {
Expand Down
59 changes: 53 additions & 6 deletions tests/lib/venue-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
sanitizeVenueFilters,
venueCarriesBeer,
venueFiltersToSearch,
venueIsStocked,
venueOffersFormat,
EMPTY_VENUE_FILTERS,
} from "../../lib/venue-filters";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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"]
);
});

Expand Down Expand Up @@ -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" })),
Expand Down Expand Up @@ -250,7 +253,7 @@ describe("filterVenues", () => {
island: "saba",
})
),
["fixture-tavern"]
["fixture-tavern", "fixture-quiet-cafe"]
);
assert.deepEqual(
names(
Expand Down Expand Up @@ -298,7 +301,8 @@ describe("distinctIslands / groupVenuesByIsland", () => {
it("offers one saba option and one saba group for all Saba localities", () => {
// Issue #116 regression: distinct "<locality>, 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(
Expand Down Expand Up @@ -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);
Expand Down
Loading