diff --git a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
new file mode 100644
index 00000000..2f94f86d
--- /dev/null
+++ b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
@@ -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`).
diff --git a/web/frontend/app/features/users/components/profile/EmptyState.tsx b/web/frontend/app/features/users/components/profile/EmptyState.tsx
new file mode 100644
index 00000000..24232baa
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/EmptyState.tsx
@@ -0,0 +1,9 @@
+const EmptyState = ({ message }: { message: string }) => (
+
+);
+
+export default EmptyState;
diff --git a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx
new file mode 100644
index 00000000..258af837
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+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();
+ },
+};
diff --git a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx
new file mode 100644
index 00000000..c99a9a89
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx
@@ -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) => (
+
+
+
+
{displayName}
+
@{username}
+
+
+ {isOwnProfile ? (
+
+ Edit profile
+
+ ) : (
+
+ {isFollowing ? 'Following' : 'Follow'}
+
+ )}
+
+
{bio}
+
+
+
+
+ {location}
+
+
+
+ {joinedLabel}
+
+
+
+
+
+
+ {stats.map((stat) => (
+
+ {stat.label}
+
+ {stat.value}
+
+
+ ))}
+
+
+
+);
+
+export default ProfileSidebarCard;
diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx
new file mode 100644
index 00000000..9ff96ec7
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+const ControlledTabs = () => {
+ const [activeTab, setActiveTab] = useState('activity');
+ return ;
+};
+
+export const Default: Story = {
+ args: { activeTab: 'activity', onChange: fn() },
+ render: () => ,
+ 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');
+ },
+};
diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx
new file mode 100644
index 00000000..ec0a8c0a
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx
@@ -0,0 +1,38 @@
+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) => (
+
+ {TABS.map((tab) => {
+ const active = tab.id === activeTab;
+ return (
+ onChange(tab.id)}
+ className={`py-3 text-sm font-semibold border-b-2 -mb-px transition-colors ${
+ active
+ ? 'text-primary border-primary'
+ : 'text-base-content/60 border-transparent hover:text-base-content'
+ }`}
+ >
+ {tab.label}
+
+ );
+ })}
+
+);
+
+export default ProfileTabs;
diff --git a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx
new file mode 100644
index 00000000..ad789c40
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+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();
+ },
+};
diff --git a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx
new file mode 100644
index 00000000..e377faa5
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ {activity.map((item) => {
+ const Icon = ICONS[item.icon];
+ return (
+
+
+
+
+
+
+
+ {item.verb} {item.target}
+
+ {item.rating !== undefined && (
+
+
+
+ )}
+ {item.comment && (
+
+ “{item.comment}”
+
+ )}
+
+ {item.time}
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ActivityTab;
diff --git a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx
new file mode 100644
index 00000000..01f8b429
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+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();
+ },
+};
diff --git a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx
new file mode 100644
index 00000000..53552452
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx
@@ -0,0 +1,51 @@
+import EmptyState from '../EmptyState';
+import type { FillerFollowing } from '../../../utils/filler-user-profile';
+
+const initials = (name: string) =>
+ name
+ .split(' ')
+ .map((part) => part[0])
+ .filter(Boolean)
+ .slice(0, 2)
+ .join('')
+ .toUpperCase();
+
+interface FollowingTabProps {
+ following: (FillerFollowing & { isFollowing: boolean })[];
+ onToggleFollow: (id: string) => void;
+}
+
+const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
+ if (following.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {following.map((f) => (
+
+
+
+
+ {initials(f.name)}
+
+
+
+
onToggleFollow(f.id)}
+ className={`btn btn-sm shrink-0 ${f.isFollowing ? 'btn-outline' : 'btn-primary'}`}
+ >
+ {f.isFollowing ? 'Following' : 'Follow'}
+
+
+
+ ))}
+
+ );
+};
+
+export default FollowingTab;
diff --git a/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx
new file mode 100644
index 00000000..23257429
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+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();
+ },
+};
diff --git a/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx b/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx
new file mode 100644
index 00000000..b6cd40c0
--- /dev/null
+++ b/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx
@@ -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 ;
+ }
+
+ return (
+
+ {liked.map((item) => (
+
+
+
+
{item.name}
+
{item.meta}
+
+
+
+
+
+ ))}
+
+ );
+};
+
+export default LikedTab;
diff --git a/web/frontend/app/features/users/routes/user-profile.tsx b/web/frontend/app/features/users/routes/user-profile.tsx
new file mode 100644
index 00000000..6948a62a
--- /dev/null
+++ b/web/frontend/app/features/users/routes/user-profile.tsx
@@ -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('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 (
+
+
+
+
+
+
+
+
+
+
+ {displayName.slice(0, 2).toUpperCase()}
+
+
+
+
+
+
setIsFollowingProfile((prev) => !prev)}
+ />
+
+
+
+
+
+ {activeTab === 'activity' &&
}
+ {activeTab === 'following' && (
+
+ )}
+ {activeTab === 'liked' &&
}
+
+
+
+
+ );
+};
+
+export default UserProfile;
+
+export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
+
+);
diff --git a/web/frontend/app/features/users/users.server.ts b/web/frontend/app/features/users/users.server.ts
new file mode 100644
index 00000000..0bdd2146
--- /dev/null
+++ b/web/frontend/app/features/users/users.server.ts
@@ -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 => {
+ 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,
+ };
+};
diff --git a/web/frontend/app/features/users/utils/filler-user-profile.ts b/web/frontend/app/features/users/utils/filler-user-profile.ts
new file mode 100644
index 00000000..c37878bc
--- /dev/null
+++ b/web/frontend/app/features/users/utils/filler-user-profile.ts
@@ -0,0 +1,98 @@
+// 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;
+}
+
+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 },
+];
diff --git a/web/frontend/app/routes.ts b/web/frontend/app/routes.ts
index fbf0fb0d..2d338492 100644
--- a/web/frontend/app/routes.ts
+++ b/web/frontend/app/routes.ts
@@ -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;