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>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import { Award, Media } from "@/payload-types";
|
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
|
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { AwardIcon, LockIcon } from "lucide-react";
|
|
|
|
/**
|
|
* Card shown on the awards listing page. Unlike the profile's AwardCard
|
|
* (which wraps an individual's grant), this reviews a single definable award
|
|
* type — ribbon or medal — so users can inspect the award art and its
|
|
* full requirements. Commendations are intentionally out of scope.
|
|
*
|
|
* Ribbon art is rendered at a fixed, centered width so every ribbon (and its
|
|
* container) is an identical, justified size across the grid. Medal art is
|
|
* currently hidden; only the ribbon art is shown.
|
|
*/
|
|
interface AwardListingCardProps {
|
|
award: Award;
|
|
awardImage?: Media | null;
|
|
backgroundImage?: Media | null;
|
|
showGrantButton?: boolean;
|
|
onGrantClick?: () => void;
|
|
canGrant?: boolean | null;
|
|
remainingGrants?: number | null;
|
|
}
|
|
|
|
export function AwardListingCard({
|
|
award,
|
|
awardImage,
|
|
backgroundImage,
|
|
showGrantButton = false,
|
|
onGrantClick,
|
|
canGrant,
|
|
remainingGrants,
|
|
}: AwardListingCardProps) {
|
|
const type = award.type ?? "commendation";
|
|
const isMedal = type === "medal";
|
|
const hasRibbonArt =
|
|
type === "ribbon" &&
|
|
award.ribbonDesign &&
|
|
typeof award.ribbonDesign === "object" &&
|
|
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
|
|
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
|
|
const bgUrl = backgroundImage?.url;
|
|
|
|
return (
|
|
<Item
|
|
variant="outline"
|
|
className={cn(
|
|
"relative overflow-hidden min-w-0 h-full flex flex-col flex-nowrap text-center",
|
|
isMedal && "justify-center medal-card medal-card-overflow",
|
|
)}
|
|
>
|
|
{bgUrl && (
|
|
<>
|
|
<img
|
|
src={bgUrl}
|
|
alt=""
|
|
aria-hidden
|
|
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
|
|
/>
|
|
</>
|
|
)}
|
|
<ItemHeader
|
|
className={cn(
|
|
"relative z-10 flex flex-col items-center gap-2",
|
|
isMedal && "justify-center",
|
|
)}
|
|
>
|
|
{hasArt && (
|
|
<div
|
|
className={cn(
|
|
"flex w-full items-center justify-center",
|
|
hasRibbonArt ? "h-20" : "h-16",
|
|
)}
|
|
>
|
|
{hasRibbonArt ? (
|
|
<div className="w-44 max-w-full">
|
|
<RibbonViewer
|
|
ribbonDesign={award.ribbonDesign as RibbonDesign}
|
|
showRibbonOnly={false}
|
|
/>
|
|
</div>
|
|
) : awardImage?.url ? (
|
|
<img src={awardImage.url} alt={award.name} className="size-16 object-contain" />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<ItemTitle className={cn("mt-1", isMedal && "text-lg text-shadow-lg text-shadow-white/15")}>
|
|
{award.name}
|
|
</ItemTitle>
|
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
|
|
{type}
|
|
</span>
|
|
{award.advancement?.tier != null && (
|
|
<Badge variant="outline" className="w-fit text-xs text-muted-foreground">
|
|
Tier {award.advancement.tier}
|
|
</Badge>
|
|
)}
|
|
</ItemHeader>
|
|
|
|
<ItemContent className="relative z-10 flex flex-col gap-2">
|
|
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
|
|
{award.description
|
|
? convertLexicalToPlaintext({ data: award.description })
|
|
: "No description available."}
|
|
</ItemDescription>
|
|
{award.experiencePoints != null && (
|
|
<span className="mt-1 font-mono text-sm text-muted-foreground">
|
|
+{award.experiencePoints} XP
|
|
</span>
|
|
)}
|
|
</ItemContent>
|
|
|
|
{showGrantButton && (
|
|
<div className="relative z-10 mt-auto w-full">
|
|
{canGrant === false ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled
|
|
className="w-full opacity-50 cursor-not-allowed"
|
|
>
|
|
<LockIcon data-icon="inline-start" />
|
|
You cannot grant this award
|
|
</Button>
|
|
) : onGrantClick ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onGrantClick}
|
|
className="w-full flex flex-col gap-0 h-full py-1.5"
|
|
>
|
|
<span className="flex flex-row gap-1">
|
|
<AwardIcon data-icon="inline-start" />
|
|
Award this
|
|
</span>
|
|
<span className="opacity-50">
|
|
{remainingGrants === undefined
|
|
? ""
|
|
: remainingGrants === null
|
|
? ` (\u221E grant${remainingGrants === 1 ? "" : "s"} remaining)`
|
|
: ` (${remainingGrants} grant${remainingGrants === 1 ? "" : "s"} remaining)`}{" "}
|
|
</span>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</Item>
|
|
);
|
|
}
|