Files
property-match/src/components/match-card/IntelligenceMatchCard.tsx
T
Benjamin Sutter 7f090a48ff refactor: replace hardcoded hex colors with design tokens across priority components
- Add DS_COLORS.heat, DS_COLORS.futureCard, INQUIRY_STATUS_META tokens to ds.ts
- Eliminate 4 duplicate RESULT_TYPE_META / TYPE_META constants (now all use ds.ts)
- Eliminate 3 duplicate scoreColor / SCORE_COLOR functions (now all use matchScoreHex)
- HeatBadge: use DS_COLORS.heat.VERY_HOT / HOT tokens
- FutureAvailabilityCard: use DS_COLORS.futureCard.controlled / signal tokens
- IntelligenceMatchCard: import RESULT_TYPE_META from ds.ts, remove primary override
- MatchScoreDisplay: replace local scoreColor() with matchScoreHex()
- CompareColumnHeader: remove local TYPE_META + SCORE_COLOR, use ds.ts + utils.ts
- CompareTableBody: import RESULT_TYPE_META + matchScoreHex, fix #7c3aed → token
- compareUtils: remove TYPE_META + SCORE_COLOR, use dataQualityHex in scoreBar
- Anfragen + AnfragenInquiryItem: deduplicate STATUS_CONFIG → INQUIRY_STATUS_META
- Anfragen: #1e3a5f → 'primary.main', KI alert → futureCard.controlled tokens
- AnfragenMessageBubble: own-message bubble → 'primary.main', AI bubble → tokens
- ScoreBreakdownPanel: #1e3a5f → 'primary.main', #6d28d9 → futureCard.controlled
- ESLint: add warn rule against new hex colors in component sx props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:02:01 +02:00

168 lines
6.7 KiB
TypeScript

import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import {
Building2,
CheckCircle2,
} from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Main component ────────────────────────────────────────────────────────────
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 { currentUser } = useSessionStore()
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
// Future availability cards use the new design
if (isFuture) {
return <FutureAvailabilityCard vm={vm} />
}
return (
<Box sx={{
borderRadius: 2,
overflow: 'hidden',
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${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)',
},
}}>
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
<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>
{tier !== 'bronze' && (
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
{theme.label}
</Typography>
)}
</Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={9} color="#1e3a5f" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
<HeatBadge propertyId={vm.propertyId} size="sm" />
</Box>
</Box>
{/* ── Card body ── */}
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Unit-first title (floor + street) */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{vm.title}
</Typography>
{/* Building name as subtitle when a unit or floor range is in the title */}
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: '#475569', fontWeight: 500 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography>
{/* Unit area + rent when a specific unit is matched */}
{vm.preMarketUnit && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
{vm.preMarketUnit.rentPricePerSqm
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
: ''}
</Typography>
)}
{/* Regular explainability summary */}
{vm.explainabilitySummary && (
<>
<Divider sx={{ my: 1.25 }} />
<Typography variant="body2" sx={{
fontWeight: 500, color: '#1e293b', lineHeight: 1.5, flex: 1,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
}}>
{vm.explainabilitySummary}
</Typography>
</>
)}
{vm.reasons.length > 0 && (
<>
<Divider sx={{ my: 1.25 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#64748b', lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</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.label}
</Button>
))}
</Box>
</Box>
</Box>
)
}