681 lines
26 KiB
TypeScript
681 lines
26 KiB
TypeScript
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<ReturnType<typeof getPayload>>;
|
|
|
|
async function findEligibleEvaluationMissions(
|
|
payload: Payload,
|
|
viewerId: number,
|
|
now: Date,
|
|
): Promise<readonly EvaluationMissionOption[]> {
|
|
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<readonly LatestMissionRow[]> {
|
|
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<number>();
|
|
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<readonly EvaluationMissionOption[]> = viewer
|
|
? findEligibleEvaluationMissions(payload, viewer.id, new Date())
|
|
: Promise.resolve([]);
|
|
const privilegedViewerPromise: Promise<boolean> = viewer
|
|
? isSuperuser(payload, viewer)
|
|
: Promise.resolve(false);
|
|
const directLeaderPromise: Promise<boolean> =
|
|
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<Assignment, "name" | "type" | "callsign">[];
|
|
|
|
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 (
|
|
<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>
|
|
<RibbonRack awards={awards} />
|
|
<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>
|
|
<ClientDate
|
|
date={profile.dossier.enlistmentDate}
|
|
options={{ year: "numeric", month: "short", day: "numeric" }}
|
|
/>
|
|
</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>
|
|
|
|
<LeadershipEvaluationSection
|
|
username={username}
|
|
summary={evaluationSummary}
|
|
summaryError={evaluationSummaryError}
|
|
eligibleMissions={eligibleMissions}
|
|
latestMissionRows={latestMissionRows}
|
|
canSubmitEvaluation={canSubmitEvaluation}
|
|
canViewSubordinate={canViewSubordinate}
|
|
/>
|
|
|
|
{/* ── 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>
|
|
{profile.dossier?.intelExcerpt ? (
|
|
<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>
|
|
) : (
|
|
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
|
|
<span>Undergoing review by the Intelligence Division</span>{" "}
|
|
<HourglassIcon 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>
|
|
|
|
{grantableAwards.length > 0 && (
|
|
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
|
|
)}
|
|
|
|
{/* ── 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 & Decorations
|
|
</h2>
|
|
</div>
|
|
{awards.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{[...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 <AwardCard key={award.id} awardImage={awardImage} award={award} />;
|
|
})}
|
|
</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>
|
|
);
|
|
}
|