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:
Benjamin Sutter
2026-05-18 13:25:27 +02:00
parent 9e7a09f8a2
commit c75d3c8121
13 changed files with 581 additions and 26 deletions
@@ -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>
)
}