mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Create landing page (#321)
This commit is contained in:
55
web/frontend/app/features/home/HOME_HANDOFF.md
Normal file
55
web/frontend/app/features/home/HOME_HANDOFF.md
Normal 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`.
|
||||
BIN
web/frontend/app/features/home/assets/bar-background.jpg
Normal file
BIN
web/frontend/app/features/home/assets/bar-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -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',
|
||||
);
|
||||
},
|
||||
};
|
||||
24
web/frontend/app/features/home/components/ClosingCta.tsx
Normal file
24
web/frontend/app/features/home/components/ClosingCta.tsx
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
};
|
||||
44
web/frontend/app/features/home/components/HeroSection.tsx
Normal file
44
web/frontend/app/features/home/components/HeroSection.tsx
Normal 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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
42
web/frontend/app/features/home/components/LandingFooter.tsx
Normal file
42
web/frontend/app/features/home/components/LandingFooter.tsx
Normal 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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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 →
|
||||
</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;
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
26
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal file
26
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user