Skip to content

Commit 63af90e

Browse files
dcl10claude
andauthored
Feature/invitation inbox (#54)
* Add membership API types and client functions Adds UserTeamMembershipDto type and fetchMyMemberships, acceptMembership, and declineMembership functions to the API layer, wiring up the existing backend endpoints for GET /api/users/me/teams and the accept/decline membership routes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Wire Invitations nav item and live pending badge Renames the LeftRail "Matches" item to "Invitations", makes its badge dynamic via a pendingInviteCount prop, and threads the count from AppShell through both the projects and profile layouts (fetched in parallel with the user sync). AppShell now routes the matches nav item to /invitations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add invitation inbox page with accept/decline actions New /invitations route shows pending team invitations (Invited status) with Accept/Decline buttons and self-requests (Requested status) with an awaiting-response indicator. Server actions revalidate the page after each response. InvitationCard is exported from the cards barrel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix monospace font in create project description textarea Browsers apply a monospace UA default to <textarea> that overrides inheritance; adding font-sans to the shared inputClass fixes it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e3d24cd commit 63af90e

12 files changed

Lines changed: 332 additions & 12 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
5+
import { acceptMembership, declineMembership } from "@/lib/api/users";
6+
import { getAccessToken } from "@/lib/auth";
7+
8+
/**
9+
* Accepts a team invitation on behalf of the current user.
10+
* @param teamId - Team GUID to accept
11+
*/
12+
export async function acceptInvitationAction(teamId: string): Promise<void> {
13+
const token = (await getAccessToken())!;
14+
await acceptMembership(teamId, token);
15+
revalidatePath("/invitations");
16+
}
17+
18+
/**
19+
* Declines a team invitation on behalf of the current user.
20+
* @param teamId - Team GUID to decline
21+
*/
22+
export async function declineInvitationAction(teamId: string): Promise<void> {
23+
const token = (await getAccessToken())!;
24+
await declineMembership(teamId, token);
25+
revalidatePath("/invitations");
26+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { redirect } from "next/navigation";
2+
3+
import { fetchMyMemberships, syncAndFetchCurrentUser } from "@/lib/api/users";
4+
import { getAccessToken, getSession } from "@/lib/auth";
5+
6+
import { AppShell } from "../profile/_AppShell";
7+
8+
/**
9+
* Authenticated layout for the invitations inbox — provides TopBar and LeftRail.
10+
*/
11+
export default async function InvitationsLayout({
12+
children,
13+
}: {
14+
children: React.ReactNode;
15+
}) {
16+
const session = await getSession();
17+
if (!session) redirect("/");
18+
19+
const token = await getAccessToken();
20+
if (!token) redirect("/");
21+
22+
const [currentUser, memberships] = await Promise.all([
23+
syncAndFetchCurrentUser(token),
24+
fetchMyMemberships(token),
25+
]);
26+
27+
const initials = currentUser.displayName
28+
.split(" ")
29+
.map((w) => w[0])
30+
.join("")
31+
.slice(0, 2)
32+
.toUpperCase();
33+
34+
const pendingInviteCount = memberships.filter(
35+
(m) => m.membershipStatus === "Invited",
36+
).length;
37+
38+
return (
39+
<AppShell userInitials={initials} pendingInviteCount={pendingInviteCount}>
40+
{children}
41+
</AppShell>
42+
);
43+
}

frontend/app/invitations/page.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { InboxIcon } from "@heroicons/react/24/outline";
2+
3+
import { InvitationCard } from "@/components/cards/InvitationCard";
4+
import { EmptyState } from "@/components/shared/EmptyState";
5+
import { PageHeader } from "@/components/shared/PageHeader";
6+
import { fetchMyMemberships } from "@/lib/api/users";
7+
import { getAccessToken } from "@/lib/auth";
8+
9+
/**
10+
* Invitation inbox — lists pending team invitations and self-requests for the current user.
11+
*/
12+
export default async function InvitationsPage() {
13+
const token = (await getAccessToken())!;
14+
const memberships = await fetchMyMemberships(token);
15+
16+
const pending = memberships.filter(
17+
(m) =>
18+
m.membershipStatus === "Invited" || m.membershipStatus === "Requested",
19+
);
20+
21+
return (
22+
<main className="bg-paper px-6 py-10 min-h-[calc(100vh-60px)]">
23+
<PageHeader
24+
title="Invitations"
25+
description="Projects you have been invited to join or have requested to join."
26+
/>
27+
28+
{pending.length === 0 ? (
29+
<EmptyState
30+
icon={InboxIcon}
31+
title="No pending invitations"
32+
description="When a project manager invites you to a team, it will appear here."
33+
/>
34+
) : (
35+
<div className="flex flex-col gap-4 max-w-2xl">
36+
{pending.map((membership) => (
37+
<InvitationCard key={membership.teamId} membership={membership} />
38+
))}
39+
</div>
40+
)}
41+
</main>
42+
);
43+
}

frontend/app/profile/_AppShell.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import { TopBar } from "@/components/shared/TopBar";
99

1010
const ROUTE_MAP: Record<string, string> = {
1111
"/projects": "my-projects",
12+
"/invitations": "matches",
1213
};
1314

1415
interface AppShellProps {
1516
userInitials: string;
1617
userHue?: 0 | 1 | 2 | 3;
1718
children: React.ReactNode;
19+
/** Number of pending invitations to show as a badge on the Invitations nav item. */
20+
pendingInviteCount?: number;
1821
}
1922

2023
/**
@@ -26,6 +29,7 @@ export function AppShell({
2629
userInitials,
2730
userHue = 1,
2831
children,
32+
pendingInviteCount,
2933
}: AppShellProps) {
3034
const pathname = usePathname();
3135
const router = useRouter();
@@ -37,6 +41,7 @@ export function AppShell({
3741
function handleNavigate(routeId: string) {
3842
const destinations: Record<string, string> = {
3943
"my-projects": "/projects",
44+
matches: "/invitations",
4045
};
4146
const path = destinations[routeId];
4247
if (path) router.push(path);
@@ -62,6 +67,7 @@ export function AppShell({
6267
onNavigate={handleNavigate}
6368
open={drawerOpen}
6469
onClose={() => setDrawerOpen(false)}
70+
pendingInviteCount={pendingInviteCount}
6571
/>
6672
<div className="flex-1 min-w-0">{children}</div>
6773
</div>

frontend/app/profile/layout.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { redirect } from "next/navigation";
22

3-
import { syncAndFetchCurrentUser } from "@/lib/api/users";
3+
import { fetchMyMemberships, syncAndFetchCurrentUser } from "@/lib/api/users";
44
import { getAccessToken, getSession } from "@/lib/auth";
55

66
import { AppShell } from "./_AppShell";
@@ -19,7 +19,10 @@ export default async function ProfileLayout({
1919
const token = await getAccessToken();
2020
if (!token) redirect("/");
2121

22-
const currentUser = await syncAndFetchCurrentUser(token);
22+
const [currentUser, memberships] = await Promise.all([
23+
syncAndFetchCurrentUser(token),
24+
fetchMyMemberships(token),
25+
]);
2326

2427
const initials = currentUser.displayName
2528
.split(" ")
@@ -28,5 +31,13 @@ export default async function ProfileLayout({
2831
.slice(0, 2)
2932
.toUpperCase();
3033

31-
return <AppShell userInitials={initials}>{children}</AppShell>;
34+
const pendingInviteCount = memberships.filter(
35+
(m) => m.membershipStatus === "Invited",
36+
).length;
37+
38+
return (
39+
<AppShell userInitials={initials} pendingInviteCount={pendingInviteCount}>
40+
{children}
41+
</AppShell>
42+
);
3243
}

frontend/app/projects/layout.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { redirect } from "next/navigation";
22

3-
import { syncAndFetchCurrentUser } from "@/lib/api/users";
3+
import { fetchMyMemberships, syncAndFetchCurrentUser } from "@/lib/api/users";
44
import { getAccessToken, getSession } from "@/lib/auth";
55

66
import { AppShell } from "../profile/_AppShell";
@@ -19,7 +19,10 @@ export default async function ProjectsLayout({
1919
const token = await getAccessToken();
2020
if (!token) redirect("/");
2121

22-
const currentUser = await syncAndFetchCurrentUser(token);
22+
const [currentUser, memberships] = await Promise.all([
23+
syncAndFetchCurrentUser(token),
24+
fetchMyMemberships(token),
25+
]);
2326

2427
const initials = currentUser.displayName
2528
.split(" ")
@@ -28,5 +31,13 @@ export default async function ProjectsLayout({
2831
.slice(0, 2)
2932
.toUpperCase();
3033

31-
return <AppShell userInitials={initials}>{children}</AppShell>;
34+
const pendingInviteCount = memberships.filter(
35+
(m) => m.membershipStatus === "Invited",
36+
).length;
37+
38+
return (
39+
<AppShell userInitials={initials} pendingInviteCount={pendingInviteCount}>
40+
{children}
41+
</AppShell>
42+
);
3243
}

frontend/app/projects/new/_CreateProjectForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function Field({
132132
}
133133

134134
const inputClass = cn(
135-
"w-full text-[14px] text-ink border border-[var(--border)] rounded-sm px-3 py-[9px]",
135+
"w-full font-sans text-[14px] text-ink border border-[var(--border)] rounded-sm px-3 py-[9px]",
136136
"bg-paper placeholder:text-ink-faint",
137137
"focus:outline-none focus:border-nottingham-blue focus:ring-2 focus:ring-nottingham-blue/20",
138138
"transition-[border-color,box-shadow] duration-[120ms] disabled:opacity-50",
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
5+
import {
6+
acceptInvitationAction,
7+
declineInvitationAction,
8+
} from "@/app/invitations/actions";
9+
import { Button } from "@/components/core/Button";
10+
import { StatusPill } from "@/components/core/StatusPill";
11+
import { Toast } from "@/components/core/Toast";
12+
import type { ProjectStatus, UserTeamMembershipDto } from "@/lib/api/types";
13+
import { cn } from "@/lib/utils";
14+
15+
function toStatusPill(projectStatus: ProjectStatus) {
16+
switch (projectStatus) {
17+
case "Open":
18+
return <StatusPill status="open" />;
19+
case "TeamConfirmed":
20+
return <StatusPill status="accepted" label="Team confirmed" />;
21+
case "Closed":
22+
return <StatusPill status="closed" />;
23+
default:
24+
return null;
25+
}
26+
}
27+
28+
interface InvitationCardProps {
29+
membership: UserTeamMembershipDto;
30+
}
31+
32+
/**
33+
* Card displaying a single team invitation or self-request.
34+
* Invited entries show Accept/Decline buttons; Requested entries show an awaiting-response indicator.
35+
* @param membership - The membership record to display
36+
*/
37+
export function InvitationCard({ membership }: InvitationCardProps) {
38+
const [isPendingAccept, startAccept] = useTransition();
39+
const [isPendingDecline, startDecline] = useTransition();
40+
const [toast, setToast] = useState<string | null>(null);
41+
42+
const busy = isPendingAccept || isPendingDecline;
43+
44+
function handleAccept() {
45+
setToast("Invitation accepted");
46+
startAccept(async () => {
47+
await acceptInvitationAction(membership.teamId);
48+
});
49+
}
50+
51+
function handleDecline() {
52+
setToast("Invitation declined");
53+
startDecline(async () => {
54+
await declineInvitationAction(membership.teamId);
55+
});
56+
}
57+
58+
return (
59+
<>
60+
<article
61+
className={cn(
62+
"bg-paper border border-[var(--border)] rounded-md p-[22px]",
63+
"flex flex-col gap-3",
64+
busy && "opacity-60",
65+
)}
66+
>
67+
<div className="flex items-start justify-between gap-4">
68+
<div className="min-w-0">
69+
<h3 className="text-[18px] font-medium tracking-[-0.015em] leading-[1.25] text-ink m-0">
70+
{membership.projectTitle}
71+
</h3>
72+
<p className="text-[13px] text-ink-soft mt-1 m-0">
73+
Role: <span className="text-ink">{membership.projectRole}</span>
74+
</p>
75+
</div>
76+
<div className="shrink-0 mt-0.5">
77+
{toStatusPill(membership.projectStatus)}
78+
</div>
79+
</div>
80+
81+
{membership.membershipStatus === "Invited" && (
82+
<div className="flex gap-2 pt-1">
83+
<Button size="sm" onClick={handleAccept} disabled={busy}>
84+
Accept
85+
</Button>
86+
<Button
87+
size="sm"
88+
variant="destructive"
89+
onClick={handleDecline}
90+
disabled={busy}
91+
>
92+
Decline
93+
</Button>
94+
</div>
95+
)}
96+
97+
{membership.membershipStatus === "Requested" && (
98+
<div className="pt-1">
99+
<StatusPill status="pending" label="Awaiting response" />
100+
</div>
101+
)}
102+
</article>
103+
104+
{toast && <Toast message={toast} onDismiss={() => setToast(null)} />}
105+
</>
106+
);
107+
}

frontend/components/cards/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { InvitationCard } from "./InvitationCard";
12
export { MatchRow } from "./MatchRow";
23
export { ProfileCard } from "./ProfileCard";
34
export { ProjectCard } from "./ProjectCard";

0 commit comments

Comments
 (0)