1
0
Fork 0

feat: add profile page with service record

- Add profile page at /profile/[username] showing user service record
- Update NavUser dropdown to link to Service Record instead of Upgrade to Pro
- Remove unused Billing menu item from user dropdown
This commit is contained in:
Jason Fraley 2026-07-31 20:03:03 -04:00
parent f645eb16e5
commit f16b76a198
2 changed files with 488 additions and 8 deletions

View file

@ -0,0 +1,481 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { User, Rank, Award, Media, Assignment, Qualification } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import {
ArrowLeftIcon,
ShieldIcon,
SwordsIcon,
UserIcon,
FileTextIcon,
CrosshairIcon,
AwardIcon,
SparkleIcon,
CalendarDaysIcon,
HashIcon,
SkullIcon,
TruckIcon,
PlaneIcon,
TargetIcon,
BadgeCheckIcon,
ShieldCheck,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
interface ProfilePageProps {
params: Promise<{ username: string }>;
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { username } = await params;
const payload = await getPayload({ config });
const profileRes = await payload
.find({
collection: "profiles",
where: { "user.username": { equals: username } },
depth: 2,
select: {
user: true,
rank: true,
dossier: true,
progression: {
awards: true,
qualifications: true,
xp: true,
scoreboard: true,
},
createdAt: true,
},
})
.catch(() => null);
if (!profileRes || profileRes.docs.length === 0) {
notFound();
}
const profile = profileRes.docs[0];
const user = profile.user as User;
const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).filter(
(a): a is Award => typeof a === "object" && a !== null,
);
const qualifications = (profile.progression?.qualifications ?? []).filter(
(q): q is Qualification => typeof q === "object" && q !== null,
);
const scoreboard = profile.progression?.scoreboard ?? {};
const xp = profile.progression?.xp ?? 0;
const experienceRes = await payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: { name: true, requiredPoints: true },
});
const xpLevel = resolveLevel(xp, experienceRes.docs);
const assignmentRes = await payload.find({
collection: "assignments",
where: { members: { equals: user.id } },
limit: 10,
select: { name: true, type: true, callsign: true },
});
const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
const rankInsignia =
typeof rank === "object" && rank.insignia && typeof rank.insignia === "object"
? (rank.insignia as Media)
: null;
const infantryKills = scoreboard.infantryKills ?? 0;
const vehicleKills = scoreboard.vehicleKills ?? 0;
const armorKills = scoreboard.armorKills ?? 0;
const airKills = scoreboard.airKills ?? 0;
const deaths = scoreboard.deaths ?? 0;
const totalKills = infantryKills + vehicleKills + armorKills + airKills;
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.createdAt);
const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
const now = new Date();
const serviceMonths =
(now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth());
const serviceYears = Math.floor(serviceMonths / 12);
const remainingMonths = serviceMonths % 12;
const serviceLength =
serviceYears > 0
? `${serviceYears} yr${serviceYears !== 1 ? "s" : ""} ${remainingMonths} mo`
: `${remainingMonths} mo`;
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back
</Link>
{/* ── Service Header ─────────────────────────────────── */}
<div className="flex items-start gap-5 border border-border rounded-lg p-5 bg-muted/30">
{rankInsignia?.url && (
<img src={rankInsignia.url} alt={rank.name} className="size-20 object-contain shrink-0" />
)}
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight uppercase">{user.displayName}</h1>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{rank.name}</span>
<span className="text-border">|</span>
<span className="uppercase tracking-wide text-xs font-semibold text-primary">
{xpLevel.levelName}
</span>
<span className="text-border">|</span>
<span className="font-mono text-xs">@{user.username}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden max-w-xs">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${Math.min(xpLevel.progress, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground">
{xp.toLocaleString()} XP
</span>
</div>
</div>
</div>
{/* ── Service Record ─────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Service Record
</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<CalendarDaysIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Enlisted</ItemTitle>
<ItemDescription>{enlistDateStr}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<CalendarDaysIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Time in Service</ItemTitle>
<ItemDescription>{serviceLength}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<HashIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Steam ID</ItemTitle>
<ItemDescription className="font-mono text-xs truncate">
{user.steamId}
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<UserIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Discord</ItemTitle>
<ItemDescription>{user.discordUsername}</ItemDescription>
</ItemContent>
</Item>
</div>
{assignments.length > 0 && (
<div className="flex flex-wrap gap-2">
{assignments.map((a) => (
<span
key={a.name}
className="text-xs font-mono bg-muted border border-border px-2.5 py-1 rounded-md"
>
{a.callsign ? `[${a.callsign}] ` : ""}
{a.name}
<span className="text-muted-foreground ml-1.5">({a.type})</span>
</span>
))}
</div>
)}
</section>
{/* ── Dossier ────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Personnel Dossier
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline">
<ItemHeader>
<ItemTitle>Personal Excerpt</ItemTitle>
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<p>{profile.dossier?.personalExcerpt || "No personal excerpt on file."}</p>
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle>Intelligence Record</ItemTitle>
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
{profile.dossier?.intelExcerpt ? (
<RichText data={profile.dossier.intelExcerpt} />
) : (
<p>No intelligence record on file.</p>
)}
</ItemContent>
</Item>
</div>
</section>
{/* ── Qualifications ─────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<BadgeCheckIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Qualifications
</h2>
</div>
{qualifications.length > 0 ? (
<div className="flex flex-wrap gap-2">
{qualifications.map((q) => (
<span
key={q.id}
className="text-xs font-medium bg-muted border border-border px-2.5 py-1 rounded-md"
>
{q.name}
</span>
))}
</div>
) : (
<Item variant="muted">
<ItemContent>
<p className="text-sm text-muted-foreground italic">No qualifications on file.</p>
</ItemContent>
</Item>
)}
</section>
{/* ── Combat Record ──────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Combat Record
</h2>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Category
</th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Count
</th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
XP Value
</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="px-4 py-2 flex items-center gap-2">
<TargetIcon className="size-3.5 text-muted-foreground" />
Infantry Kills
</td>
<td className="px-4 py-2 text-right font-mono">{infantryKills.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">
{(infantryKills * 10).toLocaleString()}
</td>
</tr>
<tr className="border-b border-border/50">
<td className="px-4 py-2 flex items-center gap-2">
<TruckIcon className="size-3.5 text-muted-foreground" />
Vehicle Kills
</td>
<td className="px-4 py-2 text-right font-mono">{vehicleKills.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">
{(vehicleKills * 100).toLocaleString()}
</td>
</tr>
<tr className="border-b border-border/50">
<td className="px-4 py-2 flex items-center gap-2">
<SwordsIcon className="size-3.5 text-muted-foreground" />
Armor Kills
</td>
<td className="px-4 py-2 text-right font-mono">{armorKills.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">
{(armorKills * 500).toLocaleString()}
</td>
</tr>
<tr className="border-b border-border/50">
<td className="px-4 py-2 flex items-center gap-2">
<PlaneIcon className="size-3.5 text-muted-foreground" />
Air Kills
</td>
<td className="px-4 py-2 text-right font-mono">{airKills.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">
{(airKills * 500).toLocaleString()}
</td>
</tr>
<tr className="border-b border-border/50 bg-muted/20">
<td className="px-4 py-2 flex items-center gap-2 font-medium">
<SkullIcon className="size-3.5 text-muted-foreground" />
Deaths
</td>
<td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">—</td>
</tr>
<tr className="bg-muted/30">
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold">
<SwordsIcon className="size-3.5 text-muted-foreground" />
K/D Ratio
</td>
<td className="px-4 py-2.5 text-right font-mono font-bold" colSpan={2}>
{kdRatio}
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* ── Awards & Decorations ───────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<AwardIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Awards &amp; Decorations
</h2>
</div>
{awards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{awards.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
return (
<Item key={award.id} variant="outline">
<ItemHeader>
<div className="flex items-center gap-2.5">
{awardImage?.url && (
<img
src={awardImage.url}
alt={award.name}
className="size-8 object-contain"
/>
)}
<div className="flex flex-col">
<ItemTitle>{award.name}</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{award.type ?? "commendation"}
</span>
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-3">
{award.description
? convertLexicalToPlaintext({ data: award.description }).slice(0, 120)
: "No description."}
</ItemDescription>
{award.experiencePoints != null && (
<span className="text-xs font-mono text-muted-foreground mt-1">
+{award.experiencePoints} XP
</span>
)}
</ItemContent>
</Item>
);
})}
</div>
) : (
<Item variant="muted">
<ItemContent>
<p className="text-sm text-muted-foreground italic">
This soldier has not yet received any awards or decorations.
</p>
</ItemContent>
</Item>
)}
</section>
{/* ── Progression ────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<SparkleIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Progression
</h2>
</div>
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex justify-between items-center w-full">
<span className="uppercase">{xpLevel.levelName}</span>
<span className="text-2xl font-bold font-mono">
{xp.toLocaleString()}
<span className="text-xs text-muted-foreground font-normal ml-1">XP</span>
</span>
</ItemTitle>
</ItemHeader>
<ItemContent className="flex flex-col gap-1.5">
<div className="h-2.5 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${Math.min(xpLevel.progress, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{xpLevel.currentLevelXP.toLocaleString()} XP</span>
{xpLevel.nextLevelXP !== null ? (
<span>
{Math.floor(xpLevel.progress)}% — {xpLevel.nextLevelXP.toLocaleString()} XP to
next level
</span>
) : (
<span>Max level reached</span>
)}
</div>
</ItemContent>
</Item>
</section>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from "lucide-react"; import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, FileTextIcon, LogOut, Sparkles } from "lucide-react";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
@ -72,9 +73,11 @@ export function NavUser({
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem asChild>
<Sparkles /> <Link href={`/profile/${user.username}`}>
Upgrade to Pro <FileTextIcon />
Service Record
</Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -83,10 +86,6 @@ export function NavUser({
<BadgeCheck /> <BadgeCheck />
Account Account
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<Bell /> <Bell />
Notifications Notifications