feat(awards): add awards listing page, grant permissions, and profile ribbon rack integration
This commit is contained in:
parent
e3e8b69a54
commit
24c5259056
15 changed files with 1653 additions and 9 deletions
159
src/app/(frontend)/awards/actions.ts
Normal file
159
src/app/(frontend)/awards/actions.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import {
|
||||||
|
countGrantsByGrantor,
|
||||||
|
findUserProfile,
|
||||||
|
getGrantorAssignments,
|
||||||
|
isRecipientEligible,
|
||||||
|
} from "@/lib/awards/grant";
|
||||||
|
import type { Profile } from "@/payload-types";
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPayloadUser(user)) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantAward(input: {
|
||||||
|
recipientUserId: number;
|
||||||
|
awardId: number;
|
||||||
|
reason?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
let award;
|
||||||
|
try {
|
||||||
|
award = await payload.findByID({
|
||||||
|
collection: "awards",
|
||||||
|
id: input.awardId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Award not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let grantorProfile: Profile | null = null;
|
||||||
|
const superuser = await isSuperuser(payload, user);
|
||||||
|
|
||||||
|
if (award.type === "medal") {
|
||||||
|
if (!superuser) {
|
||||||
|
return { success: false, error: "Only superusers can grant medals." };
|
||||||
|
}
|
||||||
|
} else if (!superuser) {
|
||||||
|
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
|
||||||
|
if (allowedTypes.length === 0) {
|
||||||
|
return { success: false, error: "This award cannot be granted by leaders." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantorAssignments = await getGrantorAssignments(
|
||||||
|
payload,
|
||||||
|
user.id as number,
|
||||||
|
allowedTypes,
|
||||||
|
);
|
||||||
|
if (grantorAssignments.length === 0) {
|
||||||
|
return { success: false, error: "You are not authorized to grant this award." };
|
||||||
|
}
|
||||||
|
const grantorAssignmentId = grantorAssignments[0].id;
|
||||||
|
|
||||||
|
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
|
||||||
|
if (maxGrants) {
|
||||||
|
grantorProfile = await findUserProfile(payload, user.id as number);
|
||||||
|
if (!grantorProfile) {
|
||||||
|
return { success: false, error: "You do not have a profile." };
|
||||||
|
}
|
||||||
|
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
||||||
|
if (issued >= maxGrants) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "You have reached the maximum number of grants for this award.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligible = await isRecipientEligible(
|
||||||
|
payload,
|
||||||
|
input.recipientUserId,
|
||||||
|
award.grantConfig?.recipientScope ?? "same_command",
|
||||||
|
grantorAssignmentId,
|
||||||
|
award.grantConfig?.eligibleRecipientAssignments,
|
||||||
|
);
|
||||||
|
if (!eligible) {
|
||||||
|
return { success: false, error: "Recipient is not eligible for this award." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientProfile = await findUserProfile(payload, input.recipientUserId);
|
||||||
|
if (!recipientProfile) {
|
||||||
|
return { success: false, error: "Recipient does not have a profile." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grantorProfile) {
|
||||||
|
grantorProfile = await findUserProfile(payload, user.id as number);
|
||||||
|
}
|
||||||
|
if (!grantorProfile) {
|
||||||
|
return { success: false, error: "You do not have a profile." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = recipientProfile.progression;
|
||||||
|
const currentAwards = progression?.awards ?? [];
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: recipientProfile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progression,
|
||||||
|
awards: [
|
||||||
|
...currentAwards,
|
||||||
|
{
|
||||||
|
award: award.id,
|
||||||
|
awardedBy: grantorProfile.id,
|
||||||
|
awardedAt: new Date().toISOString(),
|
||||||
|
awardReason: input.reason ?? null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.AwardGrant,
|
||||||
|
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
|
||||||
|
actor: user.id,
|
||||||
|
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/app/(frontend)/awards/grant-context.ts
Normal file
105
src/app/(frontend)/awards/grant-context.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { countGrantsByGrantor, findUserProfile, getGrantorAssignments } from "@/lib/awards/grant";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only grant context for a single award: whether the current user may
|
||||||
|
* grant it and how many grants (if limited) they have left. Mirrors the
|
||||||
|
* authorization logic in `grantAward` so the UI can warn before submitting.
|
||||||
|
*/
|
||||||
|
export interface GrantStats {
|
||||||
|
canGrant: boolean;
|
||||||
|
isSuperuser: boolean;
|
||||||
|
currentGrants: number;
|
||||||
|
maxGrants: number | null;
|
||||||
|
remainingGrants: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPayloadUser(user)) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGrantStats(awardId: number): Promise<ActionResult<GrantStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
let award;
|
||||||
|
try {
|
||||||
|
award = await payload.findByID({
|
||||||
|
collection: "awards",
|
||||||
|
id: awardId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Award not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const superuser = await isSuperuser(payload, user);
|
||||||
|
|
||||||
|
let canGrant = false;
|
||||||
|
if (superuser) {
|
||||||
|
canGrant = true;
|
||||||
|
} else if (award.type === "medal") {
|
||||||
|
canGrant = false;
|
||||||
|
} else {
|
||||||
|
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
|
||||||
|
if (allowedTypes.length > 0) {
|
||||||
|
const grantorAssignments = await getGrantorAssignments(
|
||||||
|
payload,
|
||||||
|
user.id as number,
|
||||||
|
allowedTypes,
|
||||||
|
);
|
||||||
|
canGrant = grantorAssignments.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentGrants = 0;
|
||||||
|
const maxGrants = superuser ? null : (award.grantConfig?.maxGrantsPerGrantor ?? null);
|
||||||
|
if (canGrant && maxGrants) {
|
||||||
|
const grantorProfile = await findUserProfile(payload, user.id as number);
|
||||||
|
if (grantorProfile) {
|
||||||
|
currentGrants = await countGrantsByGrantor(payload, awardId, grantorProfile.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
canGrant,
|
||||||
|
isSuperuser: superuser,
|
||||||
|
currentGrants,
|
||||||
|
maxGrants,
|
||||||
|
remainingGrants: maxGrants === null ? null : Math.max(maxGrants - currentGrants, 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/(frontend)/awards/page.tsx
Normal file
86
src/app/(frontend)/awards/page.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { Award, Media } from "@/payload-types";
|
||||||
|
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { MedalIcon } from "lucide-react";
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AwardsPage() {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: "awards",
|
||||||
|
where: {
|
||||||
|
type: { in: ["ribbon", "medal"] },
|
||||||
|
},
|
||||||
|
sort: "name",
|
||||||
|
limit: 100,
|
||||||
|
depth: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const awards: Award[] = res.docs;
|
||||||
|
const ribbons = awards.filter((a) => a.type === "ribbon");
|
||||||
|
const medals = awards.filter((a) => a.type === "medal");
|
||||||
|
|
||||||
|
const sections: { title: string; items: Award[] }[] = [];
|
||||||
|
if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals });
|
||||||
|
if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
sections.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((award) => {
|
||||||
|
const awardImage =
|
||||||
|
typeof award.image === "object" && award.image ? (award.image as Media) : null;
|
||||||
|
return (
|
||||||
|
<AwardWithGrantButton key={award.id} award={award} awardImage={awardImage} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,9 @@ import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
||||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
|
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import AwardCard from "@/components/frontend/account/AwardCard";
|
import AwardCard from "@/components/frontend/account/AwardCard";
|
||||||
|
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
|
||||||
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
||||||
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||||
|
|
@ -282,6 +284,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
});
|
});
|
||||||
const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
|
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 =
|
const rankInsignia =
|
||||||
typeof rank === "object" && rank.insignia && typeof rank.insignia === "object"
|
typeof rank === "object" && rank.insignia && typeof rank.insignia === "object"
|
||||||
? (rank.insignia as Media)
|
? (rank.insignia as Media)
|
||||||
|
|
@ -296,11 +308,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
|
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
|
||||||
|
|
||||||
const enlistDate = new Date(profile.dossier.enlistmentDate);
|
const enlistDate = new Date(profile.dossier.enlistmentDate);
|
||||||
const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const serviceMonths =
|
const serviceMonths =
|
||||||
(now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth());
|
(now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth());
|
||||||
|
|
@ -369,7 +376,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Enlisted</ItemTitle>
|
<ItemTitle>Enlisted</ItemTitle>
|
||||||
<ItemDescription>{enlistDateStr}</ItemDescription>
|
<ItemDescription>
|
||||||
|
<ClientDate
|
||||||
|
date={profile.dossier.enlistmentDate}
|
||||||
|
options={{ year: "numeric", month: "short", day: "numeric" }}
|
||||||
|
/>
|
||||||
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted" size="sm">
|
<Item variant="muted" size="sm">
|
||||||
|
|
@ -499,6 +511,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{grantableAwards.length > 0 && (
|
||||||
|
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Combat Record ──────────────────────────────────── */}
|
{/* ── Combat Record ──────────────────────────────────── */}
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
132
src/components/frontend/account/AwardListingCard.tsx
Normal file
132
src/components/frontend/account/AwardListingCard.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { Award, Media } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
|
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
|
||||||
|
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;
|
||||||
|
showGrantButton?: boolean;
|
||||||
|
onGrantClick?: () => void;
|
||||||
|
canGrant?: boolean | null;
|
||||||
|
remainingGrants?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AwardListingCard({
|
||||||
|
award,
|
||||||
|
awardImage,
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 h-full flex flex-col flex-nowrap text-center",
|
||||||
|
isMedal && "justify-center medal-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemHeader className={cn("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>
|
||||||
|
</ItemHeader>
|
||||||
|
|
||||||
|
<ItemContent className="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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/frontend/account/AwardPickerDialog.tsx
Normal file
120
src/components/frontend/account/AwardPickerDialog.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Award } from "@/payload-types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
|
||||||
|
import { RibbonViewer } from "./RibbonViewer";
|
||||||
|
import { GrantAwardDialog } from "./GrantAwardDialog";
|
||||||
|
import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface AwardPickerDialogProps {
|
||||||
|
awards: Award[];
|
||||||
|
recipientUserId: number;
|
||||||
|
recipientUsername: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onGranted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid of grantable awards. Selecting a card closes this dialog and opens the
|
||||||
|
* GrantAwardDialog for that award with the recipient pre-filled.
|
||||||
|
*/
|
||||||
|
export function AwardPickerDialog({
|
||||||
|
awards,
|
||||||
|
recipientUserId,
|
||||||
|
recipientUsername,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onGranted,
|
||||||
|
}: AwardPickerDialogProps) {
|
||||||
|
const [selectedAward, setSelectedAward] = useState<Award | null>(null);
|
||||||
|
|
||||||
|
function pick(award: Award) {
|
||||||
|
setSelectedAward(award);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setSelectedAward(null);
|
||||||
|
onOpenChange(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select an award</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose which award to grant to @{recipientUsername}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{awards.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No awards are available to grant.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid max-h-[50vh] grid-cols-1 gap-3 overflow-y-auto sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{awards.map((award) => (
|
||||||
|
<button
|
||||||
|
key={award.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(award)}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-accent"
|
||||||
|
>
|
||||||
|
{award.type === "ribbon" && award.ribbonDesign ? (
|
||||||
|
<div className="flex h-16 w-full items-center justify-center">
|
||||||
|
<div className="w-32 max-w-full">
|
||||||
|
<RibbonViewer ribbonDesign={award.ribbonDesign as RibbonDesign} showRibbonOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="flex size-16 items-center justify-center rounded-md bg-muted">
|
||||||
|
{award.type === "medal" ? (
|
||||||
|
<MedalIcon className="size-6 text-muted-foreground" />
|
||||||
|
) : award.type === "commendation" ? (
|
||||||
|
<ScrollTextIcon className="size-6 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<RibbonIcon className="size-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="line-clamp-2 text-center text-sm font-medium">
|
||||||
|
{award.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="w-fit capitalize">
|
||||||
|
{award.type ?? "award"}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{selectedAward && (
|
||||||
|
<GrantAwardDialog
|
||||||
|
awardId={selectedAward.id}
|
||||||
|
awardName={selectedAward.name}
|
||||||
|
recipientUserId={recipientUserId}
|
||||||
|
recipientUsername={recipientUsername}
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setSelectedAward(null);
|
||||||
|
}}
|
||||||
|
onGranted={onGranted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/frontend/account/AwardWithGrantButton.tsx
Normal file
59
src/components/frontend/account/AwardWithGrantButton.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Award, Media } from "@/payload-types";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
import { AwardListingCard } from "./AwardListingCard";
|
||||||
|
import { GrantAwardDialog } from "./GrantAwardDialog";
|
||||||
|
import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
|
||||||
|
|
||||||
|
interface AwardWithGrantButtonProps {
|
||||||
|
award: Award;
|
||||||
|
awardImage?: Media | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [canGrant, setCanGrant] = useState<boolean | null>(null);
|
||||||
|
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getGrantStats(award.id).then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setCanGrant(res.data.canGrant);
|
||||||
|
setRemainingGrants(res.data.remainingGrants);
|
||||||
|
} else {
|
||||||
|
setCanGrant(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [award.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AwardListingCard
|
||||||
|
award={award}
|
||||||
|
awardImage={awardImage}
|
||||||
|
showGrantButton
|
||||||
|
onGrantClick={() => setOpen(true)}
|
||||||
|
canGrant={canGrant}
|
||||||
|
remainingGrants={remainingGrants}
|
||||||
|
/>
|
||||||
|
<GrantAwardDialog
|
||||||
|
awardId={award.id}
|
||||||
|
awardName={award.name}
|
||||||
|
awardDescription={convertLexicalToPlaintext({ data: award.description })}
|
||||||
|
showRecipientPicker
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onGranted={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/frontend/account/GrantAwardDialog.tsx
Normal file
263
src/components/frontend/account/GrantAwardDialog.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { grantAward } from "@/app/(frontend)/awards/actions";
|
||||||
|
import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
|
||||||
|
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
|
||||||
|
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface GrantAwardDialogProps {
|
||||||
|
awardId: number;
|
||||||
|
awardName: string;
|
||||||
|
awardDescription?: string;
|
||||||
|
recipientUserId?: number;
|
||||||
|
recipientUsername?: string;
|
||||||
|
showRecipientPicker?: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onGranted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
|
||||||
|
* recipient's @username. Grant limits are pre-checked via getGrantStats so
|
||||||
|
* users see warnings before submitting.
|
||||||
|
*/
|
||||||
|
export function GrantAwardDialog({
|
||||||
|
awardId,
|
||||||
|
awardName,
|
||||||
|
awardDescription,
|
||||||
|
recipientUserId,
|
||||||
|
recipientUsername,
|
||||||
|
showRecipientPicker = false,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onGranted,
|
||||||
|
}: GrantAwardDialogProps) {
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [stats, setStats] = useState<GrantStats | null>(null);
|
||||||
|
const [statsError, setStatsError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep(1);
|
||||||
|
setReason("");
|
||||||
|
setConfirmText("");
|
||||||
|
setStats(null);
|
||||||
|
setStatsError(null);
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(false);
|
||||||
|
setRecipient(
|
||||||
|
!showRecipientPicker && recipientUserId != null && recipientUsername
|
||||||
|
? { id: recipientUserId, username: recipientUsername }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
|
||||||
|
|
||||||
|
// Load grant context for the award while the dialog is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getGrantStats(awardId).then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) setStats(res.data);
|
||||||
|
else setStatsError(res.error ?? "Failed to load grant stats.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, awardId]);
|
||||||
|
|
||||||
|
const expectedConfirm = recipient ? `@${recipient.username}` : null;
|
||||||
|
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
|
||||||
|
const limited = stats?.remainingGrants != null;
|
||||||
|
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
|
||||||
|
|
||||||
|
async function handleGrant() {
|
||||||
|
if (!recipient || !confirmMatches || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await grantAward({
|
||||||
|
recipientUserId: recipient.id,
|
||||||
|
awardId,
|
||||||
|
reason: reason.trim() ? reason.trim() : undefined,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("Award granted", {
|
||||||
|
description: `${awardName} granted to @${recipient.username}.`,
|
||||||
|
});
|
||||||
|
onGranted?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setError(res.error ?? "Failed to grant award.");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!submitting) onOpenChange(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Grant {awardName}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{showRecipientPicker
|
||||||
|
? "Select a recipient, then confirm the grant."
|
||||||
|
: `Granting to @${recipientUsername}.`}
|
||||||
|
{stats && stats.canGrant && (
|
||||||
|
<span className="block mt-1 text-xs opacity-50">
|
||||||
|
{stats.remainingGrants === null
|
||||||
|
? "Unlimited grants available."
|
||||||
|
: `${stats.remainingGrants} grant${stats.remainingGrants === 1 ? "" : "s"} remaining.`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{awardDescription && (
|
||||||
|
<p className="text-sm text-muted-foreground">{awardDescription}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRecipientPicker ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Recipient</Label>
|
||||||
|
<RecipientPicker
|
||||||
|
selectedUser={recipient}
|
||||||
|
onSelect={setRecipient}
|
||||||
|
onClear={() => setRecipient(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">
|
||||||
|
Recipient: <span className="font-medium">@{recipientUsername}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Reason (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Citation or reason for the award..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statsError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
|
<AlertTitle>Could not verify grant limits</AlertTitle>
|
||||||
|
<AlertDescription>{statsError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && !stats.canGrant && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
|
<AlertTitle>You are not authorized to grant this award</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Your current role and assignments do not allow you to grant this award.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && stats.canGrant && lowRemaining && (
|
||||||
|
<Alert>
|
||||||
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
|
<AlertTitle>Limited award</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You have {stats.remainingGrants} grant
|
||||||
|
{(stats.remainingGrants ?? 0) === 1 ? "" : "s"} remaining for this award.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
disabled={!recipient || (stats !== null && !stats.canGrant)}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Alert>
|
||||||
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
|
<AlertTitle>Confirm grant</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
|
||||||
|
be undone.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{limited && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{(stats?.remainingGrants ?? 0) <= 1
|
||||||
|
? "This will use your LAST grant for this award."
|
||||||
|
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Type @{recipient?.username} to confirm</Label>
|
||||||
|
<Input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder={`@${recipient?.username}`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStep(1)} disabled={submitting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleGrant} disabled={!confirmMatches || submitting}>
|
||||||
|
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||||
|
Confirm grant
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/frontend/account/MedalViewer.tsx
Normal file
214
src/components/frontend/account/MedalViewer.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-safe SVG renderer for a medal's art. Mirrors the admin
|
||||||
|
* `MedalDesigner` preview so the award listing page can show medal art
|
||||||
|
* without pulling in Payload admin UI dependencies. Self-contained — the
|
||||||
|
* shape/finish constants intentionally duplicate the admin module so this
|
||||||
|
* file stays client- and server-safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MedalShape = "circle" | "star" | "cross" | "shield";
|
||||||
|
export type MetalFinish = "gold" | "silver" | "bronze" | "copper" | "darkSteel";
|
||||||
|
|
||||||
|
export interface MedalDesign {
|
||||||
|
ribbonStripes: { color: string; width: number }[];
|
||||||
|
shape: MedalShape;
|
||||||
|
finish: MetalFinish;
|
||||||
|
engraving?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINISH_INFO: Record<MetalFinish, { colors: string[] }> = {
|
||||||
|
gold: { colors: ["#fbbf24", "#ca8a04", "#92400e"] },
|
||||||
|
silver: { colors: ["#e5e7eb", "#9ca3af", "#6b7280"] },
|
||||||
|
bronze: { colors: ["#d97706", "#92400e", "#78350f"] },
|
||||||
|
copper: { colors: ["#f97316", "#c2410c", "#7c2d12"] },
|
||||||
|
darkSteel: { colors: ["#6b7280", "#4b5563", "#374151"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RIBBON_WIDTH = 366;
|
||||||
|
|
||||||
|
function isMedalDesign(value: unknown): value is MedalDesign {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
Array.isArray((value as MedalDesign).ribbonStripes) &&
|
||||||
|
((value as MedalDesign).ribbonStripes as unknown[]).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MedalBody({
|
||||||
|
shape,
|
||||||
|
finish,
|
||||||
|
engraving,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
}: {
|
||||||
|
shape: MedalShape;
|
||||||
|
finish: MetalFinish;
|
||||||
|
engraving: string;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
}) {
|
||||||
|
const finishColors = FINISH_INFO[finish].colors;
|
||||||
|
const gradId = `medal-grad-${finish}`;
|
||||||
|
const r = 40;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={gradId} cx="40%" cy="35%">
|
||||||
|
<stop offset="0%" stopColor={finishColors[0]} />
|
||||||
|
<stop offset="60%" stopColor={finishColors[1]} />
|
||||||
|
<stop offset="100%" stopColor={finishColors[2]} />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{shape === "circle" && (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill={`url(#${gradId})`}
|
||||||
|
stroke={finishColors[2]}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape === "star" && (
|
||||||
|
<polygon
|
||||||
|
points={Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const angle = Math.PI / 2 + (i * Math.PI) / 5;
|
||||||
|
const radius = i % 2 === 0 ? r : r * 0.45;
|
||||||
|
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
|
||||||
|
}).join(" ")}
|
||||||
|
fill={`url(#${gradId})`}
|
||||||
|
stroke={finishColors[2]}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape === "cross" && (
|
||||||
|
<path
|
||||||
|
d={`M${cx - 12},${cy - r} L${cx + 12},${cy - r} L${cx + 12},${cy - 12}
|
||||||
|
L${cx + r},${cy - 12} L${cx + r},${cy + 12} L${cx + 12},${cy + 12}
|
||||||
|
L${cx + 12},${cy + r} L${cx - 12},${cy + r} L${cx - 12},${cy + 12}
|
||||||
|
L${cx - r},${cy + 12} L${cx - r},${cy - 12} L${cx - 12},${cy - 12}Z`}
|
||||||
|
fill={`url(#${gradId})`}
|
||||||
|
stroke={finishColors[2]}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape === "shield" && (
|
||||||
|
<path
|
||||||
|
d={`M${cx},${cy - r}
|
||||||
|
Q${cx + r},${cy - r * 0.6} ${cx + r * 0.9},${cy - r * 0.1}
|
||||||
|
L${cx},${cy + r}
|
||||||
|
L${cx - r * 0.9},${cy - r * 0.1}
|
||||||
|
Q${cx - r},${cy - r * 0.6} ${cx},${cy - r}Z`}
|
||||||
|
fill={`url(#${gradId})`}
|
||||||
|
stroke={finishColors[2]}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{engraving && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={finishColors[2]}
|
||||||
|
fontSize={engraving.length > 10 ? 6 : 8}
|
||||||
|
fontFamily="serif"
|
||||||
|
fontWeight="bold"
|
||||||
|
letterSpacing={0.5}
|
||||||
|
>
|
||||||
|
{engraving.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedalViewer({
|
||||||
|
medalDesign,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
medalDesign: MedalDesign | unknown;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const design = isMedalDesign(medalDesign) ? medalDesign : null;
|
||||||
|
if (!design) return null;
|
||||||
|
|
||||||
|
const { ribbonStripes, shape, finish, engraving = "" } = design;
|
||||||
|
const totalWeight = ribbonStripes.reduce((s, stripe) => s + stripe.width, 0);
|
||||||
|
const viewWidth = RIBBON_WIDTH;
|
||||||
|
|
||||||
|
const ribbonTop = 14;
|
||||||
|
const ribbonBottom = 72;
|
||||||
|
const convergeRatio = 0.35;
|
||||||
|
const medalCx = viewWidth / 2;
|
||||||
|
const medalCy = 120;
|
||||||
|
const ribbonViewWidth = RIBBON_WIDTH;
|
||||||
|
const offset = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${viewWidth} 175`}
|
||||||
|
className={className ?? "w-full h-auto rounded border border-zinc-700 bg-zinc-950"}
|
||||||
|
style={{ maxHeight: 220, ...style }}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const topStartX = offset;
|
||||||
|
const bottomCenter = medalCx;
|
||||||
|
const topWidth = ribbonViewWidth;
|
||||||
|
const bottomWidth = topWidth * convergeRatio;
|
||||||
|
|
||||||
|
let topX = topStartX;
|
||||||
|
return ribbonStripes.map((stripe, i) => {
|
||||||
|
const stripeW = (stripe.width / totalWeight) * ribbonViewWidth;
|
||||||
|
const fracStart = (topX - topStartX) / topWidth;
|
||||||
|
const fracEnd = (topX + stripeW - topStartX) / topWidth;
|
||||||
|
|
||||||
|
const tlx = topX;
|
||||||
|
const trx = topX + stripeW;
|
||||||
|
const blx = bottomCenter - bottomWidth / 2 + fracStart * bottomWidth;
|
||||||
|
const brx = bottomCenter - bottomWidth / 2 + fracEnd * bottomWidth;
|
||||||
|
|
||||||
|
topX += stripeW;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<polygon
|
||||||
|
key={i}
|
||||||
|
points={`${tlx},${ribbonTop} ${trx},${ribbonTop} ${brx},${ribbonBottom} ${blx},${ribbonBottom}`}
|
||||||
|
fill={stripe.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Small connector tab between ribbon and medal */}
|
||||||
|
<rect
|
||||||
|
x={medalCx - 4}
|
||||||
|
y={ribbonBottom}
|
||||||
|
width={8}
|
||||||
|
height={8}
|
||||||
|
rx={2}
|
||||||
|
fill="#9ca3af"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Medal body */}
|
||||||
|
<MedalBody
|
||||||
|
shape={shape}
|
||||||
|
finish={finish}
|
||||||
|
engraving={engraving}
|
||||||
|
cx={medalCx}
|
||||||
|
cy={medalCy}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/frontend/account/ProfileGrantAwardButton.tsx
Normal file
49
src/components/frontend/account/ProfileGrantAwardButton.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Award } from "@/payload-types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AwardPickerDialog } from "./AwardPickerDialog";
|
||||||
|
import { AwardIcon, PlusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface ProfileGrantAwardButtonProps {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
awards: Award[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile-page section that lets viewers grant an award to the profile owner.
|
||||||
|
* Eligibility is enforced server-side (getGrantStats / grantAward inside
|
||||||
|
* GrantAwardDialog) — this component only provides the entry point.
|
||||||
|
*/
|
||||||
|
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<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">
|
||||||
|
Grant Award
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Grant Award
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AwardPickerDialog
|
||||||
|
awards={awards}
|
||||||
|
recipientUserId={userId}
|
||||||
|
recipientUsername={username}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onGranted={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/components/frontend/account/RecipientPicker.tsx
Normal file
254
src/components/frontend/account/RecipientPicker.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Assignment, Media, Profile } from "@/payload-types";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface RecipientSelection {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
profileId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipientPickerProps {
|
||||||
|
onSelect: (user: RecipientSelection) => void;
|
||||||
|
selectedUser: RecipientSelection | null;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult extends RecipientSelection {
|
||||||
|
rankName?: string | null;
|
||||||
|
insigniaUrl?: string | null;
|
||||||
|
assignmentLabel?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsOf(username: string): string {
|
||||||
|
return username.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable recipient picker. Queries the profiles REST API (users have no
|
||||||
|
* `profile` relationship, so searching /api/users would not surface rank
|
||||||
|
* data) and enriches each hit with the user's primary assignment.
|
||||||
|
*/
|
||||||
|
export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPickerProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchSeq = useRef(0);
|
||||||
|
|
||||||
|
// Debounced search with a sequence guard so stale responses are dropped.
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length < 1) {
|
||||||
|
setResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++searchSeq.current;
|
||||||
|
setSearching(true);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const q = encodeURIComponent(trimmed);
|
||||||
|
const url = `/api/profiles?where[or][0][user.username][like]=${q}&where[or][1][user.displayName][like]=${q}&limit=10&depth=2`;
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("Search failed");
|
||||||
|
const data = (await res.json()) as { docs?: unknown[] };
|
||||||
|
|
||||||
|
const mapped: SearchResult[] = [];
|
||||||
|
for (const doc of data.docs ?? []) {
|
||||||
|
const profile = doc as Profile;
|
||||||
|
const userDoc = profile.user;
|
||||||
|
if (typeof userDoc !== "object" || !userDoc) continue;
|
||||||
|
const rank = typeof profile.rank === "object" && profile.rank ? profile.rank : null;
|
||||||
|
mapped.push({
|
||||||
|
id: userDoc.id,
|
||||||
|
username: userDoc.username,
|
||||||
|
displayName: userDoc.displayName,
|
||||||
|
profileId: profile.id,
|
||||||
|
rankName: rank?.name ?? null,
|
||||||
|
insigniaUrl:
|
||||||
|
rank && typeof rank.insignia === "object" && rank.insignia
|
||||||
|
? (rank.insignia as Media).url
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with each user's primary assignment (best-effort).
|
||||||
|
const assignmentResults = await Promise.allSettled(
|
||||||
|
mapped.map((r) =>
|
||||||
|
fetch(`/api/assignments?where[members][equals]=${r.id}&limit=1&depth=0`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((json) => {
|
||||||
|
const doc = (json as { docs?: unknown[] } | null)?.docs?.[0];
|
||||||
|
if (!doc || typeof doc !== "object") return null;
|
||||||
|
const assignment = doc as Assignment;
|
||||||
|
return assignment.callsign ? `[${assignment.callsign}] ${assignment.name}` : assignment.name;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assignmentResults.forEach((res, i) => {
|
||||||
|
if (res.status === "fulfilled" && res.value) mapped[i].assignmentLabel = res.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seq !== searchSeq.current) return;
|
||||||
|
setResults(mapped);
|
||||||
|
setActiveIndex(0);
|
||||||
|
} catch {
|
||||||
|
if (seq === searchSeq.current) {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq === searchSeq.current) setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Close the dropdown on outside click.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handlePointerDown(e: MouseEvent) {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function select(result: SearchResult) {
|
||||||
|
onSelect({
|
||||||
|
id: result.id,
|
||||||
|
username: result.username,
|
||||||
|
displayName: result.displayName,
|
||||||
|
profileId: result.profileId,
|
||||||
|
});
|
||||||
|
setQuery("");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||||
|
<Avatar className="size-7">
|
||||||
|
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedUser.displayName || selectedUser.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear recipient"
|
||||||
|
onClick={onClear}
|
||||||
|
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open || results.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const active = results[activeIndex];
|
||||||
|
if (active) select(active);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search by username or display name..."
|
||||||
|
className="pl-8"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="recipient-picker-results"
|
||||||
|
/>
|
||||||
|
{open && query.trim().length > 0 && (
|
||||||
|
<div
|
||||||
|
id="recipient-picker-results"
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
|
||||||
|
) : (
|
||||||
|
results.map((result, i) => (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => select(result)}
|
||||||
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 px-3 py-2 text-left",
|
||||||
|
i === activeIndex && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="size-8">
|
||||||
|
{result.insigniaUrl ? (
|
||||||
|
<AvatarImage src={result.insigniaUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{result.displayName || result.username}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
@{result.username}
|
||||||
|
{result.rankName ? ` · ${result.rankName}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{result.assignmentLabel && (
|
||||||
|
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{result.assignmentLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintex
|
||||||
|
|
||||||
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
|
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row w-52 gap-1">
|
<div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
|
||||||
{awards
|
{awards
|
||||||
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
|
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
|
||||||
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
|
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
|
||||||
|
|
@ -14,7 +14,7 @@ export const RibbonRack = ({ awards }: { awards: Award[] }) => {
|
||||||
const ribbon = award.ribbonDesign as RibbonDesign;
|
const ribbon = award.ribbonDesign as RibbonDesign;
|
||||||
return (
|
return (
|
||||||
<Tooltip key={key}>
|
<Tooltip key={key}>
|
||||||
<TooltipTrigger className="w-full max-h-6.75 hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
|
<TooltipTrigger className="w-[6.375rem] max-w-full h-[1.6875rem] hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
|
||||||
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
|
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
|
Award,
|
||||||
Backpack,
|
Backpack,
|
||||||
Bolt,
|
Bolt,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
|
@ -134,6 +135,11 @@ const data = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navPersonal: [
|
navPersonal: [
|
||||||
|
{
|
||||||
|
title: "Awards",
|
||||||
|
url: "/awards",
|
||||||
|
icon: Award,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Locker",
|
title: "Locker",
|
||||||
url: "/locker",
|
url: "/locker",
|
||||||
|
|
|
||||||
178
src/lib/awards/grant.ts
Normal file
178
src/lib/awards/grant.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { Assignment, Profile } from "@/payload-types";
|
||||||
|
|
||||||
|
type AssignmentType = Assignment["type"];
|
||||||
|
type RecipientScope = "same_command" | "whitelist" | "anyone";
|
||||||
|
|
||||||
|
const MAX_TREE_DEPTH = 5;
|
||||||
|
|
||||||
|
/** Extract the numeric id from a relationship reference (raw id or populated doc). */
|
||||||
|
function refId(ref: number | { id?: unknown } | null | undefined): number | null {
|
||||||
|
if (typeof ref === "number") return ref;
|
||||||
|
if (ref && typeof ref.id === "number") return ref.id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assignments where the given user is the leader and whose type is in
|
||||||
|
* `allowedTypes`. An empty `allowedTypes` means no leader may grant.
|
||||||
|
*/
|
||||||
|
export async function getGrantorAssignments(
|
||||||
|
payload: Payload,
|
||||||
|
userId: number,
|
||||||
|
allowedTypes: readonly AssignmentType[],
|
||||||
|
): Promise<Assignment[]> {
|
||||||
|
if (allowedTypes.length === 0) return [];
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: "assignments",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ leader: { equals: userId } },
|
||||||
|
{ type: { in: [...allowedTypes] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return res.docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMembers(
|
||||||
|
payload: Payload,
|
||||||
|
assignmentId: number,
|
||||||
|
depth: number,
|
||||||
|
collected: Set<number>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (depth > MAX_TREE_DEPTH) return;
|
||||||
|
let assignment: Assignment | null = null;
|
||||||
|
try {
|
||||||
|
assignment = await payload.findByID({
|
||||||
|
collection: "assignments",
|
||||||
|
id: assignmentId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assignment) return;
|
||||||
|
|
||||||
|
const leaderId = refId(assignment.leader);
|
||||||
|
if (leaderId !== null) collected.add(leaderId);
|
||||||
|
for (const member of assignment.members ?? []) {
|
||||||
|
const id = refId(member);
|
||||||
|
if (id !== null) collected.add(id);
|
||||||
|
}
|
||||||
|
for (const sub of assignment.subAssignments ?? []) {
|
||||||
|
const id = refId(sub);
|
||||||
|
if (id !== null) await collectMembers(payload, id, depth + 1, collected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All member user ids under an assignment: its leader, its direct members,
|
||||||
|
* and (recursively) the members of its sub-assignments. Depth-capped to
|
||||||
|
* prevent infinite loops on circular sub-assignment graphs.
|
||||||
|
*/
|
||||||
|
export async function getAssignmentTreeMembers(
|
||||||
|
payload: Payload,
|
||||||
|
assignmentId: number,
|
||||||
|
depth = 0,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const collected = new Set<number>();
|
||||||
|
await collectMembers(payload, assignmentId, depth, collected);
|
||||||
|
return [...collected];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times `grantorProfileId` has already issued `awardId` across all
|
||||||
|
* profiles. Scans every profile (depth 0) and counts matching grant rows.
|
||||||
|
*/
|
||||||
|
export async function countGrantsByGrantor(
|
||||||
|
payload: Payload,
|
||||||
|
awardId: number,
|
||||||
|
grantorProfileId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
for (const profile of res.docs) {
|
||||||
|
for (const entry of profile.progression?.awards ?? []) {
|
||||||
|
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDirectMember(
|
||||||
|
payload: Payload,
|
||||||
|
assignmentId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let assignment: Assignment | null = null;
|
||||||
|
try {
|
||||||
|
assignment = await payload.findByID({
|
||||||
|
collection: "assignments",
|
||||||
|
id: assignmentId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!assignment) return false;
|
||||||
|
if (refId(assignment.leader) === userId) return true;
|
||||||
|
return (assignment.members ?? []).some((m) => refId(m) === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `recipientUserId` may receive an award under the given scope.
|
||||||
|
* - anyone: always eligible
|
||||||
|
* - whitelist: a direct member of any assignment in `whitelistIds`
|
||||||
|
* - same_command: a member of the grantor's assignment tree
|
||||||
|
*/
|
||||||
|
export async function isRecipientEligible(
|
||||||
|
payload: Payload,
|
||||||
|
recipientUserId: number,
|
||||||
|
scope: RecipientScope | null | undefined,
|
||||||
|
grantorAssignmentId?: number,
|
||||||
|
whitelistIds?: (number | Assignment)[] | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!scope || scope === "anyone") return true;
|
||||||
|
|
||||||
|
if (scope === "whitelist") {
|
||||||
|
const ids = (whitelistIds ?? [])
|
||||||
|
.map((ref) => refId(ref))
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
for (const assignmentId of ids) {
|
||||||
|
if (await isDirectMember(payload, assignmentId, recipientUserId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grantorAssignmentId) return false;
|
||||||
|
const members = await getAssignmentTreeMembers(payload, grantorAssignmentId);
|
||||||
|
return members.includes(recipientUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The profile belonging to `userId`, or null if none exists. */
|
||||||
|
export async function findUserProfile(
|
||||||
|
payload: Payload,
|
||||||
|
userId: number,
|
||||||
|
): Promise<Profile | null> {
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: userId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return res.docs[0] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,9 @@ export const EventTypes = {
|
||||||
// Evaluations
|
// Evaluations
|
||||||
EvaluationSubmit: "evaluation:submit",
|
EvaluationSubmit: "evaluation:submit",
|
||||||
|
|
||||||
|
// Awards
|
||||||
|
AwardGrant: "award:grant",
|
||||||
|
|
||||||
// Missions
|
// Missions
|
||||||
MissionAutoComplete: "mission:auto-complete",
|
MissionAutoComplete: "mission:auto-complete",
|
||||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue