1
0
Fork 0
polaris-task-force/src/app/(frontend)/awards/page.tsx
Z8MB1E 19113e39c9 feat(awards): add award advancement series
Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:37 -04:00

219 lines
7.7 KiB
TypeScript

import config from "@payload-config";
import { getPayload } from "payload";
import Link from "next/link";
import { headers as nextHeaders } from "next/headers";
import type { Award, Media } from "@/payload-types";
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import { MedalIcon } from "lucide-react";
import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata = {
title: "Awards",
description:
"Browse every medal and ribbon that can be granted to a member of Polaris Task Force.",
};
const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
ribbon: "Ribbons",
medal: "Medals",
};
type AwardCatalogItem =
| { readonly kind: "award"; readonly award: Award }
| {
readonly kind: "advancement";
readonly seriesKey: string;
readonly familyName: string;
};
function buildRibbonCatalogItems(ribbons: Award[]): AwardCatalogItem[] {
const awardsBySeries = new Map<string, Award[]>();
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null) continue;
const seriesAwards = awardsBySeries.get(seriesKey);
if (seriesAwards) seriesAwards.push(award);
else awardsBySeries.set(seriesKey, [award]);
}
const groupedSeriesKeys = new Set(
[...awardsBySeries.entries()]
.filter(([, seriesAwards]) => seriesAwards.length > 1)
.map(([seriesKey]) => seriesKey),
);
const emittedSeriesKeys = new Set<string>();
const items: AwardCatalogItem[] = [];
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !groupedSeriesKeys.has(seriesKey)) {
items.push({ kind: "award", award });
continue;
}
if (emittedSeriesKeys.has(seriesKey)) continue;
const seriesAwards = [...(awardsBySeries.get(seriesKey) ?? [])].sort((first, second) => {
const tierDifference =
(first.advancement?.tier ?? Number.POSITIVE_INFINITY) -
(second.advancement?.tier ?? Number.POSITIVE_INFINITY);
if (tierDifference !== 0) return tierDifference;
if (first.name !== second.name) return first.name < second.name ? -1 : 1;
return first.id - second.id;
});
const familyAward =
seriesAwards.find((seriesAward) => seriesAward.advancement?.tier === 1) ?? seriesAwards[0];
if (!familyAward) continue;
items.push({
kind: "advancement",
seriesKey,
familyName: familyAward.name,
});
for (const seriesAward of seriesAwards) {
items.push({ kind: "award", award: seriesAward });
}
emittedSeriesKeys.add(seriesKey);
}
return items;
}
export default async function AwardsPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const canReview = hasRoles(["admin", "developer"], user);
const res = await payload.find({
collection: "awards",
where: {
type: { in: ["ribbon", "medal"] },
},
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const pendingCount = canReview
? (
await payload.count({
collection: "ribbon-submissions",
where: { status: { equals: "pending" } },
overrideAccess: true,
})
).totalDocs
: 0;
const awards: Award[] = res.docs;
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
const catalogSections: { title: string; items: AwardCatalogItem[] }[] = [];
if (medals.length > 0) {
catalogSections.push({
title: TYPE_LABEL.medal,
items: medals.map((award) => ({ kind: "award", award })),
});
}
if (ribbons.length > 0) {
catalogSections.push({
title: TYPE_LABEL.ribbon,
items: buildRibbonCatalogItems(ribbons),
});
}
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
</div>
<p className="text-sm text-muted-foreground max-w-2xl">
A reference of every medal and ribbon that can be earned by a member of Polaris Task
Force. Review the requirements for each award and its artwork. Commendations are granted
on the fly and are not catalogued here.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm">
<Link href="/awards/design">Design a ribbon</Link>
</Button>
{canReview && (
<Button asChild size="sm" variant="outline">
<Link href="/awards/review">Review queue ({pendingCount})</Link>
</Button>
)}
</div>
</div>
{awards.length === 0 ? (
<Item variant="muted">
<ItemHeader>
<ItemTitle>No awards defined</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No medals or ribbons have been catalogued yet. Check back soon.
</p>
</ItemContent>
</Item>
) : (
catalogSections.map((section) => (
<section key={section.title} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{section.items.map((item) => {
switch (item.kind) {
case "advancement":
return (
<div
key={`advancement-${item.seriesKey}`}
className="col-span-full flex items-center gap-2 border-b border-border pb-1 text-xs font-medium text-muted-foreground"
>
<span className="shrink-0">Advancement: {item.familyName}</span>
<div className="h-px flex-1 bg-border" aria-hidden />
</div>
);
case "award": {
const { award } = item;
const awardImage =
typeof award.image === "object" && award.image
? (award.image as Media)
: null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
}
default: {
const exhaustiveItem: never = item;
return exhaustiveItem;
}
}
})}
</div>
</section>
))
)}
</div>
);
}