e95490eb72
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?; FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular - Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen, Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button - Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel, newListingConstants, MyListings, propertyHelpers) - "Zum Originalinserat"-Button nur bei Maison-Work-Objekten - Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder), nur Innenaufnahmen, rotate()-Trick für Sub-Pools - Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
|
import {
|
|
Building2,
|
|
CheckCircle2,
|
|
} from 'lucide-react'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
|
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
|
|
overlayTypeLabel?: string
|
|
overlayLocationLabel?: string
|
|
}
|
|
|
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
|
const tier = getScoreTier(vm.matchScore)
|
|
const theme = SCORE_THEME[tier]
|
|
const { currentUser } = useSessionStore()
|
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
|
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} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
|
<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>
|
|
</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', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
openInquiryDialog({
|
|
propertyTitle: vm.title,
|
|
location: vm.locationLabel ?? '',
|
|
matchScore: vm.matchScore,
|
|
matchId: vm.id,
|
|
propertyId: vm.propertyId,
|
|
})
|
|
}}
|
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
|
>
|
|
Anfrage
|
|
</Button>
|
|
{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>
|
|
)
|
|
}
|