Create landing page (#321)

This commit is contained in:
Aaron Po
2026-10-03 18:46:32 -04:00
committed by GitHub
parent c5972b9f04
commit bd0e9e4a20
19 changed files with 733 additions and 73 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,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,56 @@
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. Hosts the shared \`ThemeSegmentedControl\` with short labels, so a visitor can switch the whole document theme from the marketing page. 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.`;
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();
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.shortLabel)).toBeInTheDocument();
}
},
};

View File

@@ -0,0 +1,26 @@
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 bg-base-100 p-3 text-base-content">
<ThemeSegmentedControl
labels="short"
className="join join-vertical w-full sm:join-horizontal"
/>
</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];

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useRouteLoaderData } from 'react-router';
import type { loader as rootLoader } from '../../../root';
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
// The semantic aliases resolve at :root, so the attribute has to land on <html>
// rather than a nested element for the whole document to switch.
const applyTheme = (theme: ThemeName) => {
document.documentElement.setAttribute('data-theme', theme);
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
};
interface ThemeSegmentedControlProps {
/** `short` renders "Lager"; `full` renders "Biergarten Lager". */
labels?: 'full' | 'short';
className?: string;
onChange?: (theme: ThemeName) => void;
}
const ThemeSegmentedControl = ({
labels = 'full',
className = 'join join-vertical sm:join-horizontal',
onChange,
}: ThemeSegmentedControlProps) => {
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
return (
<div className={className} role="radiogroup" aria-label="Theme selector">
{biergartenThemes.map((theme) => {
const checked = selectedTheme === theme.value;
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
>
<input
type="radio"
name="theme"
value={theme.value}
className="sr-only"
checked={checked}
onChange={() => {
setSelectedTheme(theme.value);
applyTheme(theme.value);
onChange?.(theme.value);
}}
/>
{labels === 'short' ? theme.shortLabel : theme.label}
</label>
);
})}
</div>
);
};
export default ThemeSegmentedControl;

View File

@@ -1,12 +1,8 @@
import { useState } from 'react';
import { useRouteLoaderData } from 'react-router';
import type { loader as rootLoader } from '../../../root';
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
const applyTheme = (theme: ThemeName) => {
document.documentElement.setAttribute('data-theme', theme);
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
};
import { biergartenThemes, defaultThemeName, type ThemeName } from '../themes';
import ThemeSegmentedControl from './ThemeSegmentedControl';
const ThemeSwitcher = () => {
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
@@ -39,35 +35,7 @@ const ThemeSwitcher = () => {
<h2 className="card-title text-2xl">Theme switcher</h2>
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
<div
className="join join-vertical sm:join-horizontal"
role="radiogroup"
aria-label="Theme selector"
>
{biergartenThemes.map((theme) => {
const checked = selectedTheme === theme.value;
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
>
<input
type="radio"
name="theme"
value={theme.value}
className="sr-only"
checked={checked}
onChange={() => {
setSelectedTheme(theme.value);
applyTheme(theme.value);
}}
/>
{theme.label}
</label>
);
})}
</div>
<ThemeSegmentedControl onChange={setSelectedTheme} />
</div>
</section>
</>

View File

@@ -4,6 +4,8 @@ export type ThemeName =
export interface ThemeOption {
value: ThemeName;
label: string;
/** Used where the segmented control is narrow, such as the landing page. */
shortLabel: string;
vibe: string;
}
@@ -14,21 +16,25 @@ export const biergartenThemes: ThemeOption[] = [
{
value: 'biergarten-lager',
label: 'Biergarten Lager',
shortLabel: 'Lager',
vibe: 'Muted parchment, mellow amber, daytime beer garden',
},
{
value: 'biergarten-stout',
label: 'Biergarten Stout',
shortLabel: 'Stout',
vibe: 'Charred barrel, deep roast, cozy evening cellar',
},
{
value: 'biergarten-cassis',
label: 'Biergarten Cassis',
shortLabel: 'Cassis',
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
},
{
value: 'biergarten-weizen',
label: 'Biergarten Weizen',
shortLabel: 'Weizen',
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
},
];