From 6169bf6ce678d668b49f2d59b005c3be416ff109 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 11 Sep 2026 03:25:59 -0400 Subject: [PATCH] feat(awards): add ribbon design studio, review queue and submission UI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/awards/design/actions.ts | 251 +++++++++++++++ src/app/(frontend)/awards/design/page.tsx | 48 +++ src/app/(frontend)/awards/page.tsx | 52 ++- src/app/(frontend)/awards/review/page.tsx | 51 +++ .../admin/awards/RibbonDesigner.tsx | 301 +----------------- src/components/frontend/account/AwardCard.tsx | 2 +- .../frontend/account/AwardListingCard.tsx | 2 +- .../frontend/account/AwardPickerDialog.tsx | 2 +- .../frontend/account/RibbonRack.tsx | 2 +- .../frontend/account/RibbonViewer.tsx | 4 +- .../frontend/awards/MySubmissions.tsx | 128 ++++++++ .../frontend/awards/RejectRibbonDialog.tsx | 110 +++++++ .../frontend/awards/ReviewQueue.tsx | 105 ++++++ .../frontend/awards/RibbonReviewDialogs.tsx | 244 ++++++++++++++ .../frontend/awards/RibbonStudio.tsx | 142 +++++++++ 15 files changed, 1142 insertions(+), 302 deletions(-) create mode 100644 src/app/(frontend)/awards/design/actions.ts create mode 100644 src/app/(frontend)/awards/design/page.tsx create mode 100644 src/app/(frontend)/awards/review/page.tsx create mode 100644 src/components/frontend/awards/MySubmissions.tsx create mode 100644 src/components/frontend/awards/RejectRibbonDialog.tsx create mode 100644 src/components/frontend/awards/ReviewQueue.tsx create mode 100644 src/components/frontend/awards/RibbonReviewDialogs.tsx create mode 100644 src/components/frontend/awards/RibbonStudio.tsx diff --git a/src/app/(frontend)/awards/design/actions.ts b/src/app/(frontend)/awards/design/actions.ts new file mode 100644 index 0000000..b32d583 --- /dev/null +++ b/src/app/(frontend)/awards/design/actions.ts @@ -0,0 +1,251 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { validateRibbonDesign } from "@/lib/awards/ribbonValidation"; +import type { RibbonSubmission } from "@/payload-types"; + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +const MAX_PENDING_RIBBON_SUBMISSIONS = 3; + +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 }; +} + +async function fetchSubmission( + payload: Awaited>, + id: number, +): Promise { + try { + return await payload.findByID({ + collection: "ribbon-submissions", + id, + depth: 0, + overrideAccess: true, + }); + } catch { + return null; + } +} + +export async function submitRibbonDesign(input: { + name: string; + description: string; + design: Record; +}): Promise> { + try { + const { payload, user } = await authenticate(); + + const trimmedName = input.name.trim(); + if (trimmedName.length === 0) { + return { success: false, error: "A ribbon name is required." }; + } + if (trimmedName.length > 60) { + return { success: false, error: "Ribbon names must be 60 characters or fewer." }; + } + + const trimmedDescription = input.description.trim(); + if (trimmedDescription.length === 0) { + return { success: false, error: "A ribbon description is required." }; + } + if (trimmedDescription.length > 300) { + return { success: false, error: "Ribbon descriptions must be 300 characters or fewer." }; + } + + const validation = validateRibbonDesign(input.design); + if (!validation.ok) { + return { success: false, error: validation.error }; + } + + const pendingCount = await payload.count({ + collection: "ribbon-submissions", + where: { + and: [ + { user: { equals: user.id } }, + { status: { equals: "pending" } }, + ], + }, + overrideAccess: true, + }); + if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) { + return { success: false, error: "You already have 3 pending ribbon submissions. Wait for them to be reviewed first." }; + } + + const submission = await payload.create({ + collection: "ribbon-submissions", + data: { + name: trimmedName, + description: trimmedDescription, + design: input.design, + user: user.id, + status: "pending", + }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.AwardRibbonSubmitted, + message: `${user.displayName ?? user.username} submitted the ribbon design "${trimmedName}"`, + actor: user.id, + targetCollection: "ribbon-submissions", + targetId: submission.id, + data: { + submissionId: submission.id, + submitterId: user.id, + name: trimmedName, + }, + }); + + return { success: true, data: { id: submission.id } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function withdrawRibbonSubmission(submissionId: number): Promise> { + try { + const { payload, user } = await authenticate(); + + const submission = await fetchSubmission(payload, submissionId); + if (!submission) { + return { success: false, error: "Submission not found." }; + } + if (submission.user !== user.id) { + return { success: false, error: "You can only withdraw your own submissions." }; + } + if (submission.status !== "pending") { + return { success: false, error: "Only pending submissions can be withdrawn." }; + } + + await payload.delete({ + collection: "ribbon-submissions", + id: submissionId, + overrideAccess: true, + }); + + return { success: true, data: { ok: true } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function reviewRibbonSubmission( + submissionId: number, + decision: "approved" | "rejected", + opts?: { + awardXp?: number; + grantorTypes?: ("division" | "squad" | "team" | "special")[]; + recipientScope?: "same_command" | "anyone"; + maxGrantsPerGrantor?: number | null; + finalName?: string; + reviewNote?: string; + }, +): Promise> { + try { + const { payload, user } = await authenticate(); + + if (!hasRoles(["admin", "developer"], user)) { + return { success: false, error: "Only admins and developers can review ribbon submissions." }; + } + + const submission = await fetchSubmission(payload, submissionId); + if (!submission) { + return { success: false, error: "Submission not found." }; + } + if (submission.status !== "pending") { + return { success: false, error: "This submission has already been reviewed." }; + } + + if (decision === "approved") { + if (opts?.awardXp === undefined || !Number.isInteger(opts.awardXp) || opts.awardXp < 1) { + return { success: false, error: "XP must be at least 1." }; + } + if (!opts?.grantorTypes || !Array.isArray(opts.grantorTypes) || opts.grantorTypes.length === 0) { + return { success: false, error: "Choose at least one grantor type." }; + } + const validGrantorTypes = ["division", "squad", "team", "special"]; + for (const grantorType of opts.grantorTypes) { + if (!validGrantorTypes.includes(grantorType)) { + return { success: false, error: "Invalid grantor type." }; + } + } + if (opts.recipientScope !== undefined && opts.recipientScope !== "same_command" && opts.recipientScope !== "anyone") { + return { success: false, error: "Whitelist scoping is not available yet." }; + } + } + + const updateData: Record = { + status: decision, + reviewNote: opts?.reviewNote ?? null, + }; + + if (decision === "approved") { + updateData.awardXp = opts?.awardXp; + updateData.grantorTypes = opts?.grantorTypes; + updateData.recipientScope = opts?.recipientScope ?? "same_command"; + updateData.maxGrantsPerGrantor = opts?.maxGrantsPerGrantor ?? null; + if (opts?.finalName && opts.finalName.trim() !== "" && opts.finalName.trim() !== submission.name) { + updateData.name = opts.finalName.trim(); + } + } + + await payload.update({ + collection: "ribbon-submissions", + id: submissionId, + data: updateData, + overrideAccess: true, + depth: 0, + }); + + // Check if the afterChange hook reverted the status back to pending due to award creation failure + const updatedSubmission = await payload.findByID({ + collection: "ribbon-submissions", + id: submissionId, + depth: 0, + overrideAccess: true, + }); + + if (decision === "approved" && updatedSubmission.status === "pending") { + return { success: false, error: "Approval failed while creating the award. The submission is back to pending; try again." }; + } + + return { success: true, data: { id: submissionId, status: decision } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} \ No newline at end of file diff --git a/src/app/(frontend)/awards/design/page.tsx b/src/app/(frontend)/awards/design/page.tsx new file mode 100644 index 0000000..6c35983 --- /dev/null +++ b/src/app/(frontend)/awards/design/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { getPayload } from "payload"; + +import config from "@payload-config"; +import { MySubmissions } from "@/components/frontend/awards/MySubmissions"; +import { RibbonStudio } from "@/components/frontend/awards/RibbonStudio"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; + +export const metadata: Metadata = { + title: "Ribbon Studio: Polaris Task Force", + description: "Design a custom ribbon and submit it for approval.", +}; + +export default async function RibbonDesignPage() { + const headers = await nextHeaders(); + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + if (!isPayloadUser(user)) { + redirect(`/login?returnTo=${encodeURIComponent("/awards/design")}`); + } + + const submissions = await payload.find({ + collection: "ribbon-submissions", + where: { user: { equals: user.id } }, + sort: "-createdAt", + depth: 1, + }); + + return ( +
+
+

Ribbon Studio

+

+ Design a custom ribbon and submit it for approval. Approved ribbons become awardable. +

+
+ +
+ + +
+
+ ); +} diff --git a/src/app/(frontend)/awards/page.tsx b/src/app/(frontend)/awards/page.tsx index ad8bb16..0db306a 100644 --- a/src/app/(frontend)/awards/page.tsx +++ b/src/app/(frontend)/awards/page.tsx @@ -1,9 +1,14 @@ 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", @@ -17,7 +22,12 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = { }; export default async function AwardsPage() { - const payload = await getPayload({ config }); + 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", @@ -30,6 +40,16 @@ export default async function AwardsPage() { 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"); @@ -40,16 +60,28 @@ export default async function AwardsPage() { return (
-
-
- -

Awards

+
+
+
+ +

Awards

+
+

+ 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. +

+
+
+ + {canReview && ( + + )}
-

- 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. -

{awards.length === 0 ? ( diff --git a/src/app/(frontend)/awards/review/page.tsx b/src/app/(frontend)/awards/review/page.tsx new file mode 100644 index 0000000..496b0e9 --- /dev/null +++ b/src/app/(frontend)/awards/review/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { getPayload } from "payload"; + +import config from "@payload-config"; +import { ReviewQueue } from "@/components/frontend/awards/ReviewQueue"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import hasRoles from "@/utils/access-control/hasRoles"; + +export const metadata: Metadata = { + title: "Ribbon Review", + description: "Review submitted ribbon designs for Polaris Task Force awards.", +}; + +export default async function RibbonReviewPage() { + const headers = await nextHeaders(); + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + if (!isPayloadUser(user)) { + redirect(`/login?returnTo=${encodeURIComponent("/awards/review")}`); + } + + if (!hasRoles(["admin", "developer"], user)) { + redirect("/awards"); + } + + const submissions = await payload.find({ + collection: "ribbon-submissions", + where: { status: { equals: "pending" } }, + sort: "-createdAt", + depth: 1, + overrideAccess: true, + }); + + return ( +
+
+

Ribbon Review

+

+ Approve or reject submitted ribbon designs. Approval settings decide XP and who can grant + the award. +

+
+ + +
+ ); +} diff --git a/src/components/admin/awards/RibbonDesigner.tsx b/src/components/admin/awards/RibbonDesigner.tsx index ad964e4..86bac4f 100644 --- a/src/components/admin/awards/RibbonDesigner.tsx +++ b/src/components/admin/awards/RibbonDesigner.tsx @@ -3,293 +3,22 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useField } from "@payloadcms/ui"; -// ─── Types ────────────────────────────────────────────────────────── +import { DEFAULT_RIBBON } from "@/components/awards/ribbonDesign/constants"; +import { RibbonPreview, StripeEditor, DeviceEditor } from "@/components/awards/ribbonDesign/parts"; +import type { RibbonDesign, Stripe, Device } from "@/components/awards/ribbonDesign/types"; -type DeviceType = "star" | "oakLeaf" | "arrowUp" | "campaignClasp"; - -type Device = { - id: string; - type: DeviceType; - position: number; - positionY: number; -}; - -type Stripe = { - color: string; - width: number; -}; - -export type RibbonDesign = { - stripes: Stripe[]; - devices: Device[]; - mirrored: boolean; -}; - -// ─── Constants ────────────────────────────────────────────────────── - -export const DEFAULT_RIBBON: RibbonDesign = { - stripes: [ - { color: "#b91c1c", width: 30 }, - { color: "#f5f5f4", width: 5 }, - { color: "#1e3a5f", width: 30 }, - { color: "#f5f5f4", width: 5 }, - { color: "#b91c1c", width: 30 }, - ], - devices: [], - mirrored: false, -}; - -const MILITARY_COLORS: { name: string; hex: string }[] = [ - { name: "Scarlet", hex: "#b91c1c" }, - { name: "Crimson", hex: "#991b1b" }, - { name: "Red", hex: "#dc2626" }, - { name: "Dark Blue", hex: "#1e3a5f" }, - { name: "Navy", hex: "#1e293b" }, - { name: "Air Force Blue", hex: "#5b8fa8" }, - { name: "Sky Blue", hex: "#38bdf8" }, - { name: "Gold", hex: "#ca8a04" }, - { name: "Yellow", hex: "#eab308" }, - { name: "Forest Green", hex: "#166534" }, - { name: "Green", hex: "#16a34a" }, - { name: "Olive", hex: "#4d7c0f" }, - { name: "Maroon", hex: "#581c87" }, - { name: "Purple", hex: "#7e22ce" }, - { name: "Orange", hex: "#ea580c" }, - { name: "Brown", hex: "#78350f" }, - { name: "Tan / Khaki", hex: "#d4a056" }, - { name: "White", hex: "#f5f5f4" }, - { name: "Silver / Gray", hex: "#9ca3af" }, - { name: "Black", hex: "#18181b" }, -]; - -export const RIBBON_DEVICE_INFO: Record = { - star: { label: "Star", symbol: "\u2605" }, - oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" }, - arrowUp: { label: "Arrow", symbol: "\u2191" }, - campaignClasp: { label: "Clasp", symbol: "\u2015" }, -}; - -export const RIBBON_CONSTANTS = { - RIBBON_ASPECT: 3.667, - RIBBON_WIDTH: 366, - PREVIEW_HEIGHT: 80, -}; - -const { RIBBON_ASPECT, RIBBON_WIDTH, PREVIEW_HEIGHT } = RIBBON_CONSTANTS; - -// ─── Preview ──────────────────────────────────────────────────────── - -function RibbonPreview({ design }: { design: RibbonDesign }) { - const { stripes, devices, mirrored } = design; - if (stripes.length === 0) return null; - - const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes; - - const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0); - const viewWidth = RIBBON_WIDTH; - const viewHeight = PREVIEW_HEIGHT; - - return ( - - - {(() => { - let x = 0; - return displayStripes.map((stripe, i) => { - const w = (stripe.width / totalWeight) * viewWidth; - const el = ; - x += w; - return el; - }); - })()} - - {devices.map((device) => { - const info = RIBBON_DEVICE_INFO[device.type]; - const cx = (device.position / 100) * viewWidth; - const cy = (device.positionY / 100) * viewHeight; - const fontSize = Math.min(viewHeight * 0.45, 24); - return ( - - {info.symbol} - - ); - })} - - ); -} - -// ─── Stripe Editor ────────────────────────────────────────────────── - -function StripeEditor({ - stripes, - onChange, -}: { - stripes: Stripe[]; - onChange: (s: Stripe[]) => void; -}) { - const totalWeight = stripes.reduce((sum, s) => sum + s.width, 0); - const addStripe = () => onChange([...stripes, { color: "#f5f5f4", width: 10 }]); - const removeStripe = (idx: number) => onChange(stripes.filter((_, i) => i !== idx)); - const updateStripe = (idx: number, patch: Partial) => - onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s))); - - return ( -
-
- Stripes - -
- {stripes.map((stripe, idx) => ( -
- updateStripe(idx, { color: e.target.value })} - className="size-7 rounded cursor-pointer bg-transparent border-0 p-0" - /> - updateStripe(idx, { color: e.target.value })} - className="text-[11px] font-mono bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-20 text-zinc-300" - /> -
- - updateStripe(idx, { width: Number(e.target.value) })} - className="flex-1 h-1 accent-zinc-400" - /> - - {totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}% - -
-
- -
- ))} -
- ); -} - -// ─── Device Editor ────────────────────────────────────────────────── - -function DeviceEditor({ - devices, - onChange, -}: { - devices: Device[]; - onChange: (d: Device[]) => void; -}) { - const addDevice = (type: DeviceType) => - onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]); - const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id)); - const updateDevice = (id: string, patch: Partial) => - onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d))); - - return ( -
- Devices -
- {(Object.keys(RIBBON_DEVICE_INFO) as DeviceType[]).map((type) => { - const info = RIBBON_DEVICE_INFO[type]; - return ( - - ); - })} -
- {devices.length > 0 && ( -
- {devices.map((device) => { - const info = RIBBON_DEVICE_INFO[device.type]; - return ( -
- {info.symbol} - {info.label} -
- - updateDevice(device.id, { position: Number(e.target.value) })} - className="flex-1 h-1 accent-zinc-400" - /> - - {device.position} - -
-
- - updateDevice(device.id, { positionY: Number(e.target.value) })} - className="flex-1 h-1 accent-zinc-400" - /> - - {device.positionY} - -
- -
- ); - })} -
- )} -
- ); -} +export type { + RibbonDesign, + Stripe, + Device, + DeviceType, +} from "@/components/awards/ribbonDesign/types"; +export { + DEFAULT_RIBBON, + MILITARY_COLORS, + RIBBON_CONSTANTS, + RIBBON_DEVICE_INFO, +} from "@/components/awards/ribbonDesign/constants"; // ─── Main Designer ────────────────────────────────────────────────── diff --git a/src/components/frontend/account/AwardCard.tsx b/src/components/frontend/account/AwardCard.tsx index 0eee1df..a3fba4d 100644 --- a/src/components/frontend/account/AwardCard.tsx +++ b/src/components/frontend/account/AwardCard.tsx @@ -2,7 +2,7 @@ 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 type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; import { cn } from "@/lib/utils"; const getExperiencePointsClass = (experiencePoints: number) => { diff --git a/src/components/frontend/account/AwardListingCard.tsx b/src/components/frontend/account/AwardListingCard.tsx index 39b9a87..d90f56f 100644 --- a/src/components/frontend/account/AwardListingCard.tsx +++ b/src/components/frontend/account/AwardListingCard.tsx @@ -2,7 +2,7 @@ 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 type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { AwardIcon, LockIcon } from "lucide-react"; diff --git a/src/components/frontend/account/AwardPickerDialog.tsx b/src/components/frontend/account/AwardPickerDialog.tsx index b6e2eb0..e878170 100644 --- a/src/components/frontend/account/AwardPickerDialog.tsx +++ b/src/components/frontend/account/AwardPickerDialog.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner"; +import type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; import { RibbonViewer } from "./RibbonViewer"; import { GrantAwardDialog } from "./GrantAwardDialog"; import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react"; diff --git a/src/components/frontend/account/RibbonRack.tsx b/src/components/frontend/account/RibbonRack.tsx index f920a00..e48b2d5 100644 --- a/src/components/frontend/account/RibbonRack.tsx +++ b/src/components/frontend/account/RibbonRack.tsx @@ -1,4 +1,4 @@ -import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner"; +import type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; import { RibbonViewer } from "@/components/frontend/account/RibbonViewer"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Award } from "@/payload-types"; diff --git a/src/components/frontend/account/RibbonViewer.tsx b/src/components/frontend/account/RibbonViewer.tsx index 5b8af4e..ace2798 100644 --- a/src/components/frontend/account/RibbonViewer.tsx +++ b/src/components/frontend/account/RibbonViewer.tsx @@ -3,9 +3,9 @@ import { RIBBON_DEVICE_INFO, RIBBON_CONSTANTS, - RibbonDesign, DEFAULT_RIBBON, -} from "@/components/admin/awards/RibbonDesigner"; +} from "@/components/awards/ribbonDesign/constants"; +import type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; import { useState } from "react"; import { cn } from "@/lib/utils"; diff --git a/src/components/frontend/awards/MySubmissions.tsx b/src/components/frontend/awards/MySubmissions.tsx new file mode 100644 index 0000000..c89ef24 --- /dev/null +++ b/src/components/frontend/awards/MySubmissions.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { withdrawRibbonSubmission } from "@/app/(frontend)/awards/design/actions"; +import type { RibbonDesign } from "@/components/awards/ribbonDesign/types"; +import { RibbonViewer } from "@/components/frontend/account/RibbonViewer"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import type { RibbonSubmission } from "@/payload-types"; + +interface MySubmissionsProps { + readonly submissions: RibbonSubmission[]; +} + +const statusClasses: Record = { + pending: "border-amber-500/40 text-amber-400", + approved: "border-emerald-500/40 text-emerald-400", + rejected: "border-red-500/40 text-red-400", +}; + +function formatDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export function MySubmissions({ submissions }: MySubmissionsProps) { + const router = useRouter(); + const [withdrawingId, setWithdrawingId] = useState(null); + + async function handleWithdraw(submissionId: number) { + if (!window.confirm("Withdraw this submission?")) return; + + setWithdrawingId(submissionId); + + try { + const result = await withdrawRibbonSubmission(submissionId); + if (!result.success) { + toast.error(result.error ?? "Could not withdraw this submission."); + return; + } + + toast.success("Ribbon submission withdrawn"); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not withdraw this submission."); + } finally { + setWithdrawingId(null); + } + } + + return ( + + + My submissions + Track the ribbons you have sent for review. + + + {submissions.length === 0 ? ( +

+ No submissions yet. Design your first ribbon above. +

+ ) : ( + submissions.map((submission) => ( +
+
+
+
+ +
+
+

{submission.name}

+ +
+
+ + {submission.status} + +
+ +

{submission.description}

+ + {submission.reviewNote && ( +

+ Reviewer note: {submission.reviewNote} +

+ )} + +
+ {submission.status === "approved" && ( + + )} + {submission.status === "pending" && ( + + )} +
+
+ )) + )} +
+
+ ); +} diff --git a/src/components/frontend/awards/RejectRibbonDialog.tsx b/src/components/frontend/awards/RejectRibbonDialog.tsx new file mode 100644 index 0000000..f9a696f --- /dev/null +++ b/src/components/frontend/awards/RejectRibbonDialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useId, useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { reviewRibbonSubmission } from "@/app/(frontend)/awards/design/actions"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { RibbonSubmission } from "@/payload-types"; + +interface RejectRibbonDialogProps { + readonly submission: RibbonSubmission; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +export function RejectRibbonDialog({ submission, open, onOpenChange }: RejectRibbonDialogProps) { + const router = useRouter(); + const noteId = useId(); + const [reviewNote, setReviewNote] = useState(""); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!open) return; + setReviewNote(""); + setPending(false); + }, [open]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setPending(true); + + try { + const result = await reviewRibbonSubmission(submission.id, "rejected", { + reviewNote: reviewNote.trim() || undefined, + }); + + if (!result.success) { + toast.error(result.error ?? "Could not reject this ribbon."); + return; + } + + toast.success("Ribbon rejected"); + onOpenChange(false); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not reject this ribbon."); + } finally { + setPending(false); + } + } + + return ( + { + if (!pending) onOpenChange(nextOpen); + }} + > + + + Reject ribbon + + Add optional feedback for the submitter before rejecting "{submission.name}". + + +
+
+ +