diff --git a/src/app/(frontend)/profile/[username]/actions.ts b/src/app/(frontend)/profile/[username]/actions.ts index 92b3522..786c923 100644 --- a/src/app/(frontend)/profile/[username]/actions.ts +++ b/src/app/(frontend)/profile/[username]/actions.ts @@ -20,6 +20,7 @@ import { INTEL_EXCERPT_MAX_LENGTH, PERSONAL_EXCERPT_MAX_LENGTH, } from "@/lib/profile/excerpt-limits"; +import { promoteMember as promoteMemberService } from "@/lib/promotions"; interface ActionResult { success: boolean; @@ -270,3 +271,22 @@ export async function updateIntelExcerpt(input: { return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; } } + +/** + * Promote a member to a new rank. Authority is resolved server-side from the + * actor's rank promotionCeiling (or admin/developer bypass); the client only + * supplies the target username and rank id. + */ +export async function promoteMember( + username: string, + rankId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const result = await promoteMemberService(payload, user, username, rankId); + return { success: true, data: { profileId: result.profileId } }; + } catch (e) { + if (e instanceof Error && e.message === "Unauthorized") throw e; + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index 4ab885b..dd38459 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -41,7 +41,9 @@ import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGr import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors"; +import { PromoteButton } from "@/components/frontend/profile/PromoteButton"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; +import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions"; import type { EvaluationMissionOption, EvaluationSummary, @@ -361,6 +363,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) { : null; const canSubmitEvaluation = viewer !== null && !isProfileOwner; const rank = profile.rank as Rank; + const promotionResult = viewer ? await resolvePromotionAuthority(payload, viewer) : null; + const allowedTargetRanks = promotionResult + ? allowedPromotionTargets(promotionResult, rank) + : []; const awardEntries = (profile.progression?.awards ?? []).filter( (entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null, ); @@ -607,6 +613,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) { /> )} + {allowedTargetRanks.length > 0 && ( + ({ id: r.id, name: r.name }))} + /> + )} + {/* ── Combat Record ──────────────────────────────────── */}
diff --git a/src/collections/users/Profiles.ts b/src/collections/users/Profiles.ts index 98543e9..b41c428 100644 --- a/src/collections/users/Profiles.ts +++ b/src/collections/users/Profiles.ts @@ -133,6 +133,7 @@ export const Profiles: CollectionConfig = { aimTrainer?: { xpEarned?: number | null }; opordRecall?: { xpEarned?: number | null }; poker?: { xpEarned?: number | null }; + blackjack?: { xpEarned?: number | null }; }; voiceover?: { xpEarned?: number | null }; }; @@ -150,6 +151,7 @@ export const Profiles: CollectionConfig = { const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0; const opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0; const pokerXp = progressionData?.minigames?.poker?.xpEarned ?? 0; + const blackjackXp = progressionData?.minigames?.blackjack?.xpEarned ?? 0; const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0; if (awardIds.length > 0) { @@ -195,6 +197,8 @@ export const Profiles: CollectionConfig = { xpAcc += opordRecallXp; // Poker sit-and-go: XP from placement payouts xpAcc += pokerXp; + // Blackjack sessions: XP from profit-based payouts + xpAcc += blackjackXp; // Voiceover submissions: XP awarded per accepted submission xpAcc += voiceoverXp; @@ -705,8 +709,64 @@ export const Profiles: CollectionConfig = { }, ], }, + { + name: "blackjack", + label: "Blackjack", + type: "group", + fields: [ + { + name: "gamesPlayed", + label: "Sessions Played", + type: "number", + defaultValue: 0, + }, + { + name: "roundsWon", + label: "Rounds Won", + type: "number", + defaultValue: 0, + }, + { + name: "blackjacksHit", + label: "Blackjacks Hit", + type: "number", + defaultValue: 0, + }, + { + name: "bestScore", + label: "Best Bankroll", + type: "number", + defaultValue: 0, + }, + { + name: "xpEarned", + label: "XP Earned", + type: "number", + defaultValue: 0, + }, + ], + }, ], }, + { + name: "chipBank", + label: "Chip Bank", + type: "number", + defaultValue: 0, + min: 0, + admin: { + description: + "Persistent play-chip pool for the card minigames (poker, blackjack). Earned from the other minigames; never creates chips itself.", + }, + }, + { + name: "chipBankRefilledAt", + label: "Chip Bank Top-Up At", + type: "date", + admin: { + description: "Last daily chip top-up claim; one claim per 24 hours.", + }, + }, { name: "voiceover", label: "Voiceover",