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,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;