Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Member } from "@gov-portal/api-client";
import Link from "next/link";

import { Button } from "@/components/ui/button";
import { type Dictionary, type Locale, localePath } from "@/lib/i18n";

export function MemberProfileContent({
profile,
dict,
locale,
isOwner,
}: {
profile: Member;
dict: Dictionary;
locale: Locale;
isOwner: boolean;
}) {
const bio = profile.bio?.trim() ?? "";
const hasSkills = profile.skills.length > 0;
const hasAffiliation = profile.affiliation !== null;
const hasLinks = profile.links.length > 0;
const hasDetails = bio !== "" || hasSkills || hasAffiliation || hasLinks;

return (
<section
aria-labelledby="member-about-heading"
className="mx-auto w-full max-w-[960px] overflow-hidden rounded-xl border border-border bg-card"
>
<div className="border-b border-border px-5 py-4 sm:px-7">
<h2 id="member-about-heading" className="m-0 text-base font-semibold text-foreground">
{dict.member.about}
</h2>
</div>

{hasDetails ? (
<div className="px-5 py-5 sm:px-7 sm:py-6">
{bio !== "" ? (
<p className="m-0 max-w-[72ch] whitespace-pre-wrap text-sm leading-7 text-foreground sm:text-base">
{bio}
</p>
) : null}

{hasSkills || hasAffiliation || hasLinks ? (
<dl
className={`m-0 divide-y divide-border ${bio !== "" ? "mt-6 border-t border-border" : ""}`}
>
{hasSkills ? (
<div className="grid gap-2 py-4 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-5">
<dt className="text-sm font-semibold text-foreground">{dict.member.skills}</dt>
<dd className="m-0 flex flex-wrap gap-2">
{profile.skills.map((skill) => (
<span
key={skill}
className="rounded-md border border-border bg-muted/60 px-2.5 py-1 text-xs font-medium text-foreground"
>
{dict.members.skillNames[skill]}
</span>
))}
</dd>
</div>
) : null}

{hasAffiliation ? (
<div className="grid gap-2 py-4 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-5">
<dt className="text-sm font-semibold text-foreground">
{dict.member.affiliationLabel}
</dt>
<dd className="m-0 text-sm leading-6 text-foreground">{profile.affiliation}</dd>
</div>
) : null}

{hasLinks ? (
<div className="grid gap-2 py-4 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-5">
<dt className="text-sm font-semibold text-foreground">{dict.member.links}</dt>
<dd className="m-0 flex min-w-0 flex-wrap gap-2">
{profile.links.map((link) => (
<a
key={link}
href={link}
target="_blank"
rel="noopener noreferrer"
className="max-w-full break-all rounded-md border border-border bg-muted/60 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{link.replace(/^https?:\/\//, "")}
</a>
))}
</dd>
</div>
) : null}
</dl>
) : null}
</div>
) : (
<div className="flex min-h-48 flex-col items-start justify-center px-5 py-8 sm:min-h-56 sm:px-7">
<h3 className="m-0 text-lg font-semibold text-foreground">{dict.member.emptyTitle}</h3>
<p className="mt-2 mb-0 max-w-[48ch] text-sm leading-6 text-muted-foreground">
{dict.member.emptyBody}
</p>
{isOwner ? (
<Button
className="mt-5"
size="sm"
variant="outline"
nativeButton={false}
render={<Link href={localePath(locale, "/profile")} />}
>
{dict.member.addDetails}
</Button>
) : null}
</div>
)}
</section>
);
}
88 changes: 8 additions & 80 deletions apps/api/src/app/(site)/[locale]/members/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import {
BriefcaseIcon,
BuildingsIcon,
CheckCircleIcon,
GithubLogoIcon,
MapPinIcon,
Expand All @@ -21,6 +20,8 @@ import { useActor, useLocale, useMember } from "@/hooks";
import { ApiError } from "@/lib/api-error";
import { localePath } from "@/lib/i18n";

import { MemberProfileContent } from "./member-profile-content";

export default function MemberDetailPage() {
const { locale, dict } = useLocale();
const { username } = useParams<{ username: string }>();
Expand Down Expand Up @@ -75,17 +76,13 @@ export default function MemberDetailPage() {

const isOwner = actor !== null && actor.member.githubId === profile.githubId;
const isPending = actor !== null && isOwner && actor.member.status !== "approved";
const hasBio = profile.bio !== null;
const hasSidebar =
profile.skills.length > 0 || profile.affiliation !== null || profile.links.length > 0;

return (
<div className="w-full bg-background min-h-screen">
<div className="w-full bg-background">
{/* Breadcrumb Bar */}
<div className="border-b border-border bg-card px-4 py-3">
<div className="mx-auto flex max-w-[1200px] flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link href={localePath(locale, "/")} className="hover:text-foreground transition-colors">
Home
{dict.member.home}
</Link>
<span>/</span>
<Link
Expand Down Expand Up @@ -162,15 +159,15 @@ export default function MemberDetailPage() {
<Button
variant="outline"
size="icon-sm"
aria-label="Share profile"
aria-label={dict.member.shareProfile}
onClick={handleShare}
className="cursor-pointer"
>
<ShareNetworkIcon className="size-4" />
</Button>
{copied ? (
<span className="absolute -top-8 left-1/2 -translate-x-1/2 rounded-md bg-foreground px-2 py-0.5 text-[11px] font-semibold text-background shadow-xs whitespace-nowrap z-50">
Copied link!
{dict.member.copiedLink}
</span>
) : null}
</div>
Expand All @@ -194,83 +191,14 @@ export default function MemberDetailPage() {
</div>

{/* Main Content Area */}
<div className="mx-auto max-w-[1200px] px-4 py-8">
<div className="mx-auto max-w-[1200px] px-4 py-6 sm:py-8">
{isPending ? (
<StateBanner tone="attention" role="status" className="mb-6">
{dict.profile.status[actor.member.status]}
</StateBanner>
) : null}

<div
className={
hasBio && hasSidebar
? "grid grid-cols-1 gap-8 lg:grid-cols-[1fr_320px]"
: "grid grid-cols-1 gap-8"
}
>
{hasBio ? (
<div>
<p className="text-sm leading-relaxed text-muted-foreground sm:text-base">
{profile.bio}
</p>
</div>
) : null}

{/* Right Sidebar Column */}
{hasSidebar ? (
<div className={`space-y-6 ${hasBio ? "" : "max-w-sm"}`}>
{/* Skills Card */}
{profile.skills.length > 0 ? (
<div className="rounded-xl border border-border bg-card p-5">
<h3 className="mb-3 text-sm font-bold text-foreground">Skills</h3>
<div className="flex flex-wrap gap-2">
{profile.skills.map((skill) => (
<span
key={skill}
className="rounded-md border border-border bg-muted/60 px-2.5 py-1 text-xs font-medium text-foreground"
>
{skill}
</span>
))}
</div>
</div>
) : null}

{/* Affiliation Card */}
{profile.affiliation !== null ? (
<div className="rounded-xl border border-border bg-card p-5">
<h3 className="mb-3 text-sm font-bold text-foreground">Affiliation</h3>
<div className="space-y-2.5 text-sm text-foreground">
<div className="flex items-center gap-2">
<BuildingsIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{profile.affiliation}</span>
</div>
</div>
</div>
) : null}

{/* Links Card */}
{profile.links.length > 0 ? (
<div className="rounded-xl border border-border bg-card p-5">
<h3 className="mb-3 text-sm font-bold text-foreground">Links</h3>
<div className="flex flex-wrap gap-2">
{profile.links.map((link) => (
<a
key={link}
href={link}
target="_blank"
rel="noopener noreferrer"
className="max-w-full break-all rounded-md border border-border bg-muted/60 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
{link.replace(/^https?:\/\//, "")}
</a>
))}
</div>
</div>
) : null}
</div>
) : null}
</div>
<MemberProfileContent profile={profile} dict={dict} locale={locale} isOwner={isOwner} />
</div>
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ const en = {
},
member: {
kicker: "Member profile",
home: "Home",
about: "About",
emptyTitle: "No additional details yet",
emptyBody: "This member has not added a bio, skills, affiliation, or links.",
addDetails: "Add profile details",
shareProfile: "Share profile",
copiedLink: "Link copied",
locationLabel: "Location",
affiliationLabel: "Affiliation",
skills: "Skills",
Expand Down Expand Up @@ -560,6 +567,13 @@ const ne: Dictionary = {
},
member: {
kicker: "सदस्य प्रोफाइल",
home: "गृहपृष्ठ",
about: "परिचय",
emptyTitle: "थप विवरण अझै छैन",
emptyBody: "यो सदस्यले परिचय, सीप, संस्था वा लिंकहरू थपेका छैनन्।",
addDetails: "प्रोफाइल विवरण थप्नुहोस्",
shareProfile: "प्रोफाइल साझा गर्नुहोस्",
copiedLink: "लिंक कपी भयो",
locationLabel: "स्थान",
affiliationLabel: "संस्था",
skills: "सीपहरू",
Expand Down
81 changes: 81 additions & 0 deletions apps/api/tests/unit/member-profile-content.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Member } from "@gov-portal/api-client";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { MemberProfileContent } from "@/app/(site)/[locale]/members/[username]/member-profile-content";
import { getDictionary } from "@/lib/i18n";

const emptyProfile: Member = {
githubId: 123,
githubUsername: "member",
displayName: "Member",
headline: null,
affiliation: null,
location: null,
bio: null,
links: [],
skills: [],
avatarUrl: null,
};

describe("public member profile content", () => {
it("shows a meaningful empty card for visitors", () => {
const html = renderToStaticMarkup(
<MemberProfileContent
profile={emptyProfile}
dict={getDictionary("en")}
locale="en"
isOwner={false}
/>,
);

expect(html).toContain('aria-labelledby="member-about-heading"');
expect(html).toContain("No additional details yet");
expect(html).not.toContain("Add profile details");
});

it("offers the owner a route to complete an empty profile", () => {
const html = renderToStaticMarkup(
<MemberProfileContent
profile={emptyProfile}
dict={getDictionary("ne")}
locale="ne"
isOwner
/>,
);

expect(html).toContain("थप विवरण अझै छैन");
expect(html).toContain('href="/ne/profile"');
});

it("keeps sparse details in the same content card", () => {
const html = renderToStaticMarkup(
<MemberProfileContent
profile={{ ...emptyProfile, affiliation: "Public Office", skills: ["engineering"] }}
dict={getDictionary("en")}
locale="en"
isOwner={false}
/>,
);

expect(html).toContain("Public Office");
expect(html).toContain("Engineering");
expect(html).not.toContain("No additional details yet");
expect(html.match(/<section/g)).toHaveLength(1);
});

it("renders a bio and links without an empty message", () => {
const html = renderToStaticMarkup(
<MemberProfileContent
profile={{ ...emptyProfile, bio: "Building public tools.", links: ["https://example.org"] }}
dict={getDictionary("en")}
locale="en"
isOwner={false}
/>,
);

expect(html).toContain("Building public tools.");
expect(html).toContain('href="https://example.org"');
expect(html).not.toContain("No additional details yet");
});
});
Loading