import config from "@payload-config"; import { getPayload } from "payload"; import { notFound } from "next/navigation"; import { headers as nextHeaders } from "next/headers"; import Link from "next/link"; import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import { RichText } from "@payloadcms/richtext-lexical/react"; import { ClientDate } from "@/components/frontend/ClientDate"; import { resolveLevel } from "@/utils/xp/resolveLevel"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations"; import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels"; import { getEvaluationSummary } from "./actions"; import { ArrowLeftIcon, AwardIcon, BadgeCheckIcon, CalendarDaysIcon, CrosshairIcon, FileTextIcon, HashIcon, HourglassIcon, PlaneIcon, ShieldCheck, ShieldIcon, SkullIcon, SparkleIcon, SwordsIcon, TargetIcon, TruckIcon, UserIcon, } from "lucide-react"; import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle, } from "@/components/ui/item"; import AwardCard from "@/components/frontend/account/AwardCard"; import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton"; import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; import type { EvaluationMissionOption, EvaluationSummary, LatestMissionRow, } from "@/components/frontend/profile/evaluation-types"; interface ProfilePageProps { params: Promise<{ username: string }>; } type Payload = Awaited>; async function findEligibleEvaluationMissions( payload: Payload, viewerId: number, now: Date, ): Promise { const attendanceRes = await payload.find({ collection: "mission-attendances", where: { and: [{ user: { equals: viewerId } }, { response: { equals: "yes" } }], }, limit: 100, depth: 0, select: { mission: true }, overrideAccess: true, }); const missionIds = [ ...new Set( attendanceRes.docs.map((attendance) => typeof attendance.mission === "number" ? attendance.mission : attendance.mission.id, ), ), ]; if (missionIds.length === 0) return []; const missionRes = await payload.find({ collection: "missions", where: { id: { in: missionIds } }, limit: missionIds.length, depth: 0, select: { name: true, classification: { startDateTime: true }, ownershipAndStatus: { status: true }, }, overrideAccess: true, }); return [...missionRes.docs] .filter((mission) => isMissionEvaluable( mission.ownershipAndStatus.status, mission.classification.startDateTime, now, ), ) .sort( (first, second) => new Date(second.classification.startDateTime).getTime() - new Date(first.classification.startDateTime).getTime(), ) .map((mission) => ({ id: mission.id, name: mission.name, startDateTime: mission.classification.startDateTime, })); } async function findLatestLeaderMissionRows( payload: Payload, rateeId: number, ): Promise { const evaluationRes = await payload.find({ collection: "evaluations", where: { and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }], }, sort: "-updatedAt", limit: 10, depth: 1, select: { mission: true, level: true, updatedAt: true }, overrideAccess: true, }); const missionIds = new Set(); const rows: LatestMissionRow[] = []; for (const evaluation of evaluationRes.docs) { if (typeof evaluation.mission === "number") continue; const level = PERFORMANCE_LEVELS.find((candidate) => candidate.id === evaluation.level); if (!level || missionIds.has(evaluation.mission.id)) continue; missionIds.add(evaluation.mission.id); rows.push({ missionId: evaluation.mission.id, missionName: evaluation.mission.name, startDateTime: evaluation.mission.classification.startDateTime, level: { id: level.id, label: getLeadershipDisplayLabel(level), score: level.score, color: level.color, }, }); if (rows.length === 5) break; } return rows; } export default async function ProfilePage({ params }: ProfilePageProps) { const { username } = await params; const payload = await getPayload({ config }); const { user: authUser } = await payload.auth({ headers: await nextHeaders(), canSetHeaders: false, }); const viewer = isPayloadUser(authUser) ? authUser : null; 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, minigames: true, }, createdAt: true, }, }) .catch(() => null); if (!profileRes || profileRes.docs.length === 0) { notFound(); } const profile = profileRes.docs[0]; const user = profile.user as User; const isProfileOwner = viewer?.id === user.id; const isLegacyPrivileged = viewer?.roles?.some((role) => role === "admin" || role === "developer") === true; const evaluationSummaryPromise = viewer ? getEvaluationSummary(username) : Promise.resolve(null); const eligibleMissionsPromise: Promise = viewer ? findEligibleEvaluationMissions(payload, viewer.id, new Date()) : Promise.resolve([]); const privilegedViewerPromise: Promise = viewer ? isSuperuser(payload, viewer) : Promise.resolve(false); const directLeaderPromise: Promise = viewer && !isProfileOwner ? isDirectLeaderOf(payload, viewer.id, user.id) : Promise.resolve(false); const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] = await Promise.all([ evaluationSummaryPromise, eligibleMissionsPromise, privilegedViewerPromise, directLeaderPromise, ]); const canViewSubordinate = isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader; const latestMissionRows = evaluationSummaryResult?.success && evaluationSummaryResult.data.leader.visible ? await findLatestLeaderMissionRows(payload, user.id) : []; const evaluationSummary: EvaluationSummary | null = evaluationSummaryResult?.success ? { leader: { totalRaters: evaluationSummaryResult.data.leader.totalRaters, visible: evaluationSummaryResult.data.leader.visible, averageScore: evaluationSummaryResult.data.leader.averageScore, levels: evaluationSummaryResult.data.leader.levels?.map((level) => ({ id: level.id, label: level.label, score: level.score, color: level.color, count: level.count, percentage: level.percentage, })) ?? null, }, subordinate: canViewSubordinate && evaluationSummaryResult.data.subordinate ? { level: { id: evaluationSummaryResult.data.subordinate.level.id, label: evaluationSummaryResult.data.subordinate.level.label, score: evaluationSummaryResult.data.subordinate.level.score, color: evaluationSummaryResult.data.subordinate.level.color, }, missionId: evaluationSummaryResult.data.subordinate.missionId, at: evaluationSummaryResult.data.subordinate.at, } : null, } : null; const evaluationSummaryError = evaluationSummaryResult && !evaluationSummaryResult.success ? evaluationSummaryResult.error : null; const canSubmitEvaluation = viewer !== null && !isProfileOwner; const rank = profile.rank as Rank; const awards = (profile.progression?.awards ?? []).flatMap((entry) => { if (typeof entry !== "object" || entry === null) return []; const award = entry.award; return typeof award === "object" && award !== null ? [award] : []; }); 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, description: 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 grantableAwardsRes = await payload.find({ collection: "awards", where: { type: { in: ["ribbon", "commendation"] } }, sort: "name", limit: 100, depth: 1, overrideAccess: true, }); const grantableAwards = grantableAwardsRes.docs as Award[]; 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.dossier.enlistmentDate); 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 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 {profile.dossier?.intelExcerpt ? (
Verified by the Intelligence Division
) : (
Undergoing review 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.

)}
{grantableAwards.length > 0 && ( )} {/* ── 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] .sort((a, b) => { const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const; const categoryOrder = (typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) - (typeOrder[b.type as keyof typeof typeOrder] ?? typeOrder.commendation); return categoryOrder || (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0); }) .map((award) => { const awardImage = award.image && typeof award.image === "object" ? (award.image as Media) : null; return ; })}
) : (

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