mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
5 Commits
feat/auth-
...
81717eddbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81717eddbf | ||
|
|
5449b284cb | ||
|
|
66ba43888b | ||
|
|
84232b5a47 | ||
|
|
6f1dfce625 |
@@ -379,6 +379,7 @@ web/frontend/
|
||||
│ │ - catalog beers, beer styles
|
||||
│ │ - home landing page
|
||||
│ │ - theme theme switcher/guide
|
||||
│ │ - users public user profile page (/users/:id)
|
||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
||||
│ ├── root.tsx App shell and global providers
|
||||
@@ -395,6 +396,8 @@ web/frontend/
|
||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||
search
|
||||
- Render the auth demo, account dashboard, and theme guide routes
|
||||
- Render the public user profile route (`/users/:id`), including a user's
|
||||
following list and (via brewery comments) links back to commenter profiles
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||
brewery/location data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||
@@ -47,8 +47,10 @@ export const Authenticated: Story = {
|
||||
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||
await expect(userButton).toBeInTheDocument();
|
||||
await userEvent.click(userButton);
|
||||
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
// MenuItems is anchor-positioned, which portals it outside canvasElement.
|
||||
await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => (
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
<UserMenu
|
||||
username={auth.username}
|
||||
userAccountId={auth.userAccountId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
|
||||
@@ -3,25 +3,40 @@ import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
|
||||
<Menu>
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItems
|
||||
anchor="bottom end"
|
||||
transition
|
||||
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
<Link
|
||||
to={`/users/${userAccountId}`}
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
My profile
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to="/logout"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
|
||||
@@ -8,6 +8,7 @@ const commentItemDescription = `A single comment row on the brewery detail page:
|
||||
const comment: FillerComment = {
|
||||
id: '1',
|
||||
user: 'malt_kettle',
|
||||
userAccountId: 'malt_kettle',
|
||||
initials: 'MK',
|
||||
rating: 5,
|
||||
time: '2 days ago',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Heart, HeartSolid } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
import StarRating from '../../shared/StarRating';
|
||||
import type { FillerComment } from '../../../utils/filler-brewery-detail';
|
||||
|
||||
@@ -19,7 +20,9 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
||||
</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>
|
||||
<Link to={`/users/${comment.userAccountId}`} className="font-bold text-sm link link-hover">
|
||||
{comment.user}
|
||||
</Link>
|
||||
<StarRating value={comment.rating} size="xs" />
|
||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||
</div>
|
||||
|
||||
@@ -28,13 +28,14 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
brewery,
|
||||
loggedIn: auth !== null,
|
||||
username: auth?.username ?? null,
|
||||
userAccountId: auth?.userAccountId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const initials = (username: string) => username.slice(0, 2).toUpperCase();
|
||||
|
||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { brewery, loggedIn, username } = loaderData;
|
||||
const { brewery, loggedIn, username, userAccountId } = loaderData;
|
||||
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
|
||||
@@ -57,6 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
user: username ?? 'you',
|
||||
userAccountId: userAccountId ?? '',
|
||||
initials: username ? initials(username) : 'YO',
|
||||
rating: yourRating,
|
||||
time: 'just now',
|
||||
|
||||
@@ -88,6 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
|
||||
export interface FillerComment {
|
||||
id: string;
|
||||
user: string;
|
||||
userAccountId: string;
|
||||
initials: string;
|
||||
rating: number;
|
||||
time: string;
|
||||
@@ -100,6 +101,7 @@ export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '1',
|
||||
user: 'malt_kettle',
|
||||
userAccountId: 'malt_kettle',
|
||||
initials: 'MK',
|
||||
rating: 5,
|
||||
time: '2 days ago',
|
||||
@@ -110,6 +112,7 @@ export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '2',
|
||||
user: 'stoutfan_88',
|
||||
userAccountId: 'stoutfan_88',
|
||||
initials: 'SB',
|
||||
rating: 4,
|
||||
time: '1 week ago',
|
||||
@@ -120,6 +123,7 @@ export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '3',
|
||||
user: 'ale_annie',
|
||||
userAccountId: 'ale_annie',
|
||||
initials: 'AL',
|
||||
rating: 4,
|
||||
time: '3 weeks ago',
|
||||
|
||||
48
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal file
48
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Public user profile: data requirements
|
||||
|
||||
Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
||||
`../breweries/BREWERY_HANDOFF.md`.
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Available**: real data, wired up.
|
||||
- ⚠️ **Partial**: schema/repository exists but no public read endpoint.
|
||||
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
||||
|
||||
## Header & sidebar
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}` (`UserAccount`) | |
|
||||
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
||||
| Bio | ⚠️ | `FILLER_PROFILE_META.bio` | `Social.UserProfile.Biography` exists (`IUserProfileRepository`, `UpdateBiographyHandler`) but there's no query/controller route to read another user's profile — only a write path for the authenticated user's own. |
|
||||
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
||||
| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. |
|
||||
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
|
||||
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
|
||||
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
|
||||
|
||||
## Follow action
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ----------------------------- | ------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Follow / unfollow the profile | ❌ | Local component state | `Social.UserFollow` has no command/query/controller (create, delete, "am I following them" check) — mirrors the brewery-like gap. |
|
||||
| Edit profile (own profile) | ✅ | Links to `/account`, the existing profile-edit surface | |
|
||||
|
||||
## Activity / Following / Liked tabs
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| -------------------------------------- | ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Activity feed (ratings/likes/comments) | ❌ | `FILLER_ACTIVITY` | No unified activity/timeline concept. Needs a query that unions ratings, likes, and comments across brewery/beer posts for a given user, ordered by time. |
|
||||
| Following list (people + breweries) | ❌ | `FILLER_FOLLOWING` | Needs a query over `Social.UserFollow` joined to `Auth.UserAccount`, plus (once the like/rating gaps close) a way to follow breweries specifically. |
|
||||
| Liked list | ❌ | `FILLER_LIKED` | Blocked on the `BreweryPostLike`/`BeerPostLike` gap in `BREWERY_HANDOFF.md`. |
|
||||
| Unfollow from the Following tab | ❌ | Local component state (optimistic toggle) | Same `Social.UserFollow` gap as the header Follow action. |
|
||||
|
||||
## Summary of backend work
|
||||
|
||||
1. A public query + controller route to read a user's `Social.UserProfile.Biography` by `UserAccountId` (the write path already exists; only the public read is missing).
|
||||
2. A public query + controller route to read a user's stored `Social.UserAvatar`.
|
||||
3. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
|
||||
4. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
||||
5. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
|
||||
6. Once (4) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`).
|
||||
@@ -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,40 @@
|
||||
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 ControlledTabs = () => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
|
||||
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} />;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { activeTab: 'activity', onChange: fn() },
|
||||
render: () => <ControlledTabs />,
|
||||
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');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Tab, TabGroup, TabList } from '@headlessui/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;
|
||||
}
|
||||
|
||||
const ProfileTabs = ({ activeTab, onChange }: 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>
|
||||
</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-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;
|
||||
116
web/frontend/app/features/users/routes/user-profile.tsx
Normal file
116
web/frontend/app/features/users/routes/user-profile.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { data } from 'react-router';
|
||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import { getPublicUserProfile } from '../users.server';
|
||||
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
||||
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
||||
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
||||
import FollowingTab from '../components/profile/tabs/FollowingTab';
|
||||
import LikedTab from '../components/profile/tabs/LikedTab';
|
||||
import {
|
||||
FILLER_ACTIVITY,
|
||||
FILLER_FOLLOWING,
|
||||
FILLER_LIKED,
|
||||
FILLER_PROFILE_META,
|
||||
} from '../utils/filler-user-profile';
|
||||
import type { Route } from './+types/user-profile';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [{ title: 'Profile | The Biergarten App' }];
|
||||
|
||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const [profile, auth] = await Promise.all([
|
||||
getPublicUserProfile(params.id),
|
||||
getOptionalAuth(request),
|
||||
]);
|
||||
|
||||
if (!profile) {
|
||||
throw data('User not found.', { status: 404, statusText: 'Not Found' });
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
isOwnProfile: auth?.userAccountId === profile.userAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const joinedLabel = (isoDate: string) =>
|
||||
`Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`;
|
||||
|
||||
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { profile, isOwnProfile } = loaderData;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
|
||||
const [isFollowingProfile, setIsFollowingProfile] = useState(false);
|
||||
const [following, setFollowing] = useState(() =>
|
||||
FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })),
|
||||
);
|
||||
|
||||
const handleToggleFollowRow = (id: string) => {
|
||||
setFollowing((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, isFollowing: !f.isFollowing } : f)),
|
||||
);
|
||||
};
|
||||
|
||||
const displayName = `${profile.firstName} ${profile.lastName}`.trim() || profile.username;
|
||||
|
||||
const stats = [
|
||||
{ label: 'Following', value: FILLER_PROFILE_META.following },
|
||||
{ label: 'Followers', value: FILLER_PROFILE_META.followers },
|
||||
{ label: 'Breweries visited', value: FILLER_PROFILE_META.breweriesVisited },
|
||||
{ label: 'Ratings given', value: FILLER_PROFILE_META.ratingsGiven },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 pb-16">
|
||||
<div className="max-w-[1080px] mx-auto px-6 pt-8">
|
||||
<div className="rounded-box h-[190px] bg-base-300" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
|
||||
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
|
||||
<div className="avatar avatar-placeholder w-full h-full">
|
||||
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileSidebarCard
|
||||
displayName={displayName}
|
||||
username={profile.username}
|
||||
bio={FILLER_PROFILE_META.bio}
|
||||
location={FILLER_PROFILE_META.location}
|
||||
joinedLabel={joinedLabel(profile.createdAt)}
|
||||
stats={stats}
|
||||
isOwnProfile={isOwnProfile}
|
||||
isFollowing={isFollowingProfile}
|
||||
onToggleFollow={() => setIsFollowingProfile((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 mt-12">
|
||||
<ProfileTabs activeTab={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'activity' && <ActivityTab activity={FILLER_ACTIVITY} />}
|
||||
{activeTab === 'following' && (
|
||||
<FollowingTab
|
||||
following={following}
|
||||
onToggleFollow={handleToggleFollowRow}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'liked' && <LikedTab liked={FILLER_LIKED} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||
<RouteErrorState error={error} />
|
||||
);
|
||||
56
web/frontend/app/features/users/users.server.ts
Normal file
56
web/frontend/app/features/users/users.server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { data } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface PublicUserProfile {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the public-facing slice of a user account. `GET /api/user/{id}` also returns
|
||||
* `email` and `dateOfBirth`, but those stay out of anything rendered on a public profile.
|
||||
*/
|
||||
export const getPublicUserProfile = async (
|
||||
userAccountId: string,
|
||||
): Promise<PublicUserProfile | null> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}`);
|
||||
} catch {
|
||||
throw data('The user service is unreachable right now. Please try again in a moment.', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw data(`Failed to load user profile (${res.status}).`, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const account: {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
} = await res.json();
|
||||
|
||||
return {
|
||||
userAccountId: account.userAccountId,
|
||||
username: account.username,
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
createdAt: account.createdAt,
|
||||
};
|
||||
};
|
||||
99
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal file
99
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for
|
||||
// bio, location, avatar/cover photos, follower/following stats, and activity feeds.
|
||||
|
||||
export interface FillerProfileMeta {
|
||||
bio: string;
|
||||
location: string;
|
||||
following: number;
|
||||
followers: number;
|
||||
breweriesVisited: number;
|
||||
ratingsGiven: number;
|
||||
}
|
||||
|
||||
export const FILLER_PROFILE_META: FillerProfileMeta = {
|
||||
bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺',
|
||||
location: 'Portland, OR',
|
||||
following: 68,
|
||||
followers: 214,
|
||||
breweriesVisited: 37,
|
||||
ratingsGiven: 112,
|
||||
};
|
||||
|
||||
export interface FillerActivity {
|
||||
id: string;
|
||||
icon: 'star' | 'heart' | 'comment';
|
||||
verb: string;
|
||||
target: string;
|
||||
rating?: number;
|
||||
comment?: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const FILLER_ACTIVITY: FillerActivity[] = [
|
||||
{
|
||||
id: '1',
|
||||
icon: 'star',
|
||||
verb: 'Rated',
|
||||
target: 'Fremont Brewing — Interurban IPA',
|
||||
rating: 5,
|
||||
time: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
icon: 'heart',
|
||||
verb: 'Liked',
|
||||
target: 'Stormbreaker Brewing',
|
||||
time: '1 day ago',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
icon: 'comment',
|
||||
verb: 'Commented on',
|
||||
target: 'Great Notion — Blueberry Muffin',
|
||||
comment: "Best fruited stout I've had this year, worth the line.",
|
||||
time: '3 days ago',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
icon: 'star',
|
||||
verb: 'Rated',
|
||||
target: 'Baerlic Brewing Co.',
|
||||
rating: 4,
|
||||
time: '5 days ago',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
icon: 'heart',
|
||||
verb: 'Liked',
|
||||
target: 'Ecliptic Brewing — Phase Shift IPA',
|
||||
time: '1 week ago',
|
||||
},
|
||||
];
|
||||
|
||||
export interface FillerFollowing {
|
||||
id: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
userAccountId?: string;
|
||||
}
|
||||
|
||||
export const FILLER_FOLLOWING: FillerFollowing[] = [
|
||||
{ id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings', userAccountId: 'jweck' },
|
||||
{ id: '3', name: 'Wayfinder Beer', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings', userAccountId: 'caseyb' },
|
||||
];
|
||||
|
||||
export interface FillerLiked {
|
||||
id: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export const FILLER_LIKED: FillerLiked[] = [
|
||||
{ id: '1', name: 'Interurban IPA', meta: 'Fremont Brewing', rating: 5 },
|
||||
{ id: '2', name: 'Blueberry Muffin', meta: 'Great Notion Brewing', rating: 5 },
|
||||
{ id: '3', name: 'Phase Shift IPA', meta: 'Ecliptic Brewing', rating: 4 },
|
||||
{ id: '4', name: 'Snapshot Wheat', meta: 'Deschutes Brewery', rating: 4 },
|
||||
];
|
||||
@@ -15,4 +15,5 @@ export default [
|
||||
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
|
||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||
route('users/:id', 'features/users/routes/user-profile.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
Reference in New Issue
Block a user