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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ Never commit real values to source control. `.env.local` is already ignored by G
| `npm run seed:venues` | Seed sample venue data (developer script) |
| `npm run bootstrap-superadmin` | Grant the initial superadmin role to `SUPER_ADMIN_EMAIL` (see docs first) |
| `npm run prune:trade-leads` | Prune trade inquiries past the 24-month retention window (dry-run; `-- --delete` executes) |
| `npm run migrate:venue-islands` | Backfill the canonical venue `island` field from legacy `locationName` values (dry-run; `-- --write` executes) |

## Deployment overview

Expand Down
13 changes: 12 additions & 1 deletion components/admin-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
type RebuildMeta,
} from "@/components/admin-workspace";
import { refreshAdminAccess } from "@/lib/admin-session-refresh";
import { resolveVenueIsland } from "@/lib/venue-filters";
import { isVenueIsland } from "@/lib/venue-islands";
import type { AdminRole, Beer, Venue } from "@/lib/types";

const DEFAULT_BEER: Beer = {
Expand Down Expand Up @@ -195,7 +197,11 @@ export function AdminDashboard() {
useEffect(() => {
const found = venues.find((venue) => venue.slug === selectedVenueSlug);
if (!found) return;
setVenueForm(found);
// Legacy records lack the canonical island field (Issue #134): preselect
// the island resolved from the old free-text location so saving writes
// the field without manual repair. Unresolvable records leave the
// required select unset instead of guessing.
setVenueForm({ ...found, island: resolveVenueIsland(found) });
setVenueCarriesSelection(found.carriesBeerSlugs ?? []);
setVenueTapSelection(found.tapBeerSlugs ?? []);
setVenueCanSelection(found.canBeerSlugs ?? []);
Expand Down Expand Up @@ -538,6 +544,11 @@ export function AdminDashboard() {
return;
}

if (!isVenueIsland(venueForm.island)) {
setStatusMessage("Select an island for this venue.");
return;
}

setIsSaving(true);
setStatusMessage("");
try {
Expand Down
10 changes: 9 additions & 1 deletion components/admin-fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
AdminWorkspace,
type RebuildMeta,
} from "@/components/admin-workspace";
import { resolveVenueIsland } from "@/lib/venue-filters";
import { isVenueIsland } from "@/lib/venue-islands";
import type { AdminRole, Beer, Venue } from "@/lib/types";

const FIXTURE_BEERS: Beer[] = [
Expand Down Expand Up @@ -50,6 +52,7 @@ const FIXTURE_VENUES: Venue[] = [
slug: "fixture-tavern",
type: "bar_restaurant",
locationName: "Windwardside",
island: "saba",
carriesBeerSlugs: ["saba-suds-pilsner"],
tapBeerSlugs: ["saba-suds-pilsner"],
canBeerSlugs: [],
Expand Down Expand Up @@ -143,7 +146,8 @@ export function AdminFixture({ role }: { role: AdminRole }) {
setSelectedVenueSlug(slug);
const found = FIXTURE_VENUES.find((venue) => venue.slug === slug);
if (found) {
setVenueForm(found);
// Mirror the real dashboard's legacy-island resolution (Issue #134).
setVenueForm({ ...found, island: resolveVenueIsland(found) });
setVenueCarriesSelection(found.carriesBeerSlugs ?? []);
setVenueTapSelection(found.tapBeerSlugs ?? []);
setVenueCanSelection(found.canBeerSlugs ?? []);
Expand All @@ -169,6 +173,10 @@ export function AdminFixture({ role }: { role: AdminRole }) {
setStatusMessage("Venue name and slug are required.");
return;
}
if (!isVenueIsland(venueForm.island)) {
setStatusMessage("Select an island for this venue.");
return;
}
setIsSaving(true);
setStatusMessage("");
setTimeout(() => {
Expand Down
21 changes: 19 additions & 2 deletions components/admin-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AdminPanelUser,
} from "@/components/admin-access";
import type { Beer, Venue } from "@/lib/types";
import { VENUE_ISLAND_OPTIONS, type VenueIsland } from "@/lib/venue-islands";

export interface RebuildMeta {
cooldownUntil?: number;
Expand Down Expand Up @@ -383,8 +384,24 @@ export function AdminWorkspace({
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Location Name</span>
<input className={fieldClass} value={venueForm.locationName} onChange={(e) => setVenueForm((prev) => ({ ...prev, locationName: e.target.value }))} />
<span className="mb-1 block font-medium">
Island
<span aria-hidden="true" className="text-ember"> *</span>
</span>
<select required className={fieldClass} value={venueForm.island ?? ""} onChange={(e) => setVenueForm((prev) => ({ ...prev, island: (e.target.value || undefined) as VenueIsland | undefined }))}>
<option value="" disabled>
Select an island
</option>
{VENUE_ISLAND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Location / locality</span>
<input className={fieldClass} value={venueForm.locationName} onChange={(e) => setVenueForm((prev) => ({ ...prev, locationName: e.target.value }))} placeholder="e.g. Windwardside" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Sort Order</span>
Expand Down
7 changes: 5 additions & 2 deletions components/venue-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { venueCardLocation, venueIslandKey } from "@/lib/venue-filters";
import type { Venue } from "@/lib/types";

const typeLabels: Record<Venue["type"], string> = {
Expand All @@ -14,15 +15,17 @@ interface VenueCardProps {
export function VenueCard({ venue, beerNameBySlug }: VenueCardProps) {
const tapBeers = (venue.tapBeerSlugs ?? []).map((slug) => beerNameBySlug[slug] ?? slug);
const canBeers = (venue.canBeerSlugs ?? []).map((slug) => beerNameBySlug[slug] ?? slug);
const island = venue.locationName ?? "Saba";
// Analytics island param is the canonical key ("saba"/"sxm"/"statia") —
// a stable identifier, never free-form locality text.
const island = venueIslandKey(venue);

return (
<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>
<p className="mt-1 text-sm text-muted-foreground">
{venue.locationName}
{venueCardLocation(venue)}
</p>
</div>
<Badge variant="outline">{typeLabels[venue.type]}</Badge>
Expand Down
23 changes: 15 additions & 8 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
| `lib/` | Shared logic. Client-safe: `firebase.ts`, `beers.ts`, `venues.ts`, `analytics.ts`, `types.ts`, `utils.ts`, `email.ts`, `trade-leads-common.ts`, admin `*-common`/`admin-format.ts` helpers. Server-only (`import "server-only"`): `firebase-admin.ts`, `admin-auth.ts`, `admin-users.ts`, `admin-invitations.ts`, `admin-invitation-email.ts`, `admin-invitation-resend-core.ts`, `admin-audit.ts`, `trade-leads.ts`. Policy/serialization helpers shared by both: `admin-policy.ts`, `admin-serializers.ts`, `admin-invitation-policy.ts`, `admin-invitation-resend-policy.ts`, `admin-types.ts`. |
| `tests/` | Node `node:test` unit tests (`tsx` loader) for admin/auth/invitation/audit helpers and for the *contents* of `firestore.rules` and `storage.rules`. |
| `rules-tests/` | Emulator-backed security-rules tests (`@firebase/rules-unit-testing` against the Firestore/Storage emulators). Run via `npm run test:rules`, which wraps `firebase emulators:exec`; each file uses its own `demo-*` project so parallel `node:test` files stay isolated. |
| `scripts/` | Local/manual tooling: Playwright diagnostics (`*-check.mjs`, `hero-video-network.mjs`), `check-md-links.mjs`, `check-react-versions.mjs`, `optimize-assets.mjs`, `bootstrap-superadmin.ts`, `prune-trade-leads.ts`, `seed-beers.ts`, `seed-venues.ts`. `check-md-links.mjs` and `check-react-versions.mjs` run in CI; the Playwright diagnostics and prune script do not (CI browser coverage lives in `smoke-tests/`). |
| `scripts/` | Local/manual tooling: Playwright diagnostics (`*-check.mjs`, `hero-video-network.mjs`), `check-md-links.mjs`, `check-react-versions.mjs`, `optimize-assets.mjs`, `bootstrap-superadmin.ts`, `prune-trade-leads.ts`, `migrate-venue-islands.ts`, `seed-beers.ts`, `seed-venues.ts`. `check-md-links.mjs` and `check-react-versions.mjs` run in CI; the Playwright diagnostics and data scripts do not (CI browser coverage lives in `smoke-tests/`). |
| `docs/` | Admin handbook (`docs/admin/`), operations guides (`docs/operations/`: deployment, troubleshooting, post-deploy checklist), and this file. |
| `content/` | Legacy placeholder (`.gitkeep` only). MDX content is co-located under `app/(pages)/`; do not add files here expecting them to render. |
| `firestore.rules`, `storage.rules` | Firebase security rules — see §6/§15. |
Expand All @@ -148,7 +148,7 @@ moving it into `lib/` or API routes without an explicit issue.
| `/` | `app/page.tsx` | Server (static) | `beers` collection via `getBeers()` | Home page; hero, featured beer/carousel, intro, brewery/CTA sections. |
| `/beers` | `app/(pages)/beers/page.tsx` | Server (static) | `beers` via `getBeers()` | Catalog grid; `BeersFilterGrid` (client) provides filtering. |
| `/beers/[slug]` | `app/(pages)/beers/[slug]/page.tsx` | Server (**static**, `generateStaticParams` + `dynamicParams = false`) | `beers` via `getBeerBySlug(slug)` at build time | Beer detail; `generateMetadata` per slug, JSON-LD, `BeerViewTracker` (client) emits `beer_detail_view`; unknown slugs 404. |
| `/where-to-buy` | `app/(pages)/where-to-buy/page.tsx` | Server (static) | `venues` + `beers` | Venue list grouped by island (Saba, SXM, Statia normalization in `lib/venue-filters.ts`), `VenueCard` entries, client-side beer/format/island filtering via `VenueDirectory` (URL-mirrored `?beer=&format=&island=` state). |
| `/where-to-buy` | `app/(pages)/where-to-buy/page.tsx` | Server (static) | `venues` + `beers` | Venue list grouped by the canonical `island` field (`lib/venue-islands.ts`, legacy `locationName` fallback in `lib/venue-filters.ts`), `VenueCard` entries, client-side beer/format/island filtering via `VenueDirectory` (URL-mirrored `?beer=&format=&island=` state). |
| `/about` | `app/(pages)/about/page.mdx` | Server (static) | none | MDX content styled by `mdx-components.tsx`. |
| `/contact` | `app/(pages)/contact/page.tsx` | Server (static) | none | Contact details; `TrackedAnchor` for click analytics. |
| `/trade` | `app/(pages)/trade/page.tsx` | Server (static) | none | Wholesale/trade page hosting `TradeInquiryForm` (client). See the `/trade` note below. |
Expand Down Expand Up @@ -202,10 +202,15 @@ in code are listed.

- **Purpose:** where-to-buy partner listings.
- **Key fields (`Venue` in `lib/types.ts`):** `name`, `slug`, `type`
(`"bar_restaurant" | "retail"`), `locationName` (island/area string,
normalized at render time), `carriesBeerSlugs[]`, optional
(`"bar_restaurant" | "retail"`), `island` (canonical island key —
`"saba" | "sxm" | "statia"`, enforced on writes by `firestore.rules`;
see `lib/venue-islands.ts`), `locationName` (free-text locality shown on
the venue card — never used for grouping), `carriesBeerSlugs[]`, optional
`tapBeerSlugs[]`/`canBeerSlugs[]`, `isPublic`, `sortOrder`, `links`
(`website`/`maps`/`instagram`/`facebook`/`untappd`), `notesPublic`.
Issue #134 split island identity out of `locationName`; reads fall back
to parsing `locationName` for legacy records until the
`migrate:venue-islands` backfill completes.
- **Reads:** `/where-to-buy` via `getVenues()` (`isPublic` + `sortOrder`);
rules allow public reads only of `isPublic` docs; admin dashboard reads all.
- **Writes:** admin dashboard `setDoc` merge keyed by `slug` (client SDK).
Expand Down Expand Up @@ -446,8 +451,9 @@ server-side.
visibility and ordering; `status` (`core`/`seasonal`/`limited`) is display
metadata.
- **Venue data:** `getVenues()` queries `venues` where `isPublic == true`,
ordered by `sortOrder`. `/where-to-buy` groups by `locationName` with island
normalization (Saba / SXM / Statia), maps `carriesBeerSlugs`/`tapBeerSlugs`/
ordered by `sortOrder`. `/where-to-buy` groups by the canonical `island`
field (`saba` / `sxm` / `statia`, with a transitional `locationName`
inference fallback for unmigrated records), maps `carriesBeerSlugs`/`tapBeerSlugs`/
`canBeerSlugs` to beer names, and renders `VenueCard` entries with their
`links` and `notesPublic`. `VenueDirectory` (`components/venue-directory.tsx`)
is a client component that filters the server-rendered list — logic lives in
Expand Down Expand Up @@ -477,7 +483,7 @@ All of these live in `components/admin-dashboard.tsx` (client) plus
| Operation | Mechanism | Enforcement |
| --- | --- | --- |
| Load beers/venues | Client SDK reads (all docs, `sortOrder` asc — including non-public) | Rules: public sees `isPublic` only; active admin (claim + matching record) reads all |
| Save beer / venue | Client SDK `setDoc(doc(db, "beers"|"venues", slug), payload, { merge: true })` — doc id is the slug | `hasActiveAdmin` in `firestore.rules` (claim + active matching `adminUsers` record) |
| Save beer / venue | Client SDK `setDoc(doc(db, "beers"|"venues", slug), payload, { merge: true })` — doc id is the slug | `hasActiveAdmin` in `firestore.rules` (claim + active matching `adminUsers` record); venue writes additionally require a canonical `island` (`saba`/`sxm`/`statia`) |
| Upload images | Client SDK `uploadBytes` to Storage | `hasActiveAdmin` in `storage.rules` (claim + cross-service `firestore.get()` active-record check) |
| Update rebuild metadata | Client SDK `setDoc` merge on `meta/siteRebuild` (`contentUpdatedAt/By`, `lastTriggeredAt/By`, `cooldownUntil`) | rules gate `meta` to active admins |
| Trigger rebuild | `POST /api/admin/rebuild` with Bearer token | Server: `requireAdminActor` (claims + active matching `adminUsers` record) + in-memory cooldown → POST to Vercel deploy hook |
Expand Down Expand Up @@ -741,7 +747,8 @@ placeholder values exist anywhere in CI.
- **Local-only scripts (`scripts/`):** Playwright-based manual diagnostics
(`screenshot-check`, `overflow-check`, `hero-video-*`),
`optimize-assets.mjs`, and Admin-SDK utilities (`bootstrap-superadmin.ts`,
`prune-trade-leads.ts`, `seed-beers.ts`, `seed-venues.ts`). These remain
`prune-trade-leads.ts`, `migrate-venue-islands.ts`, `seed-beers.ts`,
`seed-venues.ts`). These remain
manual/local; the CI smoke suite lives in `smoke-tests/`.
- **Verification parity:** local pre-PR checks are the same commands CI
runs, per `AGENTS.md`.
Expand Down
Loading
Loading