@@ -37,6 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
3737import { SortableButton } from '../components/SortableButton' ;
3838import {
3939 AlertCircle ,
40+ ArrowUpCircle ,
4041 ChevronLeft ,
4142 Check ,
4243 ChevronRight ,
@@ -78,6 +79,13 @@ type EnrollmentState = {
7879 tier : ContributorTier ;
7980} ;
8081
82+ type UpgradeState = {
83+ contributorId : string ;
84+ githubLogin : string ;
85+ currentTier : ContributorTier ;
86+ newTier : ContributorTier ;
87+ } ;
88+
8189type SortConfig < T extends string > = {
8290 field : T ;
8391 direction : 'asc' | 'desc' ;
@@ -151,6 +159,16 @@ function normalizeTier(value: string): ContributorTier | null {
151159 return null ;
152160}
153161
162+ const TIER_ORDER : Record < ContributorTier , number > = {
163+ contributor : 0 ,
164+ ambassador : 1 ,
165+ champion : 2 ,
166+ } ;
167+
168+ function higherTiersFor ( current : ContributorTier ) : ContributorTier [ ] {
169+ return contributorTiers . filter ( t => TIER_ORDER [ t ] > TIER_ORDER [ current ] ) ;
170+ }
171+
154172function TierDisplay ( { tier } : { tier : ContributorTier | null } ) {
155173 if ( ! tier ) {
156174 return < span className = "text-muted-foreground" > —</ span > ;
@@ -325,6 +343,9 @@ export default function ContributorChampionsAdminPage() {
325343
326344 const [ drillInState , setDrillInState ] = useState < DrillInState | null > ( null ) ;
327345 const [ enrollmentState , setEnrollmentState ] = useState < EnrollmentState | null > ( null ) ;
346+ const [ upgradeState , setUpgradeState ] = useState < UpgradeState | null > ( null ) ;
347+ // Per-row selected upgrade tier, keyed by contributorId
348+ const [ upgradeSelections , setUpgradeSelections ] = useState < Record < string , ContributorTier > > ( { } ) ;
328349 // Enrolled table state
329350 const [ enrolledPage , setEnrolledPage ] = useState ( 1 ) ;
330351 const [ enrolledFilters , setEnrolledFilters ] = useState < EnrolledFilters > ( {
@@ -424,6 +445,26 @@ export default function ContributorChampionsAdminPage() {
424445 } )
425446 ) ;
426447
448+ const upgradeMutation = useMutation (
449+ trpc . admin . contributorChampions . upgradeTier . mutationOptions ( {
450+ onSuccess : result => {
451+ const creditMsg =
452+ result . creditDifferentialUsd > 0
453+ ? result . creditGranted
454+ ? ` — $${ result . creditDifferentialUsd } top-up credit granted`
455+ : ` — credit pending (no linked account)`
456+ : '' ;
457+ toast . success ( `Upgraded to ${ result . upgradedTier } ${ creditMsg } ` ) ;
458+ setUpgradeState ( null ) ;
459+ setUpgradeSelections ( { } ) ;
460+ refreshContributorQueries ( ) ;
461+ } ,
462+ onError : ( error : { message : string } ) => {
463+ toast . error ( `Failed to upgrade tier: ${ error . message } ` ) ;
464+ } ,
465+ } )
466+ ) ;
467+
427468 const syncMutation = useMutation (
428469 trpc . admin . contributorChampions . syncNow . mutationOptions ( {
429470 onSuccess : ( ) => {
@@ -700,18 +741,19 @@ export default function ContributorChampionsAdminPage() {
700741 < TableHead > Credits/mo</ TableHead >
701742 < TableHead > Last Grant</ TableHead >
702743 < TableHead > GH Integration</ TableHead >
744+ < TableHead className = "text-right" > Upgrade</ TableHead >
703745 </ TableRow >
704746 </ TableHeader >
705747 < TableBody >
706748 { isLoadingTables ? (
707749 < TableRow >
708- < TableCell colSpan = { 9 } className = "py-8 text-center" >
750+ < TableCell colSpan = { 10 } className = "py-8 text-center" >
709751 < Loader2 className = "mx-auto h-4 w-4 animate-spin" />
710752 </ TableCell >
711753 </ TableRow >
712754 ) : enrolledPageRows . length === 0 ? (
713755 < TableRow >
714- < TableCell colSpan = { 9 } className = "text-muted-foreground py-8 text-center" >
756+ < TableCell colSpan = { 10 } className = "text-muted-foreground py-8 text-center" >
715757 No enrolled contributors.
716758 </ TableCell >
717759 </ TableRow >
@@ -778,6 +820,63 @@ export default function ContributorChampionsAdminPage() {
778820 < X className = "text-muted-foreground h-4 w-4" />
779821 ) }
780822 </ TableCell >
823+ < TableCell className = "text-right" >
824+ { row . enrolledTier && higherTiersFor ( row . enrolledTier ) . length > 0 ? (
825+ < div className = "flex items-center justify-end gap-1" >
826+ < Select
827+ value = { upgradeSelections [ row . contributorId ] ?? '__none__' }
828+ onValueChange = { value => {
829+ const parsed = normalizeTier ( value ) ;
830+ if ( ! parsed ) return ;
831+ setUpgradeSelections ( prev => ( {
832+ ...prev ,
833+ [ row . contributorId ] : parsed ,
834+ } ) ) ;
835+ } }
836+ >
837+ < SelectTrigger className = "h-8 w-[130px]" >
838+ < SelectValue placeholder = "Upgrade to…" />
839+ </ SelectTrigger >
840+ < SelectContent >
841+ < SelectItem value = "__none__" disabled >
842+ Upgrade to…
843+ </ SelectItem >
844+ { higherTiersFor ( row . enrolledTier ) . map ( tier => (
845+ < SelectItem key = { tier } value = { tier } >
846+ { tier }
847+ </ SelectItem >
848+ ) ) }
849+ </ SelectContent >
850+ </ Select >
851+ < Button
852+ size = "icon"
853+ className = "h-8 w-8 bg-blue-600 hover:bg-blue-700"
854+ disabled = {
855+ ! upgradeSelections [ row . contributorId ] || upgradeMutation . isPending
856+ }
857+ onClick = { ( ) => {
858+ const newTier = upgradeSelections [ row . contributorId ] ;
859+ if ( ! newTier || ! row . enrolledTier ) return ;
860+ setUpgradeState ( {
861+ contributorId : row . contributorId ,
862+ githubLogin : row . githubLogin ,
863+ currentTier : row . enrolledTier ,
864+ newTier,
865+ } ) ;
866+ } }
867+ title = {
868+ upgradeSelections [ row . contributorId ]
869+ ? `Upgrade to ${ upgradeSelections [ row . contributorId ] } `
870+ : 'Select a tier to upgrade to'
871+ }
872+ >
873+ < ArrowUpCircle className = "h-4 w-4" />
874+ </ Button >
875+ </ div >
876+ ) : (
877+ < span className = "text-muted-foreground text-xs" > —</ span >
878+ ) }
879+ </ TableCell >
781880 </ TableRow >
782881 ) )
783882 ) }
@@ -1195,6 +1294,86 @@ export default function ContributorChampionsAdminPage() {
11951294 </ DialogContent >
11961295 </ Dialog >
11971296
1297+ < Dialog
1298+ open = { upgradeState !== null }
1299+ onOpenChange = { open => {
1300+ if ( ! open ) {
1301+ if ( upgradeState ) {
1302+ setUpgradeSelections ( prev => {
1303+ const next = { ...prev } ;
1304+ delete next [ upgradeState . contributorId ] ;
1305+ return next ;
1306+ } ) ;
1307+ }
1308+ setUpgradeState ( null ) ;
1309+ }
1310+ } }
1311+ >
1312+ < DialogContent className = "sm:max-w-[460px]" >
1313+ < DialogHeader >
1314+ < DialogTitle > Confirm tier upgrade</ DialogTitle >
1315+ < DialogDescription >
1316+ Upgrade @{ upgradeState ?. githubLogin } from < b > { upgradeState ?. currentTier } </ b > to{ ' ' }
1317+ < b > { upgradeState ?. newTier } </ b > .
1318+ </ DialogDescription >
1319+ </ DialogHeader >
1320+
1321+ { upgradeState ? (
1322+ < div className = "space-y-2 text-sm" >
1323+ < p >
1324+ Immediate top-up:{ ' ' }
1325+ < b >
1326+ $
1327+ { TIER_CREDIT_USD [ upgradeState . newTier ] -
1328+ TIER_CREDIT_USD [ upgradeState . currentTier ] } { ' ' }
1329+ in Kilo Credits
1330+ </ b > { ' ' }
1331+ (the difference between { upgradeState . currentTier } and { upgradeState . newTier } for
1332+ the current period).
1333+ </ p >
1334+ < p >
1335+ Going forward: < b > ${ TIER_CREDIT_USD [ upgradeState . newTier ] } /month</ b > at the next
1336+ renewal.
1337+ </ p >
1338+ { ( ( ) => {
1339+ const matchedRow = ( enrolledQuery . data ?? [ ] ) . find (
1340+ r => r . contributorId === upgradeState . contributorId
1341+ ) ;
1342+ if ( ! matchedRow ?. linkedUserId ) {
1343+ return (
1344+ < p className = "text-yellow-500" >
1345+ ⚠️ No linked Kilo account found. The top-up credit cannot be granted until the
1346+ contributor has a Kilo account with a matching email.
1347+ </ p >
1348+ ) ;
1349+ }
1350+ return null ;
1351+ } ) ( ) }
1352+ </ div >
1353+ ) : null }
1354+
1355+ < DialogFooter >
1356+ < DialogClose asChild >
1357+ < Button variant = "secondary" disabled = { upgradeMutation . isPending } >
1358+ Cancel
1359+ </ Button >
1360+ </ DialogClose >
1361+ < Button
1362+ disabled = { upgradeMutation . isPending || upgradeState === null }
1363+ onClick = { ( ) => {
1364+ if ( ! upgradeState ) return ;
1365+ void upgradeMutation . mutateAsync ( {
1366+ contributorId : upgradeState . contributorId ,
1367+ newTier : upgradeState . newTier ,
1368+ } ) ;
1369+ } }
1370+ >
1371+ { upgradeMutation . isPending ? 'Upgrading...' : 'Confirm upgrade' }
1372+ </ Button >
1373+ </ DialogFooter >
1374+ </ DialogContent >
1375+ </ Dialog >
1376+
11981377 { /* Manual Enrollment Dialog */ }
11991378 < Dialog
12001379 open = { manualEnrollOpen }
0 commit comments