Compare commits

7 Commits

30 changed files with 1066 additions and 19 deletions

View File

@@ -379,6 +379,7 @@ web/frontend/
│ │ - catalog beers, beer styles │ │ - catalog beers, beer styles
│ │ - home landing page │ │ - home landing page
│ │ - theme theme switcher/guide │ │ - theme theme switcher/guide
│ │ - users public user profile page (/users/:id)
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery │ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
│ ├── routes.ts Route table (React Router 8 config-based routing) │ ├── routes.ts Route table (React Router 8 config-based routing)
│ ├── root.tsx App shell and global providers │ ├── root.tsx App shell and global providers
@@ -395,6 +396,8 @@ web/frontend/
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
search search
- Render the auth demo, account dashboard, and theme guide routes - 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 - Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, confirmation, and - Call the .NET API for login, registration, token refresh, confirmation, and
brewery/location data brewery/location data

View File

@@ -0,0 +1,65 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Repository;
using FluentAssertions;
using Moq;
namespace Features.Users.Tests.Queries;
public class GetPublicUserProfileByIdHandlerTests
{
private readonly GetPublicUserProfileByIdHandler _handler;
private readonly Mock<IUserListRepository> _repoMock = new();
public GetPublicUserProfileByIdHandlerTests()
{
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
}
[Fact]
public async Task Handle_ReturnsPublicProfile_WhenFound()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
Email = "jane@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result
.Should()
.Be(
new PublicUserProfileDto(
user.UserAccountId,
user.Username,
user.FirstName,
user.LastName,
user.CreatedAt
)
);
}
[Fact]
public async Task Handle_Throws_WhenNotFound()
{
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<PublicUserProfileDto>> act = async () =>
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -1,6 +1,8 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetAllUsers;
using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Queries.GetUserById; using Features.Users.Queries.GetUserById;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -37,4 +39,19 @@ public class UserController(IMediator mediator) : ControllerBase
UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
return Ok(user); return Ok(user);
} }
/// <summary>Gets the public profile for the user account with the given ID.</summary>
/// <remarks>
/// Exposes only the fields that are safe to show on an unauthenticated, public profile page;
/// unlike <see cref="GetById"/>, this never returns <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
/// </exception>
[HttpGet("{id:guid}/profile")]
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
{
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
return Ok(profile);
}
} }

View File

@@ -0,0 +1,11 @@
using Domain.Entities;
namespace Features.Users.Dtos;
/// <summary>Converts user domain entities into API response models.</summary>
public static class UserDtoMapper
{
/// <summary>Converts a user account into its public-profile representation.</summary>
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) =>
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt);
}

View File

@@ -0,0 +1,10 @@
namespace Features.Users.Dtos;
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
public record PublicUserProfileDto(
Guid UserAccountId,
string Username,
string FirstName,
string LastName,
DateTime CreatedAt
);

View File

@@ -0,0 +1,23 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Repository;
using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById;
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
{
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<PublicUserProfileDto> Handle(
GetPublicUserProfileByIdQuery request,
CancellationToken cancellationToken
)
{
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
return user.ToPublicProfileDto();
}
}

View File

@@ -0,0 +1,6 @@
using Features.Users.Dtos;
using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById;
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; 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'; 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.`; 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 }); const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument(); await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton); await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument(); // MenuItems is anchor-positioned, which portals it outside canvasElement.
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument(); 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();
}, },
}; };

View File

@@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => (
Dashboard Dashboard
</Link> </Link>
<UserMenu username={auth.username} /> <UserMenu
username={auth.username}
userAccountId={auth.userAccountId}
/>
</> </>
) : ( ) : (
<Link to="/login" className="btn btn-primary btn-sm"> <Link to="/login" className="btn btn-primary btn-sm">

View File

@@ -3,25 +3,40 @@ import { Link } from 'react-router';
interface UserMenuProps { interface UserMenuProps {
username: string; username: string;
userAccountId: string;
} }
const UserMenu = ({ username }: UserMenuProps) => ( const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
<Menu as="div" className="relative"> <Menu>
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton> <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> <MenuItem>
{({ focus }) => ( <Link
<Link to="/dashboard" className={focus ? 'active' : ''}> to={`/users/${userAccountId}`}
Dashboard className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
</Link> >
)} My profile
</Link>
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
{({ focus }) => ( <Link
<Link to="/logout" className={focus ? 'active' : ''}> to="/dashboard"
Logout className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
</Link> >
)} Dashboard
</Link>
</MenuItem>
<MenuItem>
<Link
to="/logout"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Logout
</Link>
</MenuItem> </MenuItem>
</MenuItems> </MenuItems>
</Menu> </Menu>

View File

@@ -54,3 +54,14 @@ export const Liked: Story = {
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument(); await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument();
}, },
}; };
export const WithLinkedAccount: Story = {
args: { comment: { ...comment, userAccountId: '11111111-1111-1111-1111-111111111111' } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: 'malt_kettle' })).toHaveAttribute(
'href',
'/users/11111111-1111-1111-1111-111111111111',
);
},
};

View File

@@ -1,4 +1,5 @@
import { Heart, HeartSolid } from 'iconoir-react'; import { Heart, HeartSolid } from 'iconoir-react';
import { Link } from 'react-router';
import StarRating from '../../shared/StarRating'; import StarRating from '../../shared/StarRating';
import type { FillerComment } from '../../../utils/filler-brewery-detail'; import type { FillerComment } from '../../../utils/filler-brewery-detail';
@@ -19,7 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
</div> </div>
<div className="flex-1 flex flex-col gap-1 min-w-0"> <div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
<span className="font-bold text-sm">{comment.user}</span> {comment.userAccountId ? (
<Link
to={`/users/${comment.userAccountId}`}
className="font-bold text-sm link link-hover"
>
{comment.user}
</Link>
) : (
<span className="font-bold text-sm">{comment.user}</span>
)}
<StarRating value={comment.rating} size="xs" /> <StarRating value={comment.rating} size="xs" />
<span className="text-xs text-base-content/60">{comment.time}</span> <span className="text-xs text-base-content/60">{comment.time}</span>
</div> </div>

View File

@@ -28,13 +28,14 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
brewery, brewery,
loggedIn: auth !== null, loggedIn: auth !== null,
username: auth?.username ?? null, username: auth?.username ?? null,
userAccountId: auth?.userAccountId ?? null,
}; };
}; };
const initials = (username: string) => username.slice(0, 2).toUpperCase(); const initials = (username: string) => username.slice(0, 2).toUpperCase();
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => { const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
const { brewery, loggedIn, username } = loaderData; const { brewery, loggedIn, username, userAccountId } = loaderData;
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount); const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
@@ -57,6 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
user: username ?? 'you', user: username ?? 'you',
userAccountId: userAccountId ?? '',
initials: username ? initials(username) : 'YO', initials: username ? initials(username) : 'YO',
rating: yourRating, rating: yourRating,
time: 'just now', time: 'just now',

View File

@@ -88,6 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
export interface FillerComment { export interface FillerComment {
id: string; id: string;
user: string; user: string;
userAccountId?: string;
initials: string; initials: string;
rating: number; rating: number;
time: string; time: string;
@@ -96,6 +97,8 @@ export interface FillerComment {
liked: boolean; liked: boolean;
} }
// `userAccountId` is left unset below since these commenters aren't backed by real accounts
// yet; CommentItem only links a commenter's name once a real account GUID is available.
export const FILLER_COMMENTS: FillerComment[] = [ export const FILLER_COMMENTS: FillerComment[] = [
{ {
id: '1', id: '1',

View 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}/profile` (`PublicUserProfileDto`) | |
| `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`).

View File

@@ -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;

View File

@@ -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();
},
};

View File

@@ -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;

View File

@@ -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 = () => {
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
};
export const Default: Story = {
args: { activeTab: 'activity', onChange: fn(), panels },
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');
await expect(canvas.getByText('Following panel')).toBeInTheDocument();
},
};

View File

@@ -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;

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,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;

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;

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import { data } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getOptionalAuth } from '../../auth/auth.server';
import { getPublicUserProfile, type PublicUserProfile } 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))}`;
interface ProfileContentProps {
profile: PublicUserProfile;
isOwnProfile: boolean;
}
/**
* Owns the profile-scoped interactive state. Keyed by `profile.userAccountId` in the
* parent so React remounts it (resetting follow/tab state) when the route's `:id` changes,
* since React Router reuses the route component itself across param-only navigations.
*/
const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
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="mt-12">
<ProfileTabs
activeTab={activeTab}
onChange={setActiveTab}
panels={{
activity: <ActivityTab activity={FILLER_ACTIVITY} />,
following: (
<FollowingTab
following={following}
onToggleFollow={handleToggleFollowRow}
/>
),
liked: <LikedTab liked={FILLER_LIKED} />,
}}
/>
</div>
</div>
</div>
</div>
);
};
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
const { profile, isOwnProfile } = loaderData;
return (
<ProfileContent
key={profile.userAccountId}
profile={profile}
isOwnProfile={isOwnProfile}
/>
);
};
export default UserProfile;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
<RouteErrorState error={error} />
);

View File

@@ -0,0 +1,58 @@
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 from the dedicated public-profile
* endpoint, which never includes `email` or `dateOfBirth`.
*/
export const getPublicUserProfile = async (
userAccountId: string,
): Promise<PublicUserProfile | null> => {
let res: Response;
try {
res = await fetch(
`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`,
);
} 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,
};
};

View File

@@ -0,0 +1,101 @@
// 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;
}
// `userAccountId` is left unset for rows below since no backend account backs them yet;
// FollowingTab only links a row once a real account GUID is available (see USER_PROFILE_HANDOFF.md).
export const FILLER_FOLLOWING: FillerFollowing[] = [
{ id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' },
{ id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings' },
{ id: '3', name: 'Wayfinder Beer', meta: 'Portland, OR · Brewery' },
{ id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings' },
];
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 },
];

View File

@@ -15,4 +15,5 @@ export default [
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'), route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'), route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'), route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
route('users/:id', 'features/users/routes/user-profile.tsx'),
] satisfies RouteConfig; ] satisfies RouteConfig;