1
0
Fork 0
polaris-task-force/src/components/frontend/profile/PromoteButton.tsx
Z8MB1E 8d92e7aaa9 feat(promotions): add rank promotion ceiling with GM promote flow
Promotion service lib, ceiling field on Ranks, permission gate, migration, and GM promote button with tests.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:37:05 -04:00

130 lines
No EOL
4.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
import { toast } from "sonner";
interface PromoteButtonProps {
username: string;
currentRankName: string;
targetRanks: readonly { id: number; name: string }[];
}
/**
* Profile-page section that lets a viewer with promotion authority promote the
* profile owner. Authority and the allowed target ranks are computed server-side
* by the page; this component only provides the entry point and submits the
* chosen rank id.
*/
export function PromoteButton({ username, currentRankName, targetRanks }: PromoteButtonProps) {
const [open, setOpen] = useState(false);
const [rankId, setRankId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const selectedRankId = rankId ?? targetRanks[0]?.id ?? null;
const selectedRank = targetRanks.find((r) => r.id === selectedRankId);
async function handlePromote() {
if (selectedRankId == null || submitting) return;
setSubmitting(true);
setError(null);
const res = await promoteMember(username, selectedRankId);
if (res.success) {
toast.success("Promotion applied", {
description: `${username} promoted to ${selectedRank?.name ?? "new rank"}.`,
});
setOpen(false);
router.refresh();
} else {
setError(res.error ?? "Failed to promote member.");
setSubmitting(false);
}
}
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Promotion
</h2>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setRankId(null);
setError(null);
setOpen(true);
}}
>
<ArrowUpIcon data-icon="inline-start" />
Promote
</Button>
</div>
<Dialog
open={open}
onOpenChange={(o) => {
if (!submitting) setOpen(o);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Promote {username}</DialogTitle>
<DialogDescription>
Current rank: {currentRankName}. Select the new rank, then confirm.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Select
value={selectedRankId != null ? String(selectedRankId) : undefined}
onValueChange={(v) => setRankId(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select a rank" />
</SelectTrigger>
<SelectContent>
{targetRanks.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handlePromote} disabled={selectedRankId == null || submitting}>
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
Confirm promotion
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}