Add brewery discovery & detail pages with location-based search (#253)

This commit is contained in:
Aaron Po
2026-08-29 00:44:33 -04:00
committed by GitHub
parent 92a30b11db
commit a580314583
90 changed files with 3874 additions and 187 deletions

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import SidebarCard from './SidebarCard';
const sidebarCardDescription = `Shared card shell used by the brewery detail page's sidebar sections (Location, Details, Beer styles, Community) — an uppercase eyebrow label over arbitrary content.`;
const meta = {
title: 'Breweries/SidebarCard',
component: SidebarCard,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component: sidebarCardDescription,
},
},
},
} satisfies Meta<typeof SidebarCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: 'Details',
children: <p className="text-sm m-0">Founded 2014</p>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Details')).toBeInTheDocument();
await expect(canvas.getByText('Founded 2014')).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
interface SidebarCardProps {
title: string;
children: ReactNode;
}
const SidebarCard = ({ title, children }: SidebarCardProps) => (
<div className="card bg-base-100 shadow">
<div className="card-body gap-3 p-6">
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
{title}
</p>
{children}
</div>
</div>
);
export default SidebarCard;

View File

@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import StarRating from './StarRating';
const starRatingDescription = `Dual-purpose star control built on daisyUI's \`rating\` component. Read-only (no \`onChange\`) when used for an average or a display rating; interactive otherwise — clicking the currently-selected star clears the rating, since daisyUI radios can't do that natively.`;
const meta = {
title: 'Breweries/StarRating',
component: StarRating,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: starRatingDescription,
},
},
},
} satisfies Meta<typeof StarRating>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ReadOnly: Story = {
args: { value: 4, size: 'sm' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole('radio')).not.toBeInTheDocument();
await expect(canvas.getByLabelText('4 out of 5 stars')).toBeInTheDocument();
},
};
export const Interactive: Story = {
args: { value: 0, size: 'lg', onChange: fn() },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
await expect(args.onChange).toHaveBeenCalledWith(3);
},
};
export const InteractiveClearsOnRepeatClick: Story = {
args: { value: 3, size: 'lg', onChange: fn() },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
await expect(args.onChange).toHaveBeenCalledWith(0);
},
};

View File

@@ -0,0 +1,71 @@
import { useId } from 'react';
interface StarRatingProps {
value: number;
max?: number;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
onChange?: (value: number) => void;
label?: string;
}
const SIZE_CLASS: Record<NonNullable<StarRatingProps['size']>, string> = {
xs: 'rating-xs',
sm: 'rating-sm',
md: 'rating-md',
lg: 'rating-lg',
xl: 'rating-xl',
};
/** Read-only when `onChange` is omitted, interactive otherwise. Clicking the
* currently-selected star clears the rating (daisyUI radios can't do this natively). */
const StarRating = ({ value, max = 5, size = 'sm', onChange, label }: StarRatingProps) => {
const name = useId();
const stars = Array.from({ length: max }, (_, i) => i + 1);
if (!onChange) {
return (
<div
className={`rating ${SIZE_CLASS[size]}`}
aria-label={label ?? `${value} out of ${max} stars`}
>
{stars.map((n) => (
<div
key={n}
className="mask mask-star bg-warning"
aria-label={`${n} star`}
aria-current={n === value ? 'true' : undefined}
/>
))}
</div>
);
}
return (
<div className={`rating ${SIZE_CLASS[size]}`}>
<input
type="radio"
name={name}
className="rating-hidden"
checked={value === 0}
onChange={() => onChange(0)}
aria-label="Clear rating"
/>
{stars.map((n) => (
<input
key={n}
type="radio"
name={name}
className="mask mask-star bg-warning"
checked={value === n}
onChange={() => onChange(n)}
onClick={() => {
if (value === n) onChange(0);
}}
aria-label={`Rate ${n} of ${max}`}
/>
))}
</div>
);
};
export default StarRating;