Squash merge main into feature branch

This commit is contained in:
Aaron Po
2026-10-11 20:40:20 -04:00
parent bce8fb0472
commit 30ad304a11
70 changed files with 1623 additions and 269 deletions

View File

@@ -0,0 +1,55 @@
# Landing page: data requirements
Covers `/` (`routes/home.tsx`), the public marketing landing page. The page is
built and live. This document lists what's real vs. filler, and what backend
work closes each gap.
## Status legend
- ✅ **Available**: real data, wired up.
- ⚠️ **Partial**: endpoint exists but is missing a field the design needs.
- ❌ **Missing**: no backend support; page shows filler or local-only state.
## Partner breweries preview
| Field | Status | Source | Gap |
| ------------------------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Name, description, city/region | ✅ | `GET /api/brewery?limit=3&offset=0` | |
| Newest-first ordering | ✅ | `GET /api/brewery` is already sorted | |
| "{n} beers" badge | ❌ | `FILLER_BEER_COUNTS` (`utils/filler-beer-counts.ts`) | Blocked on `Features.Beers`, which has a `.csproj` and nothing else — see `../breweries/BREWERY_HANDOFF.md`. |
## Hero, features, closing CTA
Static marketing copy and one bundled photo
(`assets/bar-background.jpg`, carried over from the archived Next.js
frontend). No data needs. Replace the photo when real photography exists.
## Theme try-out
✅ Fully real: renders `ThemeSegmentedControl`
(`../theme/components/ThemeSegmentedControl.tsx`), the same `join` + radio
control the theme guide uses. Selection sets `data-theme` on `<html>` and
persists through the `biergarten-theme` cookie that the root loader reads.
## Footer
✅ Fully real: swaps Login for Dashboard from the session that
`getOptionalAuth` resolves in the route loader.
## Loader resilience
`getBreweries` throws a 503 `data()` response when the API is unreachable. The
landing page catches it and renders the "No breweries have been posted yet."
empty state, so a brewery service outage leaves the marketing content intact.
`/breweries` keeps the opposite behaviour — a brewery page with no breweries is
an error, so it surfaces its `ErrorBoundary`.
## Summary of backend work
1. Stand up `Features.Beers` so the per-brewery beer count is real. This is the
same blocker already listed in `../breweries/BREWERY_HANDOFF.md`; closing it
there closes it here.
Once that lands, swap `FILLER_BEER_COUNTS` for a count carried on `BreweryDto`
(or a dedicated beers-by-brewery query) and delete
`utils/filler-beer-counts.ts`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import AiDisclosureDialog from './AiDisclosureDialog';
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
const meta = {
title: 'Disclosure/AiDisclosureDialog',
component: AiDisclosureDialog,
tags: ['autodocs'],
args: {
defaultOpen: true,
},
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: aiDisclosureDescription,
},
},
},
} satisfies Meta<typeof AiDisclosureDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await expect(
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
).toBeVisible();
});
await expect(
canvas.getByRole('link', { name: /how this data was generated/i }),
).toBeVisible();
},
};
export const Acknowledged: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};
export const InitiallyDismissed: Story = {
args: {
defaultOpen: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};

View File

@@ -0,0 +1,79 @@
import { Sparks } from 'iconoir-react';
import { useEffect, useState } from 'react';
const ethicsDocUrl =
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) {
return null;
}
return (
<div
className="modal modal-open"
role="dialog"
aria-modal="true"
aria-labelledby="ai-disclosure-title"
>
<div className="modal-box max-w-md">
<div className="flex flex-col items-center gap-3 text-center">
<span className="text-primary">
<Sparks className="size-10" aria-hidden="true" />
</span>
<h2 id="ai-disclosure-title" className="text-lg font-bold">
This content is AI-generated
</h2>
<p className="text-sm text-base-content/70">
The Biergarten App is a proof of concept. Every brewery, beer, and user in
this catalogue is fixture data produced by a language model — none of it
describes a real business, person, or beer.
</p>
<div role="alert" className="alert alert-warning alert-soft text-left">
<span className="text-sm">
Brewing details, local-language text, and coordinates are known to
contain fabrications. Treat nothing here as factual.
</span>
</div>
<a
href={ethicsDocUrl}
target="_blank"
rel="noreferrer"
className="link link-primary text-sm"
>
How this data was generated
</a>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-primary w-full"
autoFocus
onClick={() => setOpen(false)}
>
I understand
</button>
</div>
</div>
</div>
);
};
export default AiDisclosureDialog;

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import ClosingCta from './ClosingCta';
const closingCtaDescription = `Landing page closing call to action: centred headline with the sign-up and sign-in pair.`;
const meta = {
title: 'Home/ClosingCta',
component: ClosingCta,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: closingCtaDescription,
},
},
},
} satisfies Meta<typeof ClosingCta>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole('heading', { name: /your next favourite brewery is on the list/i }),
).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
'href',
'/register',
);
await expect(canvas.getByRole('link', { name: 'Sign in' })).toHaveAttribute(
'href',
'/login',
);
},
};

View File

@@ -0,0 +1,24 @@
import { Link } from 'react-router';
const ClosingCta = () => (
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
Your next favourite brewery is on the list.
</h2>
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">
Create an account to save breweries and log the beers you try.
</p>
<div className="flex flex-wrap justify-center gap-3">
<Link to="/register" className="btn btn-primary">
Create an account
</Link>
<Link to="/login" className="btn btn-ghost">
Sign in
</Link>
</div>
</section>
);
export default ClosingCta;

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import FeatureHighlights from './FeatureHighlights';
const featureHighlightsDescription = `Landing page feature band: three icon-led product capabilities on a base-100 strip.`;
const meta = {
title: 'Home/FeatureHighlights',
component: FeatureHighlights,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: featureHighlightsDescription,
},
},
},
} satisfies Meta<typeof FeatureHighlights>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole('heading', { name: 'Everything you need to plan the next round' }),
).toBeInTheDocument();
for (const title of ['Browse breweries', 'Explore the beers', 'Keep a record']) {
await expect(canvas.getByRole('heading', { name: title })).toBeInTheDocument();
}
},
};

View File

@@ -0,0 +1,43 @@
import { Bookmark, GlassHalf, MapPin } from 'iconoir-react';
const features = [
{
icon: MapPin,
title: 'Browse breweries',
body: 'Search the directory by name or city. Every listing shows location, hours and what is on tap.',
},
{
icon: GlassHalf,
title: 'Explore the beers',
body: 'See each brewery’s full lineup with style, ABV and tasting notes from other members.',
},
{
icon: Bookmark,
title: 'Keep a record',
body: 'Save the places you want to visit and check off the beers you have tried from your dashboard.',
},
];
const FeatureHighlights = () => (
<section className="border-y border-base-300 bg-base-100">
<div className="mx-auto flex max-w-7xl flex-col gap-10 px-5 py-16">
<h2 className="m-0 max-w-[36rem] font-serif text-3xl leading-tight">
Everything you need to plan the next round
</h2>
<div className="grid gap-8 grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))]">
{features.map(({ icon: Icon, title, body }) => (
<div key={title} className="flex flex-col gap-3">
<div className="flex size-11 items-center justify-center rounded-field bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
<Icon width={20} height={20} aria-hidden />
</div>
<h3 className="m-0 font-serif text-xl">{title}</h3>
<p className="m-0 leading-[1.55] text-[var(--color-muted)]">{body}</p>
</div>
))}
</div>
</div>
</section>
);
export default FeatureHighlights;

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import HeroSection from './HeroSection';
const heroSectionDescription = `Landing page hero: eyebrow, headline, supporting copy, the primary sign-up pair and the bar photo.`;
const meta = {
title: 'Home/HeroSection',
component: HeroSection,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: heroSectionDescription,
},
},
},
} satisfies Meta<typeof HeroSection>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole('heading', { name: 'Find the breweries worth the trip.' }),
).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
'href',
'/register',
);
await expect(canvas.getByRole('link', { name: 'Browse breweries' })).toHaveAttribute(
'href',
'/breweries',
);
},
};

View File

@@ -0,0 +1,44 @@
import { Link } from 'react-router';
import barBackground from '../assets/bar-background.jpg';
const HeroSection = () => (
<section className="mx-auto max-w-7xl px-5 pt-18 pb-16">
<div className="grid items-center gap-12 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))]">
<div className="flex flex-col gap-6">
<p className="m-0 text-sm font-semibold uppercase tracking-widest text-[var(--color-muted)]">
Brewery discovery
</p>
<h1 className="m-0 font-serif text-[clamp(2.5rem,5vw,4rem)] leading-[1.08] text-balance">
Find the breweries worth the trip.
</h1>
<p className="m-0 max-w-[34rem] text-lg leading-[1.55] text-[var(--color-muted)]">
Browse our partner breweries, see what they pour, and keep track of the beers
you have tried — all in one account.
</p>
<div className="flex flex-wrap gap-3">
<Link to="/register" className="btn btn-primary">
Create an account
</Link>
<Link to="/breweries" className="btn btn-outline">
Browse breweries
</Link>
</div>
<p className="m-0 text-sm text-[var(--color-muted)]">
Free to join. Must be 19 years or older.
</p>
</div>
<img
src={barBackground}
alt="A glass of lager on a bar counter"
className="w-full max-h-[34rem] aspect-[4/5] rounded-box bg-base-300 object-cover shadow-xl"
/>
</div>
</section>
);
export default HeroSection;

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import LandingFooter from './LandingFooter';
const landingFooterDescription = `Landing page footer. The last link follows the session: Login when signed out, Dashboard when signed in.`;
const meta = {
title: 'Home/LandingFooter',
component: LandingFooter,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: landingFooterDescription,
},
},
},
} satisfies Meta<typeof LandingFooter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SignedOut: Story = {
args: { isAuthenticated: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: 'Login' })).toHaveAttribute('href', '/login');
await expect(canvas.queryByRole('link', { name: 'Dashboard' })).not.toBeInTheDocument();
await expect(canvas.getByText('Please drink responsibly.')).toBeInTheDocument();
},
};
export const SignedIn: Story = {
args: { isAuthenticated: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: 'Dashboard' })).toHaveAttribute(
'href',
'/dashboard',
);
await expect(canvas.queryByRole('link', { name: 'Login' })).not.toBeInTheDocument();
},
};

View File

@@ -0,0 +1,42 @@
import { Link } from 'react-router';
const footerLinks = [
{ to: '/breweries', label: 'Breweries' },
{ to: '/beers', label: 'Beers' },
{ to: '/theme', label: 'Theme guide' },
];
interface LandingFooterProps {
isAuthenticated: boolean;
}
const LandingFooter = ({ isAuthenticated }: LandingFooterProps) => (
<div className="border-t border-base-300 bg-base-100">
<footer className="footer footer-vertical sm:footer-horizontal mx-auto max-w-7xl items-center justify-between gap-4 px-5 py-6 text-sm text-[var(--color-muted)]">
<aside>
<span className="font-bold text-base-content">🍺 The Biergarten App</span>
</aside>
<nav className="flex flex-row flex-wrap gap-5">
{footerLinks.map((link) => (
<Link key={link.to} to={link.to} className="link link-hover hover:text-primary">
{link.label}
</Link>
))}
{isAuthenticated ? (
<Link to="/dashboard" className="link link-hover hover:text-primary">
Dashboard
</Link>
) : (
<Link to="/login" className="link link-hover hover:text-primary">
Login
</Link>
)}
</nav>
<p className="m-0">Please drink responsibly.</p>
</footer>
</div>
);
export default LandingFooter;

View File

@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import type { Brewery, BreweryLocation } from '../../breweries/breweries.server';
import PartnerBreweries from './PartnerBreweries';
const partnerBreweriesDescription = `Landing page preview of the three most recently added breweries. Falls back to the empty-state line when the directory is empty or the brewery service is unreachable.`;
const halifaxLocation: BreweryLocation = {
breweryPostLocationId: 'loc-1',
breweryPostId: 'brewery-1',
cityId: 'city-1',
cityName: 'Halifax',
stateProvinceName: 'Nova Scotia',
stateProvinceCode: 'NS',
countryName: 'Canada',
countryCode: 'CA',
addressLine1: '12 Lower Water Street',
addressLine2: null,
postalCode: 'B3J 1S3',
coordinates: { latitude: 44.6488, longitude: -63.5752 },
};
const harbourline: Brewery = {
breweryPostId: 'brewery-1',
postedById: 'user-1',
breweryName: 'Harbourline Brewing',
description: 'Crisp lagers and a rotating sour program, a short walk from the waterfront.',
createdAt: '2024-05-01T12:00:00.000Z',
updatedAt: null,
location: halifaxLocation,
};
const oldMill: Brewery = {
breweryPostId: 'brewery-2',
postedById: 'user-2',
breweryName: 'Old Mill Ale Works',
description: 'Cask ales and English bitters brewed in a restored 1880s grain mill.',
createdAt: '2024-04-18T09:30:00.000Z',
updatedAt: null,
location: {
...halifaxLocation,
breweryPostId: 'brewery-2',
cityName: 'Guelph',
stateProvinceName: 'Ontario',
stateProvinceCode: 'ON',
},
};
const cedarAndHop: Brewery = {
breweryPostId: 'brewery-3',
postedById: 'user-3',
breweryName: 'Cedar & Hop',
description: 'A pop-up taproom that has not added a storefront location yet.',
createdAt: '2024-04-02T18:00:00.000Z',
updatedAt: null,
location: null,
};
const meta = {
title: 'Home/PartnerBreweries',
component: PartnerBreweries,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: partnerBreweriesDescription,
},
},
},
} satisfies Meta<typeof PartnerBreweries>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithBreweries: Story = {
args: { breweries: [harbourline, oldMill, cedarAndHop] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole('heading', { name: 'Discover our partner breweries' }),
).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /view all breweries/i })).toHaveAttribute(
'href',
'/breweries',
);
const links = canvas.getAllByRole('link', { name: 'View brewery' });
await expect(links).toHaveLength(3);
await expect(links[0]).toHaveAttribute('href', '/breweries/brewery-1');
// see HOME_HANDOFF.md — the counts are filler until the beers API exists.
await expect(canvas.getByText('14 beers')).toBeInTheDocument();
},
};
export const Empty: Story = {
args: { breweries: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('No breweries have been posted yet.')).toBeInTheDocument();
await expect(canvas.queryByRole('link', { name: 'View brewery' })).not.toBeInTheDocument();
},
};

View File

@@ -0,0 +1,70 @@
import { Link } from 'react-router';
import type { Brewery } from '../../breweries/breweries.server';
import { FILLER_BEER_COUNTS } from '../utils/filler-beer-counts';
interface PartnerBreweriesProps {
breweries: Brewery[];
}
const PartnerBreweries = ({ breweries }: PartnerBreweriesProps) => (
<section className="mx-auto flex max-w-7xl flex-col gap-8 px-5 py-16">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="m-0 font-serif text-3xl leading-tight">
Discover our partner breweries
</h2>
<p className="m-0 text-[var(--color-muted)]">
A few recent additions to the directory.
</p>
</div>
<Link
to="/breweries"
className="font-semibold text-primary hover:text-secondary hover:underline"
>
View all breweries &rarr;
</Link>
</div>
{breweries.length === 0 ? (
<p className="m-0 text-[var(--color-muted)]">No breweries have been posted yet.</p>
) : (
<div className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))]">
{breweries.map((brewery, index) => (
<div key={brewery.breweryPostId} className="card bg-base-100 shadow-md">
<div className="card-body gap-3">
<div className="flex items-start justify-between gap-3">
<h3 className="card-title m-0 font-serif text-xl">
{brewery.breweryName}
</h3>
<span className="badge badge-accent badge-sm whitespace-nowrap font-semibold">
{FILLER_BEER_COUNTS[index % FILLER_BEER_COUNTS.length]} beers
</span>
</div>
{brewery.location && (
<p className="m-0 text-sm text-[var(--color-muted)]">
{brewery.location.cityName},{' '}
{brewery.location.stateProvinceCode}
</p>
)}
<p className="m-0 leading-[1.55]">{brewery.description}</p>
<div className="card-actions">
<Link
to={`/breweries/${brewery.breweryPostId}`}
className="btn btn-ghost btn-sm"
>
View brewery
</Link>
</div>
</div>
</div>
))}
</div>
)}
</section>
);
export default PartnerBreweries;

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { expect, within } from 'storybook/test';
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
import ThemeTryOut from './ThemeTryOut';
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
const ThemeTryOutHarness = () => {
const [router] = useState(() =>
createMemoryRouter([
{
id: 'root',
path: '/',
loader: () => ({ theme: defaultThemeName }),
element: <ThemeTryOut />,
},
]),
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Home/ThemeTryOut',
component: ThemeTryOutHarness,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
usesDataRouter: true,
docs: {
description: {
component: themeTryOutDescription,
},
},
},
} satisfies Meta<typeof ThemeTryOutHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
).toBeInTheDocument();
await expect(canvas.getByText('Set the mood')).toBeVisible();
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
},
};

View File

@@ -0,0 +1,32 @@
import ThemeSegmentedControl from '../../theme/components/ThemeSegmentedControl';
const ThemeTryOut = () => (
<section className="bg-neutral text-neutral-content">
<div className="mx-auto grid max-w-7xl items-center gap-8 px-5 py-16 grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
<div className="flex flex-col gap-4">
<h2 className="m-0 font-serif text-3xl leading-tight">
Pick a pour that suits you
</h2>
<p className="m-0 max-w-[32rem] leading-[1.55] opacity-80">
Four themes ship with the app — a light lager, a dark roast stout, a berry
cassis and a bright weizen. Try one; this page switches with it.
</p>
</div>
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
Set the mood
</p>
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
</div>
<span className="badge badge-outline shrink-0">4 pours</span>
</div>
<ThemeSegmentedControl labels="short" />
</div>
</div>
</section>
);
export default ThemeTryOut;

View File

@@ -1,55 +1,51 @@
import { Link } from 'react-router';
import { getBreweries, type Brewery } from '../../breweries/breweries.server';
import { getOptionalAuth } from '../../auth/auth.server';
import ClosingCta from '../components/ClosingCta';
import FeatureHighlights from '../components/FeatureHighlights';
import HeroSection from '../components/HeroSection';
import LandingFooter from '../components/LandingFooter';
import PartnerBreweries from '../components/PartnerBreweries';
import ThemeTryOut from '../components/ThemeTryOut';
import type { Route } from './+types/home';
const PARTNER_BREWERY_COUNT = 3;
export const meta = ({}: Route.MetaArgs) => [
{ title: 'The Biergarten App' },
{ name: 'description', content: 'Welcome to The Biergarten App' },
{
name: 'description',
content:
'Browse our partner breweries, see what they pour, and keep track of the beers you have tried.',
},
];
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
return { username: auth?.username ?? null };
// The rest of the landing page stands on its own, so a brewery service
// outage degrades to the empty state instead of an error page.
let recentBreweries: Brewery[] = [];
try {
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
} catch {
recentBreweries = [];
}
return { isAuthenticated: auth !== null, recentBreweries };
};
const Home = ({ loaderData }: Route.ComponentProps) => {
const { username } = loaderData;
const { isAuthenticated, recentBreweries } = loaderData;
return (
<div className="hero min-h-screen bg-base-200">
<div className="hero-content text-center">
<div className="max-w-md space-y-6">
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
<p className="text-lg text-base-content/70">Authentication Demo</p>
{username ? (
<>
<p className="text-base-content/80">
Welcome back,{' '}
<span className="font-semibold text-primary">{username}</span>!
</p>
<div className="flex gap-3 justify-center">
<Link to="/dashboard" className="btn btn-primary">
Dashboard
</Link>
<Link to="/logout" className="btn btn-ghost">
Logout
</Link>
</div>
</>
) : (
<div className="flex gap-3 justify-center">
<Link to="/login" className="btn btn-primary">
Login
</Link>
<Link to="/register" className="btn btn-outline">
Register
</Link>
</div>
)}
</div>
</div>
</div>
<main className="bg-base-200 text-base-content">
<HeroSection />
<FeatureHighlights />
<PartnerBreweries breweries={recentBreweries} />
<ThemeTryOut />
<ClosingCta />
<LandingFooter isAuthenticated={isAuthenticated} />
</main>
);
};

View File

@@ -0,0 +1,3 @@
// see HOME_HANDOFF.md — the beers API does not exist yet, so the "{n} beers"
// badge on the partner brewery cards reads from this list by card position.
export const FILLER_BEER_COUNTS = [14, 9, 11];