From 6f1dfce6253576fac770db8d8b2c80eb3636e396 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 12 Sep 2026 15:08:47 -0400 Subject: [PATCH] Add public user profile page at /users/:id --- .../features/users/USER_PROFILE_HANDOFF.md | 48 ++++++++ .../users/components/profile/EmptyState.tsx | 9 ++ .../profile/ProfileSidebarCard.stories.tsx | 62 ++++++++++ .../components/profile/ProfileSidebarCard.tsx | 82 +++++++++++++ .../profile/ProfileTabs.stories.tsx | 40 ++++++ .../users/components/profile/ProfileTabs.tsx | 38 ++++++ .../profile/tabs/ActivityTab.stories.tsx | 42 +++++++ .../components/profile/tabs/ActivityTab.tsx | 55 +++++++++ .../profile/tabs/FollowingTab.stories.tsx | 46 +++++++ .../components/profile/tabs/FollowingTab.tsx | 51 ++++++++ .../profile/tabs/LikedTab.stories.tsx | 40 ++++++ .../components/profile/tabs/LikedTab.tsx | 32 +++++ .../features/users/routes/user-profile.tsx | 116 ++++++++++++++++++ .../app/features/users/users.server.ts | 56 +++++++++ .../users/utils/filler-user-profile.ts | 98 +++++++++++++++ web/frontend/app/routes.ts | 1 + 16 files changed, 816 insertions(+) create mode 100644 web/frontend/app/features/users/USER_PROFILE_HANDOFF.md create mode 100644 web/frontend/app/features/users/components/profile/EmptyState.tsx create mode 100644 web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx create mode 100644 web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx create mode 100644 web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx create mode 100644 web/frontend/app/features/users/components/profile/ProfileTabs.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx create mode 100644 web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx create mode 100644 web/frontend/app/features/users/routes/user-profile.tsx create mode 100644 web/frontend/app/features/users/users.server.ts create mode 100644 web/frontend/app/features/users/utils/filler-user-profile.ts 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 }) => ( +
+
+

{message}

+
+
+); + +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 + + ) : ( + + )} + +

{bio}

+ +
+
+
+
+
+
+ +
+ +
+ {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 ( + + ); + })} +
+); + +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)} +
+
+
+

{f.name}

+

{f.meta}

+
+ +
+
+ ))} +
+ ); +}; + +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;