From f16b76a1986fb55e3d2e7d90a6c922ab06b15eda Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 31 Jul 2026 20:03:03 -0400 Subject: [PATCH] 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 --- .../(frontend)/profile/[username]/page.tsx | 481 ++++++++++++++++++ src/components/frontend/blocks/NavUser.tsx | 15 +- 2 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 src/app/(frontend)/profile/[username]/page.tsx diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx new file mode 100644 index 0000000..de2f992 --- /dev/null +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -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[]; + + 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 ( +
+ + + Back + + + {/* ── Service Header ─────────────────────────────────── */} +
+ {rankInsignia?.url && ( + {rank.name} + )} +
+
+

{user.displayName}

+
+
+ {rank.name} + | + + {xpLevel.levelName} + + | + @{user.username} +
+
+
+
+
+ + {xp.toLocaleString()} XP + +
+
+
+ + {/* ── Service Record ─────────────────────────────────── */} +
+
+ +

+ Service Record +

+
+
+ + + + + + Enlisted + {enlistDateStr} + + + + + + + + Time in Service + {serviceLength} + + + + + + + + Steam ID + + {user.steamId} + + + + + + + + + Discord + {user.discordUsername} + + +
+ + {assignments.length > 0 && ( +
+ {assignments.map((a) => ( + + {a.callsign ? `[${a.callsign}] ` : ""} + {a.name} + ({a.type}) + + ))} +
+ )} +
+ + {/* ── Dossier ────────────────────────────────────────── */} +
+
+ +

+ Personnel Dossier +

+
+
+ + + Personal Excerpt + + +

{profile.dossier?.personalExcerpt || "No personal excerpt on file."}

+
+
+ + + Intelligence Record +
+ Verified by the Intelligence Division +
+
+ + {profile.dossier?.intelExcerpt ? ( + + ) : ( +

No intelligence record on file.

+ )} +
+
+
+
+ + {/* ── Qualifications ─────────────────────────────────── */} +
+
+ +

+ Qualifications +

+
+ {qualifications.length > 0 ? ( +
+ {qualifications.map((q) => ( + + {q.name} + + ))} +
+ ) : ( + + +

No qualifications on file.

+
+
+ )} +
+ + {/* ── Combat Record ──────────────────────────────────── */} +
+
+ +

+ Combat Record +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Category + + Count + + XP Value +
+ + Infantry Kills + {infantryKills.toLocaleString()} + {(infantryKills * 10).toLocaleString()} +
+ + Vehicle Kills + {vehicleKills.toLocaleString()} + {(vehicleKills * 100).toLocaleString()} +
+ + Armor Kills + {armorKills.toLocaleString()} + {(armorKills * 500).toLocaleString()} +
+ + Air Kills + {airKills.toLocaleString()} + {(airKills * 500).toLocaleString()} +
+ + Deaths + {deaths.toLocaleString()}—
+ + K/D Ratio + + {kdRatio} +
+
+
+ + {/* ── Awards & Decorations ───────────────────────────── */} +
+
+ +

+ Awards & Decorations +

+
+ {awards.length > 0 ? ( +
+ {awards.map((award) => { + const awardImage = + award.image && typeof award.image === "object" ? (award.image as Media) : null; + return ( + + +
+ {awardImage?.url && ( + {award.name} + )} +
+ {award.name} + + {award.type ?? "commendation"} + +
+
+
+ + + {award.description + ? convertLexicalToPlaintext({ data: award.description }).slice(0, 120) + : "No description."} + + {award.experiencePoints != null && ( + + +{award.experiencePoints} XP + + )} + +
+ ); + })} +
+ ) : ( + + +

+ This soldier has not yet received any awards or decorations. +

+
+
+ )} +
+ + {/* ── Progression ────────────────────────────────────── */} +
+
+ +

+ Progression +

+
+ + + + {xpLevel.levelName} + + {xp.toLocaleString()} + XP + + + + +
+
+
+
+ {xpLevel.currentLevelXP.toLocaleString()} XP + {xpLevel.nextLevelXP !== null ? ( + + {Math.floor(xpLevel.progress)}% — {xpLevel.nextLevelXP.toLocaleString()} XP to + next level + + ) : ( + Max level reached + )} +
+ + +
+
+ ); +} diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx index b2c78d4..028a0ca 100644 --- a/src/components/frontend/blocks/NavUser.tsx +++ b/src/components/frontend/blocks/NavUser.tsx @@ -1,6 +1,7 @@ "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 { @@ -72,9 +73,11 @@ export function NavUser({ - - - Upgrade to Pro + + + + Service Record + @@ -83,10 +86,6 @@ export function NavUser({ Account - - - Billing - Notifications