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,56 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import CommentItem from './CommentItem';
import type { FillerComment } from '../../../utils/filler-brewery-detail';
const commentItemDescription = `A single comment row on the brewery detail page: avatar initials, username/star/timestamp, body, and a per-comment like toggle.`;
const comment: FillerComment = {
id: '1',
user: 'malt_kettle',
initials: 'MK',
rating: 5,
time: '2 days ago',
text: 'The barrel-aged flanders red is worth the trip alone.',
likes: 12,
liked: false,
};
const meta = {
title: 'Breweries/CommentItem',
component: CommentItem,
tags: ['autodocs'],
args: {
onToggleLike: fn(),
},
parameters: {
layout: 'padded',
docs: {
description: {
component: commentItemDescription,
},
},
},
} satisfies Meta<typeof CommentItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unliked: Story = {
args: { comment },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('malt_kettle')).toBeInTheDocument();
const likeButton = canvas.getByRole('button', { name: /12 likes/i });
await userEvent.click(likeButton);
await expect(args.onToggleLike).toHaveBeenCalledWith('1');
},
};
export const Liked: Story = {
args: { comment: { ...comment, liked: true } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,46 @@
import { Heart, HeartSolid } from 'iconoir-react';
import StarRating from '../../shared/StarRating';
import type { FillerComment } from '../../../utils/filler-brewery-detail';
interface CommentItemProps {
comment: FillerComment;
onToggleLike: (id: string) => void;
}
const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
const likeCount = comment.likes + (comment.liked ? 1 : 0);
return (
<div className="flex gap-3 items-start border-t border-base-content/10 pt-4">
<div className="avatar avatar-placeholder shrink-0">
<div className="bg-secondary text-secondary-content w-9 rounded-full">
<span className="text-sm font-bold">{comment.initials}</span>
</div>
</div>
<div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="font-bold text-sm">{comment.user}</span>
<StarRating value={comment.rating} size="xs" />
<span className="text-xs text-base-content/60">{comment.time}</span>
</div>
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
<button
type="button"
onClick={() => onToggleLike(comment.id)}
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
comment.liked ? 'text-primary' : 'text-base-content/60'
}`}
>
{comment.liked ? (
<HeartSolid className="size-3.5" aria-hidden="true" />
) : (
<Heart className="size-3.5" aria-hidden="true" />
)}
{likeCount} {likeCount === 1 ? 'like' : 'likes'}
</button>
</div>
</div>
);
};
export default CommentItem;

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import CommentsCard from './CommentsCard';
import { FILLER_COMMENTS } from '../../../utils/filler-brewery-detail';
const commentsCardDescription = `Comments section on the brewery detail page: a post form (logged in) or a sign-in alert (logged out), followed by the comment list. Posting is disabled until the textarea has non-whitespace content.`;
const meta = {
title: 'Breweries/CommentsCard',
component: CommentsCard,
tags: ['autodocs'],
args: {
comments: FILLER_COMMENTS,
currentUserInitials: 'JD',
onAddComment: fn(),
onToggleCommentLike: fn(),
},
parameters: {
layout: 'padded',
docs: {
description: {
component: commentsCardDescription,
},
},
},
} satisfies Meta<typeof CommentsCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LoggedIn: Story = {
args: { loggedIn: true },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const postButton = canvas.getByRole('button', { name: /post comment/i });
await expect(postButton).toBeDisabled();
await userEvent.type(
canvas.getByPlaceholderText('Share your thoughts on this brewery'),
'Great taproom!',
);
await expect(postButton).toBeEnabled();
await userEvent.click(postButton);
await expect(args.onAddComment).toHaveBeenCalledWith('Great taproom!');
},
};
export const LoggedOut: Story = {
args: { loggedIn: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText('Sign in to like, rate, and comment on breweries.'),
).toBeInTheDocument();
await expect(
canvas.queryByPlaceholderText('Share your thoughts on this brewery'),
).not.toBeInTheDocument();
},
};

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import CommentItem from './CommentItem';
import type { FillerComment } from '../../../utils/filler-brewery-detail';
interface CommentsCardProps {
comments: FillerComment[];
loggedIn: boolean;
currentUserInitials: string;
onAddComment: (text: string) => void;
onToggleCommentLike: (id: string) => void;
}
const CommentsCard = ({
comments,
loggedIn,
currentUserInitials,
onAddComment,
onToggleCommentLike,
}: CommentsCardProps) => {
const [commentText, setCommentText] = useState('');
const handleSubmit = () => {
const text = commentText.trim();
if (!text) return;
onAddComment(text);
setCommentText('');
};
return (
<div className="card bg-base-100 shadow">
<div className="card-body gap-5 p-6">
<h3 className="text-xl m-0">Comments ({comments.length})</h3>
{loggedIn ? (
<div className="flex gap-3 items-start">
<div className="avatar avatar-placeholder shrink-0">
<div className="bg-secondary text-secondary-content w-9 rounded-full">
<span className="text-sm font-bold">{currentUserInitials}</span>
</div>
</div>
<div className="flex-1 flex flex-col gap-2">
<textarea
rows={3}
placeholder="Share your thoughts on this brewery"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="textarea w-full"
/>
<button
type="button"
onClick={handleSubmit}
disabled={!commentText.trim()}
className="btn btn-sm btn-primary self-end"
>
Post comment
</button>
</div>
</div>
) : (
<div role="alert" className="alert alert-info alert-soft">
Sign in to like, rate, and comment on breweries.
</div>
)}
<div className="flex flex-col gap-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
onToggleLike={onToggleCommentLike}
/>
))}
</div>
</div>
</div>
);
};
export default CommentsCard;