Add public user profile page at /users/:id

This commit is contained in:
Aaron Po
2026-09-12 15:08:47 -04:00
parent 180ed41e68
commit 6f1dfce625
16 changed files with 816 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import ActivityTab from './ActivityTab';
import { FILLER_ACTIVITY } from '../../../utils/filler-user-profile';
const activityTabDescription = `A profile's Activity tab: ratings, likes, and comments as a vertical feed, newest first. Renders the empty-state card when the collection is empty.`;
const meta = {
title: 'Users/ActivityTab',
component: ActivityTab,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component: activityTabDescription,
},
},
},
} satisfies Meta<typeof ActivityTab>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Populated: Story = {
args: { activity: FILLER_ACTIVITY },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/interurban ipa/i)).toBeInTheDocument();
await expect(canvas.getByText(/best fruited stout/i)).toBeInTheDocument();
},
};
export const Empty: Story = {
args: { activity: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText('No activity yet. Ratings, likes, and comments will show up here.'),
).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,55 @@
import { ChatBubble, Heart, Star } from 'iconoir-react';
import EmptyState from '../EmptyState';
import StarRating from '../../../../breweries/components/shared/StarRating';
import type { FillerActivity } from '../../../utils/filler-user-profile';
const ICONS = { star: Star, heart: Heart, comment: ChatBubble } as const;
interface ActivityTabProps {
activity: FillerActivity[];
}
const ActivityTab = ({ activity }: ActivityTabProps) => {
if (activity.length === 0) {
return (
<EmptyState message="No activity yet. Ratings, likes, and comments will show up here." />
);
}
return (
<div className="flex flex-col gap-3">
{activity.map((item) => {
const Icon = ICONS[item.icon];
return (
<div key={item.id} className="card bg-base-100 shadow">
<div className="card-body flex-row gap-4 items-start p-4">
<div className="w-[34px] h-[34px] rounded-full bg-accent text-accent-content flex items-center justify-center shrink-0">
<Icon className="size-4" aria-hidden="true" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm leading-normal m-0">
{item.verb} <span className="font-semibold">{item.target}</span>
</p>
{item.rating !== undefined && (
<div className="mt-1.5">
<StarRating value={item.rating} size="xs" />
</div>
)}
{item.comment && (
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
&ldquo;{item.comment}&rdquo;
</p>
)}
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
{item.time}
</p>
</div>
</div>
</div>
);
})}
</div>
);
};
export default ActivityTab;

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import FollowingTab from './FollowingTab';
import { FILLER_FOLLOWING } from '../../../utils/filler-user-profile';
const followingTabDescription = `A profile's Following tab: the people and breweries this user follows, each with an unfollow toggle.`;
const meta = {
title: 'Users/FollowingTab',
component: FollowingTab,
tags: ['autodocs'],
args: {
onToggleFollow: fn(),
},
parameters: {
layout: 'padded',
docs: {
description: {
component: followingTabDescription,
},
},
},
} satisfies Meta<typeof FollowingTab>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Populated: Story = {
args: {
following: FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getAllByRole('button', { name: /^following$/i })[0];
await userEvent.click(button);
await expect(args.onToggleFollow).toHaveBeenCalledWith(FILLER_FOLLOWING[0].id);
},
};
export const Empty: Story = {
args: { following: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Not following anyone yet.')).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,51 @@
import EmptyState from '../EmptyState';
import type { FillerFollowing } from '../../../utils/filler-user-profile';
const initials = (name: string) =>
name
.split(' ')
.map((part) => part[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
interface FollowingTabProps {
following: (FillerFollowing & { isFollowing: boolean })[];
onToggleFollow: (id: string) => void;
}
const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
if (following.length === 0) {
return <EmptyState message="Not following anyone yet." />;
}
return (
<div className="flex flex-col gap-3">
{following.map((f) => (
<div key={f.id} className="card bg-base-100 shadow">
<div className="card-body flex-row items-center gap-4 p-4">
<div className="avatar avatar-placeholder shrink-0">
<div className="bg-secondary text-secondary-content w-11 rounded-full">
<span className="text-sm font-bold">{initials(f.name)}</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
</div>
<button
type="button"
onClick={() => onToggleFollow(f.id)}
className={`btn btn-sm shrink-0 ${f.isFollowing ? 'btn-outline' : 'btn-primary'}`}
>
{f.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>
))}
</div>
);
};
export default FollowingTab;

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import LikedTab from './LikedTab';
import { FILLER_LIKED } from '../../../utils/filler-user-profile';
const likedTabDescription = `A profile's Liked tab: a 2-column grid of beers this user has liked, each with a rating.`;
const meta = {
title: 'Users/LikedTab',
component: LikedTab,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component: likedTabDescription,
},
},
},
} satisfies Meta<typeof LikedTab>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Populated: Story = {
args: { liked: FILLER_LIKED },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Interurban IPA')).toBeInTheDocument();
await expect(canvas.getByText('Fremont Brewing')).toBeInTheDocument();
},
};
export const Empty: Story = {
args: { liked: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('No likes yet.')).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,32 @@
import EmptyState from '../EmptyState';
import StarRating from '../../../../breweries/components/shared/StarRating';
import type { FillerLiked } from '../../../utils/filler-user-profile';
interface LikedTabProps {
liked: FillerLiked[];
}
const LikedTab = ({ liked }: LikedTabProps) => {
if (liked.length === 0) {
return <EmptyState message="No likes yet." />;
}
return (
<div className="grid grid-cols-2 gap-4">
{liked.map((item) => (
<div key={item.id} className="card bg-base-100 shadow overflow-hidden">
<div className="h-[120px] bg-base-300" />
<div className="card-body p-4 gap-1">
<p className="font-serif font-bold m-0">{item.name}</p>
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
<div className="mt-1">
<StarRating value={item.rating} size="xs" />
</div>
</div>
</div>
))}
</div>
);
};
export default LikedTab;