mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add authenticated user profile page with locked-down account endpoint (#315)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body items-center py-12">
|
||||
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EmptyState;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import ProfileSidebarCard from './ProfileSidebarCard';
|
||||
|
||||
const profileSidebarCardDescription = `Identity card in the public profile page's sidebar: name/handle, the Follow/Edit profile action, bio, location and join date, and the follower/following stat list.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ProfileSidebarCard',
|
||||
component: ProfileSidebarCard,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
displayName: 'Maren Iverson',
|
||||
username: 'mareniverson',
|
||||
bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺',
|
||||
location: 'Portland, OR',
|
||||
joinedLabel: 'Joined March 2023',
|
||||
stats: [
|
||||
{ label: 'Following', value: 68 },
|
||||
{ label: 'Followers', value: 214 },
|
||||
{ label: 'Breweries visited', value: 37 },
|
||||
{ label: 'Ratings given', value: 112 },
|
||||
],
|
||||
onToggleFollow: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: profileSidebarCardDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileSidebarCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Visitor: Story = {
|
||||
args: { isOwnProfile: false, isFollowing: false },
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const followButton = canvas.getByRole('button', { name: /^follow$/i });
|
||||
await userEvent.click(followButton);
|
||||
await expect(args.onToggleFollow).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const FollowingVisitor: Story = {
|
||||
args: { isOwnProfile: false, isFollowing: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('button', { name: /^following$/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const OwnProfile: Story = {
|
||||
args: { isOwnProfile: true, isFollowing: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('link', { name: /edit profile/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Calendar, MapPin } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ProfileStat {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ProfileSidebarCardProps {
|
||||
displayName: string;
|
||||
username: string;
|
||||
bio: string;
|
||||
location: string;
|
||||
joinedLabel: string;
|
||||
stats: ProfileStat[];
|
||||
isOwnProfile: boolean;
|
||||
isFollowing: boolean;
|
||||
onToggleFollow: () => void;
|
||||
}
|
||||
|
||||
const ProfileSidebarCard = ({
|
||||
displayName,
|
||||
username,
|
||||
bio,
|
||||
location,
|
||||
joinedLabel,
|
||||
stats,
|
||||
isOwnProfile,
|
||||
isFollowing,
|
||||
onToggleFollow,
|
||||
}: ProfileSidebarCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body gap-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
||||
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
|
||||
</div>
|
||||
|
||||
{isOwnProfile ? (
|
||||
<Link to="/account" className="btn btn-outline btn-block">
|
||||
Edit profile
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFollow}
|
||||
className={`btn btn-block ${isFollowing ? 'btn-outline' : 'btn-primary'}`}
|
||||
>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-sm leading-normal m-0">{bio}</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||
{location}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="size-4 shrink-0" aria-hidden="true" />
|
||||
{joinedLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider m-0" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-base-content/60">{stat.label}</span>
|
||||
<span className="font-serif text-lg font-bold tabular-nums">
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ProfileSidebarCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import ProfileTabs, { type ProfileTab } from './ProfileTabs';
|
||||
|
||||
const profileTabsDescription = `Activity / Following / Liked tab bar for the public profile page. Switching tabs is an instant swap with no transition, consistent with the design system's stance on tab bars.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ProfileTabs',
|
||||
component: ProfileTabs,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: profileTabsDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileTabs>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const panels = {
|
||||
activity: <p>Activity panel</p>,
|
||||
following: <p>Following panel</p>,
|
||||
liked: <p>Liked panel</p>,
|
||||
};
|
||||
|
||||
const ControlledTabs = ({ initialTab }: { initialTab: ProfileTab }) => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>(initialTab);
|
||||
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { activeTab: 'activity', onChange: fn(), panels },
|
||||
render: (args) => <ControlledTabs initialTab={args.activeTab} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const followingTab = canvas.getByRole('tab', { name: /following/i });
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'false');
|
||||
await userEvent.click(followingTab);
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(canvas.getByText('Following panel')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ProfileTab = 'activity' | 'following' | 'liked';
|
||||
|
||||
const TABS: { id: ProfileTab; label: string }[] = [
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
{ id: 'following', label: 'Following' },
|
||||
{ id: 'liked', label: 'Liked' },
|
||||
];
|
||||
|
||||
interface ProfileTabsProps {
|
||||
activeTab: ProfileTab;
|
||||
onChange: (tab: ProfileTab) => void;
|
||||
panels: Record<ProfileTab, ReactNode>;
|
||||
}
|
||||
|
||||
const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
||||
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
|
||||
|
||||
return (
|
||||
<TabGroup selectedIndex={selectedIndex} onChange={(index) => onChange(TABS[index].id)}>
|
||||
<TabList className="flex gap-6 border-b border-base-300">
|
||||
{TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-5">
|
||||
{TABS.map((tab) => (
|
||||
<TabPanel key={tab.id}>{panels[tab.id]}</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTabs;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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">
|
||||
“{item.comment}”
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTab;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link } from 'react-router';
|
||||
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">
|
||||
{f.userAccountId ? (
|
||||
<Link
|
||||
to={`/users/${f.userAccountId}`}
|
||||
className="font-semibold text-sm truncate link link-hover block"
|
||||
>
|
||||
{f.name}
|
||||
</Link>
|
||||
) : (
|
||||
<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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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-1 sm: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;
|
||||
Reference in New Issue
Block a user