feat: F029 intelligence card system — premium AI-native CRE cards
Gold/Silver/Bronze FIFA-style score badges overlaid on location imagery. List/grid view toggle on Results and Properties pages (persisted in localStorage). IntelligenceMatchCard for match results grid view; PropertyIntelligenceCard for property grid view with warm brown theme. LocationPreview component with Google Maps link. 10 mock properties enriched with Unsplash building images. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vm: MatchCardViewModel
|
||||
imageUrl?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
cityLabel?: string
|
||||
}
|
||||
|
||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||
const tier = getScoreTier(vm.matchScore)
|
||||
const theme = SCORE_THEME[tier]
|
||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||
const topReason = vm.reasons[0]
|
||||
|
||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
|
||||
border: isFuture ? '2px solid #7c3aed' : `1px solid ${theme.cardBorder}`,
|
||||
background: theme.cardBg,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
'&:hover': {
|
||||
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Image zone */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
||||
|
||||
{/* Score badge — overlaid top-left */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
background: theme.gradient,
|
||||
borderRadius: '10px',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
boxShadow: `0 4px 16px ${theme.glow}`,
|
||||
border: `1px solid ${theme.border}`,
|
||||
minWidth: 52,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
||||
{vm.matchScore}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
||||
{theme.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Result type chip — overlaid top-right */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
<Chip
|
||||
label={rt.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card body */}
|
||||
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
|
||||
{vm.explainabilitySummary && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: '#1e293b',
|
||||
lineHeight: 1.5,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topReason && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600, lineHeight: 1.4 }}>
|
||||
{topReason.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
{vm.actions.map(a => (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="small"
|
||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||
onClick={a.onClick}
|
||||
disabled={a.disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.7rem',
|
||||
py: 0.375,
|
||||
px: 1,
|
||||
...(a.variant === 'primary' && {
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* FUTURE_AVAILABILITY disclaimer */}
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ borderRadius: 0, py: 0.25, '& .MuiAlert-message': { fontSize: '0.7rem' } }}>
|
||||
{vm.disclaimer}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user