2026-06-04 11:15:13 -06:00
/ * *
* CardDetailModal — Full CARD ownership detail view
*
* Opens from the CARD tooltip "Actions" button on the reporting page .
* Shows the full ownership record and allows confirm / decline / redirect
* directly against the CARD API ( no queue item required ) .
* /
import React , { useState , useEffect , useCallback } from 'react' ;
2026-06-10 09:31:21 -06:00
import { X , Loader , AlertCircle , CheckCircle , XCircle , ArrowRightLeft , ExternalLink } from 'lucide-react' ;
2026-06-04 11:15:13 -06:00
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
const API _BASE = process . env . REACT _APP _API _BASE || 'http://localhost:3001/api' ;
const OVERLAY = {
position : 'fixed' , inset : 0 , background : 'rgba(0,0,0,0.75)' , backdropFilter : 'blur(4px)' ,
zIndex : 10200 , display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
} ;
const MODAL = {
background : 'linear-gradient(135deg, #1E293B, #0F172A)' ,
borderRadius : '1rem' , border : '1px solid rgba(124, 58, 237, 0.25)' ,
width : '90vw' , maxWidth : '580px' , maxHeight : '85vh' , overflow : 'auto' ,
padding : '1.5rem' , position : 'relative' ,
} ;
const SECTION = {
background : 'rgba(15, 23, 42, 0.6)' , border : '1px solid rgba(51, 65, 85, 0.5)' ,
borderRadius : '0.5rem' , padding : '0.75rem' , marginBottom : '0.75rem' ,
} ;
const LABEL = { fontSize : '0.6rem' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : '0.2rem' } ;
const VALUE = { fontSize : '0.75rem' , color : '#E2E8F0' , fontFamily : "'JetBrains Mono', monospace" } ;
const TEAM _BADGE = ( color ) => ( {
display : 'inline-block' , padding : '0.15rem 0.5rem' , borderRadius : '0.25rem' ,
fontSize : '0.7rem' , fontWeight : '600' , fontFamily : 'monospace' ,
background : ` ${ color } 15 ` , border : ` 1px solid ${ color } 40 ` , color ,
} ) ;
const INPUT = {
width : '100%' , boxSizing : 'border-box' , background : 'rgba(15, 23, 42, 0.8)' ,
border : '1px solid rgba(51, 65, 85, 0.6)' , borderRadius : '0.375rem' ,
color : '#E2E8F0' , padding : '0.5rem 0.75rem' , fontSize : '0.75rem' ,
fontFamily : "'JetBrains Mono', monospace" , outline : 'none' ,
} ;
const BTN = {
padding : '0.5rem 1.25rem' , borderRadius : '0.375rem' , border : 'none' ,
fontSize : '0.75rem' , fontWeight : '600' , cursor : 'pointer' , transition : 'all 0.12s' ,
} ;
export default function CardDetailModal ( { isOpen , onClose , ip , ownerData : initialOwnerData , cardTeams } ) {
const [ ownerData , setOwnerData ] = useState ( initialOwnerData || null ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ error , setError ] = useState ( null ) ;
const [ action , setAction ] = useState ( 'confirm' ) ;
const [ teamName , setTeamName ] = useState ( '' ) ;
const [ fromTeam , setFromTeam ] = useState ( '' ) ;
const [ toTeam , setToTeam ] = useState ( '' ) ;
const [ comment , setComment ] = useState ( '' ) ;
const [ executing , setExecuting ] = useState ( false ) ;
const [ execError , setExecError ] = useState ( null ) ;
const [ execSuccess , setExecSuccess ] = useState ( null ) ;
// Fetch owner data if not provided or refresh on open
useEffect ( ( ) => {
if ( ! isOpen || ! ip ) return ;
// If we already have data from the tooltip cache, use it
if ( initialOwnerData && ! initialOwnerData . notFound && ! initialOwnerData . error ) {
setOwnerData ( initialOwnerData ) ;
// Pre-fill team fields
if ( initialOwnerData . confirmed ) {
setTeamName ( initialOwnerData . confirmed . name || '' ) ;
setFromTeam ( initialOwnerData . confirmed . name || '' ) ;
} else if ( initialOwnerData . unconfirmed ) {
setTeamName ( initialOwnerData . unconfirmed . name || '' ) ;
setFromTeam ( initialOwnerData . unconfirmed . name || '' ) ;
}
return ;
}
// Fetch fresh
setLoading ( true ) ;
setError ( null ) ;
fetch ( ` ${ API _BASE } /card/owner-lookup/ ${ encodeURIComponent ( ip ) } ` , { credentials : 'include' } )
. then ( r => {
if ( ! r . ok ) return r . json ( ) . then ( d => { throw new Error ( d . error || ` HTTP ${ r . status } ` ) ; } ) ;
return r . json ( ) ;
} )
. then ( data => {
setOwnerData ( data ) ;
if ( data . confirmed ) {
setTeamName ( data . confirmed . name || '' ) ;
setFromTeam ( data . confirmed . name || '' ) ;
} else if ( data . unconfirmed ) {
setTeamName ( data . unconfirmed . name || '' ) ;
setFromTeam ( data . unconfirmed . name || '' ) ;
}
setLoading ( false ) ;
} )
. catch ( err => {
setError ( err . message ) ;
setLoading ( false ) ;
} ) ;
} , [ isOpen , ip , initialOwnerData ] ) ;
// Reset state on close
useEffect ( ( ) => {
if ( ! isOpen ) {
setExecError ( null ) ;
setExecSuccess ( null ) ;
setComment ( '' ) ;
}
} , [ isOpen ] ) ;
const handleExecute = useCallback ( async ( ) => {
if ( ! ownerData ? . asset _id ) return ;
setExecuting ( true ) ;
setExecError ( null ) ;
setExecSuccess ( null ) ;
try {
let url , body ;
const assetId = ownerData . asset _id ;
if ( action === 'confirm' ) {
url = ` ${ API _BASE } /card/owner/ ${ encodeURIComponent ( assetId ) } /confirm ` ;
body = { teamName : teamName . trim ( ) , comment : comment . trim ( ) } ;
} else if ( action === 'decline' ) {
url = ` ${ API _BASE } /card/owner/ ${ encodeURIComponent ( assetId ) } /decline ` ;
body = { teamName : teamName . trim ( ) , comment : comment . trim ( ) } ;
} else if ( action === 'redirect' ) {
url = ` ${ API _BASE } /card/owner/ ${ encodeURIComponent ( assetId ) } /redirect ` ;
body = { fromTeam : fromTeam . trim ( ) , toTeam : toTeam . trim ( ) } ;
}
const res = await fetch ( url , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) ,
} ) ;
const data = await res . json ( ) ;
if ( ! res . ok ) {
setExecError ( data . error || data . message || ` ${ action } failed. ` ) ;
} else {
setExecSuccess ( ` ${ action . charAt ( 0 ) . toUpperCase ( ) + action . slice ( 1 ) } successful. ` ) ;
}
} catch ( err ) {
setExecError ( err . message || 'Network error.' ) ;
} finally {
setExecuting ( false ) ;
}
} , [ ownerData , action , teamName , fromTeam , toTeam , comment ] ) ;
if ( ! isOpen ) return null ;
const canExecute = ( ) => {
if ( action === 'confirm' || action === 'decline' ) return teamName . trim ( ) . length > 0 ;
if ( action === 'redirect' ) return fromTeam . trim ( ) . length > 0 && toTeam . trim ( ) . length > 0 ;
return false ;
} ;
return (
< div style = { OVERLAY } onClick = { onClose } >
< div style = { MODAL } onClick = { e => e . stopPropagation ( ) } >
{ /* Header */ }
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : '1rem' } } >
< div >
< h3 style = { { margin : 0 , color : '#F8FAFC' , fontSize : '0.95rem' } } > CARD Asset Details < / h 3 >
< div style = { { fontSize : '0.72rem' , color : '#0EA5E9' , fontFamily : "'JetBrains Mono', monospace" , marginTop : '0.2rem' } } >
{ ip }
< / d i v >
{ ownerData ? . asset _id && (
< div style = { { fontSize : '0.65rem' , color : '#7C3AED' , fontFamily : 'monospace' , marginTop : '0.1rem' } } >
{ ownerData . asset _id }
< / d i v >
) }
< / d i v >
< button onClick = { onClose } style = { { background : 'none' , border : 'none' , color : '#64748B' , cursor : 'pointer' } } >
< X style = { { width : '18px' , height : '18px' } } / >
< / b u t t o n >
< / d i v >
{ /* Loading */ }
{ loading && (
< div style = { { textAlign : 'center' , padding : '2rem' } } >
< Loader style = { { width : '20px' , height : '20px' , color : '#7C3AED' , animation : 'spin 1s linear infinite' } } / >
< div style = { { fontSize : '0.75rem' , color : '#64748B' , marginTop : '0.5rem' } } > Loading CARD data ... < / d i v >
< / d i v >
) }
{ /* Error */ }
{ error && (
< div style = { { ... SECTION , borderColor : 'rgba(239, 68, 68, 0.4)' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< AlertCircle style = { { width : '14px' , height : '14px' , color : '#EF4444' } } / >
< span style = { { fontSize : '0.75rem' , color : '#FCA5A5' } } > { error } < / s p a n >
< / d i v >
< / d i v >
) }
{ /* Owner data */ }
{ ownerData && ! loading && (
< >
{ /* Ownership section */ }
< div style = { SECTION } >
< div style = { LABEL } > Ownership < / d i v >
< div style = { { display : 'grid' , gap : '0.5rem' , marginTop : '0.3rem' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< span style = { { fontSize : '0.65rem' , color : '#64748B' , width : '80px' } } > Confirmed : < / s p a n >
{ ownerData . confirmed ? (
< >
< span style = { TEAM _BADGE ( '#10B981' ) } > { ownerData . confirmed . name } < / s p a n >
< span style = { { fontSize : '0.6rem' , color : '#64748B' } } >
( score : { ownerData . confirmed . score } , { ownerData . confirmed . datasource || 'n/a' } )
< / s p a n >
< / >
) : (
< span style = { { ... VALUE , color : '#475569' } } > — < / s p a n >
) }
< / d i v >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< span style = { { fontSize : '0.65rem' , color : '#64748B' , width : '80px' } } > Unconfirmed : < / s p a n >
{ ownerData . unconfirmed ? (
< >
< span style = { TEAM _BADGE ( '#F59E0B' ) } > { ownerData . unconfirmed . name } < / s p a n >
< span style = { { fontSize : '0.6rem' , color : '#64748B' } } >
( score : { ownerData . unconfirmed . score } )
< / s p a n >
< / >
) : (
< span style = { { ... VALUE , color : '#475569' } } > — < / s p a n >
) }
< / d i v >
{ ownerData . candidate && ownerData . candidate . length > 0 && (
< div style = { { display : 'flex' , alignItems : 'flex-start' , gap : '0.5rem' } } >
< span style = { { fontSize : '0.65rem' , color : '#64748B' , width : '80px' , flexShrink : 0 } } > Candidates : < / s p a n >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '0.3rem' } } >
{ ownerData . candidate . map ( ( c , i ) => (
< span key = { i } style = { TEAM _BADGE ( '#94A3B8' ) } > { c . name } ( { c . score } ) < / s p a n >
) ) }
< / d i v >
< / d i v >
) }
{ ownerData . declined && ownerData . declined . length > 0 && (
< div style = { { display : 'flex' , alignItems : 'flex-start' , gap : '0.5rem' } } >
< span style = { { fontSize : '0.65rem' , color : '#64748B' , width : '80px' , flexShrink : 0 } } > Declined : < / s p a n >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '0.3rem' } } >
{ ownerData . declined . map ( ( d , i ) => (
< span key = { i } style = { TEAM _BADGE ( '#EF4444' ) } > { d . name } < / s p a n >
) ) }
< / d i v >
< / d i v >
) }
< / d i v >
< / d i v >
{ /* Action section */ }
< div style = { { ... SECTION , borderColor : 'rgba(124, 58, 237, 0.3)' } } >
< div style = { LABEL } > Action < / d i v >
< div style = { { display : 'flex' , gap : '0.5rem' , marginTop : '0.4rem' , marginBottom : '0.75rem' } } >
{ [ 'confirm' , 'decline' , 'redirect' ] . map ( a => (
< button
key = { a }
onClick = { ( ) => setAction ( a ) }
style = { {
... BTN ,
padding : '0.35rem 0.75rem' ,
background : action === a ? ( a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)' ) : 'transparent' ,
border : ` 1px solid ${ action === a ? ( a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9' ) : '#334155' } ` ,
color : action === a ? ( a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9' ) : '#64748B' ,
} }
>
{ a === 'confirm' && < CheckCircle style = { { width : '12px' , height : '12px' , marginRight : '0.3rem' , display : 'inline' } } / > }
{ a === 'decline' && < XCircle style = { { width : '12px' , height : '12px' , marginRight : '0.3rem' , display : 'inline' } } / > }
{ a === 'redirect' && < ArrowRightLeft style = { { width : '12px' , height : '12px' , marginRight : '0.3rem' , display : 'inline' } } / > }
{ a . charAt ( 0 ) . toUpperCase ( ) + a . slice ( 1 ) }
< / b u t t o n >
) ) }
< / d i v >
{ /* Action-specific fields */ }
{ ( action === 'confirm' || action === 'decline' ) && (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '0.5rem' } } >
< div >
< label style = { { ... LABEL , display : 'block' } } > Team < / l a b e l >
< select style = { INPUT } value = { teamName } onChange = { e => setTeamName ( e . target . value ) } >
< option value = "" > Select team ... < / o p t i o n >
{ ownerData . confirmed && < option value = { ownerData . confirmed . name } > { ownerData . confirmed . name } ( confirmed ) < / o p t i o n > }
{ ownerData . unconfirmed && < option value = { ownerData . unconfirmed . name } > { ownerData . unconfirmed . name } ( unconfirmed ) < / o p t i o n > }
{ ownerData . candidate && ownerData . candidate . filter ( c => c . name !== 'CARD-UNKNOWN' ) . map ( c => (
< option key = { c . name } value = { c . name } > { c . name } ( candidate , score : { c . score } ) < / o p t i o n >
) ) }
< option disabled > ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ < / o p t i o n >
{ cardTeams && cardTeams . filter ( t => t !== ownerData . confirmed ? . name && t !== ownerData . unconfirmed ? . name ) . map ( t => (
< option key = { t } value = { t } > { t } < / o p t i o n >
) ) }
< / s e l e c t >
< / d i v >
< div >
< label style = { { ... LABEL , display : 'block' } } > Comment ( optional ) < / l a b e l >
< input style = { INPUT } value = { comment } onChange = { e => setComment ( e . target . value ) } placeholder = "Optional comment..." / >
< / d i v >
< / d i v >
) }
{ action === 'redirect' && (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '0.5rem' } } >
< div >
< label style = { { ... LABEL , display : 'block' } } > From Team < / l a b e l >
< select style = { INPUT } value = { fromTeam } onChange = { e => setFromTeam ( e . target . value ) } >
< option value = "" > Select from team ... < / o p t i o n >
{ ownerData . confirmed && < option value = { ownerData . confirmed . name } > { ownerData . confirmed . name } ( confirmed ) < / o p t i o n > }
{ ownerData . unconfirmed && < option value = { ownerData . unconfirmed . name } > { ownerData . unconfirmed . name } ( unconfirmed ) < / o p t i o n > }
{ ownerData . candidate && ownerData . candidate . filter ( c => c . name !== 'CARD-UNKNOWN' ) . map ( c => (
< option key = { c . name } value = { c . name } > { c . name } ( candidate ) < / o p t i o n >
) ) }
{ cardTeams && cardTeams . filter ( t => t !== ownerData . confirmed ? . name && t !== ownerData . unconfirmed ? . name ) . map ( t => (
< option key = { t } value = { t } > { t } < / o p t i o n >
) ) }
< / s e l e c t >
< / d i v >
< div >
< label style = { { ... LABEL , display : 'block' } } > To Team < / l a b e l >
< select style = { INPUT } value = { toTeam } onChange = { e => setToTeam ( e . target . value ) } >
< option value = "" > Select to team ... < / o p t i o n >
{ ownerData . confirmed && < option value = { ownerData . confirmed . name } > { ownerData . confirmed . name } ( confirmed ) < / o p t i o n > }
{ ownerData . unconfirmed && < option value = { ownerData . unconfirmed . name } > { ownerData . unconfirmed . name } ( unconfirmed ) < / o p t i o n > }
{ ownerData . candidate && ownerData . candidate . filter ( c => c . name !== 'CARD-UNKNOWN' ) . map ( c => (
< option key = { c . name } value = { c . name } > { c . name } ( candidate ) < / o p t i o n >
) ) }
{ cardTeams && cardTeams . filter ( t => t !== ownerData . confirmed ? . name && t !== ownerData . unconfirmed ? . name ) . map ( t => (
< option key = { t } value = { t } > { t } < / o p t i o n >
) ) }
< / s e l e c t >
< / d i v >
< / d i v >
) }
< / d i v >
{ /* Execution error */ }
{ execError && (
2026-06-10 09:31:21 -06:00
< div style = { { padding : '0.5rem 0.75rem' , background : 'rgba(239, 68, 68, 0.1)' , border : '1px solid rgba(239, 68, 68, 0.3)' , borderRadius : '0.375rem' , marginBottom : '0.75rem' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< AlertCircle style = { { width : '13px' , height : '13px' , color : '#EF4444' , flexShrink : 0 } } / >
< span style = { { fontSize : '0.7rem' , color : '#FCA5A5' } } >
{ execError . includes ( 'update_token' ) ? 'Cannot action via API — this asset has no update token.' : execError }
< / s p a n >
< / d i v >
{ execError . includes ( 'update_token' ) && (
< div style = { { marginTop : '0.4rem' , paddingTop : '0.4rem' , borderTop : '1px solid rgba(239, 68, 68, 0.2)' } } >
< span style = { { fontSize : '0.65rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.3rem' } } >
Action this asset directly in CARD instead :
< / s p a n >
< button
onClick = { ( ) => {
const hostId = ownerData ? . asset _id ? ownerData . asset _id . replace ( /-[A-Z]+$/i , '' ) : ip ;
try {
const ta = document . createElement ( 'textarea' ) ;
ta . value = String ( hostId ) ;
ta . style . position = 'fixed' ;
ta . style . opacity = '0' ;
document . body . appendChild ( ta ) ;
ta . select ( ) ;
document . execCommand ( 'copy' ) ;
document . body . removeChild ( ta ) ;
} catch ( _ ) { /* best effort */ }
window . open ( 'https://card.charter.com/ipn-search' , '_blank' ) ;
} }
style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.35rem' ,
padding : '0.35rem 0.75rem' ,
background : 'rgba(14, 165, 233, 0.15)' ,
border : '1px solid rgba(14, 165, 233, 0.5)' ,
borderRadius : '0.3rem' ,
color : '#7DD3FC' ,
fontSize : '0.7rem' , fontWeight : '600' , fontFamily : 'monospace' ,
cursor : 'pointer' ,
transition : 'all 0.12s' ,
} }
onMouseEnter = { ( e ) => { e . currentTarget . style . background = 'rgba(14, 165, 233, 0.3)' ; } }
onMouseLeave = { ( e ) => { e . currentTarget . style . background = 'rgba(14, 165, 233, 0.15)' ; } }
>
< ExternalLink style = { { width : 12 , height : 12 } } / >
Open in CARD ( ID copied )
< / b u t t o n >
< / d i v >
) }
2026-06-04 11:15:13 -06:00
< / d i v >
) }
{ /* Success */ }
{ execSuccess && (
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , padding : '0.5rem 0.75rem' , background : 'rgba(16, 185, 129, 0.1)' , border : '1px solid rgba(16, 185, 129, 0.3)' , borderRadius : '0.375rem' , marginBottom : '0.75rem' } } >
< CheckCircle style = { { width : '13px' , height : '13px' , color : '#10B981' , flexShrink : 0 } } / >
< span style = { { fontSize : '0.7rem' , color : '#6EE7B7' } } > { execSuccess } < / s p a n >
< / d i v >
) }
{ /* Footer */ }
< div style = { { display : 'flex' , justifyContent : 'flex-end' , gap : '0.5rem' , marginTop : '0.5rem' } } >
< button onClick = { onClose } style = { { ... BTN , background : '#334155' , color : '#E2E8F0' } } > Close < / b u t t o n >
< button
onClick = { handleExecute }
disabled = { ! canExecute ( ) || executing || ! ! execSuccess }
style = { {
... BTN ,
background : canExecute ( ) && ! executing && ! execSuccess ? '#7C3AED' : '#1E293B' ,
color : canExecute ( ) && ! executing && ! execSuccess ? '#fff' : '#475569' ,
cursor : canExecute ( ) && ! executing && ! execSuccess ? 'pointer' : 'not-allowed' ,
} }
>
{ executing ? 'Executing...' : ` Execute ${ action . charAt ( 0 ) . toUpperCase ( ) + action . slice ( 1 ) } ` }
< / b u t t o n >
< / d i v >
< / >
) }
< / d i v >
< / d i v >
) ;
}