diff --git a/web/frontend/app/features/home/HOME_HANDOFF.md b/web/frontend/app/features/home/HOME_HANDOFF.md new file mode 100644 index 00000000..7bbcbab2 --- /dev/null +++ b/web/frontend/app/features/home/HOME_HANDOFF.md @@ -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 `` 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`. diff --git a/web/frontend/app/features/home/assets/bar-background.jpg b/web/frontend/app/features/home/assets/bar-background.jpg new file mode 100644 index 00000000..27841d82 Binary files /dev/null and b/web/frontend/app/features/home/assets/bar-background.jpg differ diff --git a/web/frontend/app/features/home/components/ClosingCta.stories.tsx b/web/frontend/app/features/home/components/ClosingCta.stories.tsx new file mode 100644 index 00000000..4013f94a --- /dev/null +++ b/web/frontend/app/features/home/components/ClosingCta.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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', + ); + }, +}; diff --git a/web/frontend/app/features/home/components/ClosingCta.tsx b/web/frontend/app/features/home/components/ClosingCta.tsx new file mode 100644 index 00000000..3f7c40cf --- /dev/null +++ b/web/frontend/app/features/home/components/ClosingCta.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router'; + +const ClosingCta = () => ( +
+

+ 🍺 Your next favourite brewery is on the list. +

+ +

+ Create an account to save breweries and log the beers you try. +

+ +
+ + Create an account + + + Sign in + +
+
+); + +export default ClosingCta; diff --git a/web/frontend/app/features/home/components/FeatureHighlights.stories.tsx b/web/frontend/app/features/home/components/FeatureHighlights.stories.tsx new file mode 100644 index 00000000..2511ade7 --- /dev/null +++ b/web/frontend/app/features/home/components/FeatureHighlights.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + } + }, +}; diff --git a/web/frontend/app/features/home/components/FeatureHighlights.tsx b/web/frontend/app/features/home/components/FeatureHighlights.tsx new file mode 100644 index 00000000..0edc57b0 --- /dev/null +++ b/web/frontend/app/features/home/components/FeatureHighlights.tsx @@ -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 = () => ( +
+
+

+ Everything you need to plan the next round +

+ +
+ {features.map(({ icon: Icon, title, body }) => ( +
+
+ +
+

{title}

+

{body}

+
+ ))} +
+
+
+); + +export default FeatureHighlights; diff --git a/web/frontend/app/features/home/components/HeroSection.stories.tsx b/web/frontend/app/features/home/components/HeroSection.stories.tsx new file mode 100644 index 00000000..08ce5a88 --- /dev/null +++ b/web/frontend/app/features/home/components/HeroSection.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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', + ); + }, +}; diff --git a/web/frontend/app/features/home/components/HeroSection.tsx b/web/frontend/app/features/home/components/HeroSection.tsx new file mode 100644 index 00000000..b01afb84 --- /dev/null +++ b/web/frontend/app/features/home/components/HeroSection.tsx @@ -0,0 +1,44 @@ +import { Link } from 'react-router'; +import barBackground from '../assets/bar-background.jpg'; + +const HeroSection = () => ( +
+
+
+

+ Brewery discovery +

+ +

+ Find the breweries worth the trip. +

+ +

+ Browse our partner breweries, see what they pour, and keep track of the beers + you have tried — all in one account. +

+ +
+ + Create an account + + + Browse breweries + +
+ +

+ Free to join. Must be 19 years or older. +

+
+ + A glass of lager on a bar counter +
+
+); + +export default HeroSection; diff --git a/web/frontend/app/features/home/components/LandingFooter.stories.tsx b/web/frontend/app/features/home/components/LandingFooter.stories.tsx new file mode 100644 index 00000000..c2af68f8 --- /dev/null +++ b/web/frontend/app/features/home/components/LandingFooter.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/web/frontend/app/features/home/components/LandingFooter.tsx b/web/frontend/app/features/home/components/LandingFooter.tsx new file mode 100644 index 00000000..9bf71b6b --- /dev/null +++ b/web/frontend/app/features/home/components/LandingFooter.tsx @@ -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) => ( +
+
+ + + + +

Please drink responsibly.

+
+
+); + +export default LandingFooter; diff --git a/web/frontend/app/features/home/components/PartnerBreweries.stories.tsx b/web/frontend/app/features/home/components/PartnerBreweries.stories.tsx new file mode 100644 index 00000000..b41a58d7 --- /dev/null +++ b/web/frontend/app/features/home/components/PartnerBreweries.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/web/frontend/app/features/home/components/PartnerBreweries.tsx b/web/frontend/app/features/home/components/PartnerBreweries.tsx new file mode 100644 index 00000000..10ddf9cb --- /dev/null +++ b/web/frontend/app/features/home/components/PartnerBreweries.tsx @@ -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) => ( +
+
+
+

+ Discover our partner breweries +

+

+ A few recent additions to the directory. +

+
+ + + View all breweries → + +
+ + {breweries.length === 0 ? ( +

No breweries have been posted yet.

+ ) : ( +
+ {breweries.map((brewery, index) => ( +
+
+
+

+ {brewery.breweryName} +

+ + {FILLER_BEER_COUNTS[index % FILLER_BEER_COUNTS.length]} beers + +
+ + {brewery.location && ( +

+ {brewery.location.cityName},{' '} + {brewery.location.stateProvinceCode} +

+ )} + +

{brewery.description}

+ +
+ + View brewery + +
+
+
+ ))} +
+ )} +
+); + +export default PartnerBreweries; diff --git a/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx b/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx new file mode 100644 index 00000000..d8275d3a --- /dev/null +++ b/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx @@ -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: , + }, + ]), + ); + + return ; +}; + +const meta = { + title: 'Home/ThemeTryOut', + component: ThemeTryOutHarness, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + usesDataRouter: true, + docs: { + description: { + component: themeTryOutDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + } + }, +}; diff --git a/web/frontend/app/features/home/components/ThemeTryOut.tsx b/web/frontend/app/features/home/components/ThemeTryOut.tsx new file mode 100644 index 00000000..c93afd88 --- /dev/null +++ b/web/frontend/app/features/home/components/ThemeTryOut.tsx @@ -0,0 +1,26 @@ +import ThemeSegmentedControl from '../../theme/components/ThemeSegmentedControl'; + +const ThemeTryOut = () => ( +
+
+
+

+ Pick a pour that suits you +

+

+ 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. +

+
+ +
+ +
+
+
+); + +export default ThemeTryOut; diff --git a/web/frontend/app/features/home/routes/home.tsx b/web/frontend/app/features/home/routes/home.tsx index f793c9a3..fb112452 100644 --- a/web/frontend/app/features/home/routes/home.tsx +++ b/web/frontend/app/features/home/routes/home.tsx @@ -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 ( -
-
-
-

🍺 The Biergarten App

-

Authentication Demo

- - {username ? ( - <> -

- Welcome back,{' '} - {username}! -

-
- - Dashboard - - - Logout - -
- - ) : ( -
- - Login - - - Register - -
- )} -
-
-
+
+ + + + + + +
); }; diff --git a/web/frontend/app/features/home/utils/filler-beer-counts.ts b/web/frontend/app/features/home/utils/filler-beer-counts.ts new file mode 100644 index 00000000..dfef193c --- /dev/null +++ b/web/frontend/app/features/home/utils/filler-beer-counts.ts @@ -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]; diff --git a/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx b/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx new file mode 100644 index 00000000..734ec84a --- /dev/null +++ b/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx @@ -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 +// 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('root')?.theme ?? defaultThemeName; + const [selectedTheme, setSelectedTheme] = useState(rootTheme); + + return ( +
+ {biergartenThemes.map((theme) => { + const checked = selectedTheme === theme.value; + + return ( + + ); + })} +
+ ); +}; + +export default ThemeSegmentedControl; diff --git a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx index 58f3ee92..2c5d2af6 100644 --- a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx +++ b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx @@ -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('root')?.theme ?? defaultThemeName; @@ -39,35 +35,7 @@ const ThemeSwitcher = () => {

Theme switcher

Pick a theme and preview it immediately.

-
- {biergartenThemes.map((theme) => { - const checked = selectedTheme === theme.value; - - return ( - - ); - })} -
+ diff --git a/web/frontend/app/features/theme/themes.ts b/web/frontend/app/features/theme/themes.ts index 70a3b54a..e94d350f 100644 --- a/web/frontend/app/features/theme/themes.ts +++ b/web/frontend/app/features/theme/themes.ts @@ -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', }, ];