mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Squash merge main into feature branch
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import AiDisclosureDialog from './AiDisclosureDialog';
|
||||
|
||||
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Disclosure/AiDisclosureDialog',
|
||||
component: AiDisclosureDialog,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: aiDisclosureDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AiDisclosureDialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Open: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await waitFor(async () => {
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
await expect(
|
||||
canvas.getByRole('link', { name: /how this data was generated/i }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Acknowledged: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
|
||||
await expect(
|
||||
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const InitiallyDismissed: Story = {
|
||||
args: {
|
||||
defaultOpen: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeNull();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Sparks } from 'iconoir-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const ethicsDocUrl =
|
||||
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
|
||||
|
||||
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal modal-open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ai-disclosure-title"
|
||||
>
|
||||
<div className="modal-box max-w-md">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="text-primary">
|
||||
<Sparks className="size-10" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 id="ai-disclosure-title" className="text-lg font-bold">
|
||||
This content is AI-generated
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
The Biergarten App is a proof of concept. Every brewery, beer, and user in
|
||||
this catalogue is fixture data produced by a language model — none of it
|
||||
describes a real business, person, or beer.
|
||||
</p>
|
||||
<div role="alert" className="alert alert-warning alert-soft text-left">
|
||||
<span className="text-sm">
|
||||
Brewing details, local-language text, and coordinates are known to
|
||||
contain fabrications. Treat nothing here as factual.
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={ethicsDocUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link link-primary text-sm"
|
||||
>
|
||||
How this data was generated
|
||||
</a>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
autoFocus
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
I understand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiDisclosureDialog;
|
||||
@@ -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,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
|
||||
import ThemeTryOut from './ThemeTryOut';
|
||||
|
||||
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
|
||||
|
||||
const ThemeTryOutHarness = () => {
|
||||
const [router] = useState(() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
id: 'root',
|
||||
path: '/',
|
||||
loader: () => ({ theme: defaultThemeName }),
|
||||
element: <ThemeTryOut />,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Home/ThemeTryOut',
|
||||
component: ThemeTryOutHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: themeTryOutDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ThemeTryOutHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(canvas.getByText('Set the mood')).toBeVisible();
|
||||
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
|
||||
|
||||
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
|
||||
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
32
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal file
32
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import ThemeSegmentedControl from '../../theme/components/ThemeSegmentedControl';
|
||||
|
||||
const ThemeTryOut = () => (
|
||||
<section className="bg-neutral text-neutral-content">
|
||||
<div className="mx-auto grid max-w-7xl items-center gap-8 px-5 py-16 grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="m-0 font-serif text-3xl leading-tight">
|
||||
Pick a pour that suits you
|
||||
</h2>
|
||||
<p className="m-0 max-w-[32rem] leading-[1.55] opacity-80">
|
||||
Four themes ship with the app — a light lager, a dark roast stout, a berry
|
||||
cassis and a bright weizen. Try one; this page switches with it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
|
||||
Set the mood
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
|
||||
</div>
|
||||
<span className="badge badge-outline shrink-0">4 pours</span>
|
||||
</div>
|
||||
<ThemeSegmentedControl labels="short" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default ThemeTryOut;
|
||||
Reference in New Issue
Block a user