feat: Future Availability Intelligence — rebrand & card redesign

Replaces "Schattenmarkt" branding with professional Future Availability
Intelligence throughout the UI. Two distinct card types: Pre-Market
(LEASE_EXPIRY + verified, green accent) and Market Signal (probabilistic,
blue accent). Professioneller Data-Header replaces dark purple gradient.
New detail panel sections: Erkannte Marktindikatoren, Strategische
Interpretation, Bestätigt/Nicht bestätigt. All 15 mock signals enriched
with concrete Swiss CRE market indicators and strategic interpretations.
Signal quality shown as 4-dot indicator (Hoch/Mittel/Niedrig).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-20 13:06:16 +02:00
parent d5a4862a28
commit 5fe15bac62
12 changed files with 531 additions and 358 deletions
@@ -1,4 +1,4 @@
import { Alert, Box, Button, Chip, Divider, LinearProgress, Tooltip, Typography } from '@mui/material'
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import {
BarChart2,
Briefcase,
@@ -9,9 +9,7 @@ import {
FileText,
Newspaper,
ShieldCheck,
TrendingUp,
User,
Zap,
} from 'lucide-react'
import { LocationPreview } from '../shared/LocationPreview'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
@@ -36,210 +34,211 @@ const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
}
const SIGNAL_TYPE_LABELS: Record<string, string> = {
EXPANSION: 'Expansion',
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
CONSTRUCTION_PROJECT:'Bauprojekt',
RESTRUCTURING: 'Restrukturierung',
PROJECT_DEVELOPMENT: 'Projektentwicklung',
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
LEASE_EXPIRY: 'Vertragsende',
const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Bürofläche',
LOGISTICS: 'Lagerfläche',
RETAIL: 'Retailfläche',
PRODUCTION: 'Produktionsfläche',
MIXED: 'Gewerbefläche',
}
// Human-readable explanation of WHY a signal type is relevant to a space search
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
EXPANSION: 'Neuer Flächenbedarf entsteht — Fläche wird gesucht',
POSSIBLE_MOVE_OUT: 'Fläche könnte frei werden — frühzeitig beobachten',
CONSTRUCTION_PROJECT:'Neubau geplant — zukünftige Verfügbarkeit möglich',
RESTRUCTURING: 'Flächenveränderung durch Restrukturierung möglich',
PROJECT_DEVELOPMENT: 'Projektentwicklung — neue Flächen in Planung',
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten verfügbar werden',
// Signal type → opportunity headline (with asset type)
function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string {
switch (signalType) {
case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar`
case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt`
case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}`
case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung`
case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt`
case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt`
case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung`
default: return `Potenzielle ${assetTypeLabel} erkannt`
}
}
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
}
// ── Schattenmarkt hero zone ───────────────────────────────────────────────────
function SignalHero({ vm }: { vm: MatchCardViewModel }) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const signalLabel = vm.signalType ? (SIGNAL_TYPE_LABELS[vm.signalType] ?? vm.signalType) : 'Signal'
const relevanceBridge = vm.signalType ? (SIGNAL_RELEVANCE_BRIDGE[vm.signalType] ?? null) : null
const initials = (vm.title ?? '?')
.split(/\s+/)
.slice(0, 2)
.map(w => w[0])
.join('')
.toUpperCase()
// Signal quality dots display
function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) {
const config = {
HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' },
MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' },
LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' },
}
const c = quality ? config[quality] : config.LOW
return (
<Box sx={{ position: 'relative' }}>
{/* Gradient hero background */}
<Box
sx={{
height: 140,
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
position: 'relative',
overflow: 'hidden',
px: 7,
}}
>
{/* Subtle pattern overlay */}
<Box sx={{
position: 'absolute', inset: 0, opacity: 0.08,
backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px)',
backgroundSize: '40px 40px',
}} />
{/* Company initials */}
<Box sx={{
width: 40, height: 40, borderRadius: '50%',
bgcolor: 'rgba(255,255,255,0.15)',
border: '1.5px solid rgba(255,255,255,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1, flexShrink: 0,
}}>
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '0.9rem', letterSpacing: 1 }}>
{initials}
</Typography>
</Box>
{/* Signal type label */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}>
<Zap size={10} color="#c4b5fd" />
<Typography sx={{ color: '#c4b5fd', fontSize: '0.63rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{signalLabel}
</Typography>
</Box>
{/* Relevance bridge — why this appears in results */}
{relevanceBridge && (
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontSize: '0.67rem', textAlign: 'center', lineHeight: 1.3, zIndex: 1 }}>
{relevanceBridge}
</Typography>
)}
{/* Verified badge */}
{vm.signalIsVerified && (
<Box sx={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: 'rgba(26,122,74,0.9)', borderRadius: 1, px: 0.75, py: 0.25 }}>
<ShieldCheck size={10} color="white" />
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>Verifiziert</Typography>
</Box>
)}
</Box>
{/* 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>
{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>
{/* Schattenmarkt chip — top-right */}
<Box sx={{ position: 'absolute', top: 10, right: 10 }}>
<Chip label="Schattenmarkt" size="small" sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => (
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
))}
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label}
</Typography>
</Box>
)
}
// ── Signal metadata strip ─────────────────────────────────────────────────────
// ── Future Availability Card ──────────────────────────────────────────────────
function SignalMetaStrip({ vm }: { vm: MatchCardViewModel }) {
const sourceMeta = vm.signalSourceType ? SOURCE_META[vm.signalSourceType] : null
const credMeta = vm.signalSourceCredibility ? CREDIBILITY_META[vm.signalSourceCredibility] : null
const probPct = vm.signalProbability ? Math.round(vm.signalProbability * 100) : null
function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false
const accentColor = isControlled ? '#1a7a4a' : '#1d4ed8'
const headerBg = isControlled ? '#f0fdf4' : '#f8fafc'
const badge = isControlled ? 'Pre-Market' : 'Market Signal'
const badgeBg = isControlled ? '#dcfce7' : '#dbeafe'
const badgeColor = isControlled ? '#1a7a4a' : '#1d4ed8'
const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche'
const headline = getOpportunityHeadline(vm.signalType, assetLabel)
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
return (
<Box sx={{ px: 1.75, pt: 1.5, pb: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Source + credibility row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
{sourceMeta && (
vm.signalSourceUrl ? (
<Box
component="a"
href={vm.signalSourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25, textDecoration: 'none', '&:hover': { bgcolor: '#ddd6fe' } }}
>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
<ExternalLink size={9} style={{ opacity: 0.7 }} />
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25 }}>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
)
)}
{credMeta && (
<Tooltip title={credMeta.label} arrow>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, bgcolor: '#f8fafc', border: `1px solid ${credMeta.color}30`, borderRadius: 1, px: 0.6, py: 0.2, cursor: 'default' }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: credMeta.color }} />
<Typography sx={{ fontSize: '0.63rem', color: credMeta.color, fontWeight: 600 }}>
{vm.signalSourceCredibility === 'HIGH' ? 'Hoch' : vm.signalSourceCredibility === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
</Typography>
</Box>
</Tooltip>
<Box sx={{
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${isControlled ? '#bbf7d0' : '#bfdbfe'}`,
borderLeft: `3px solid ${accentColor}`,
background: 'white',
display: 'flex', flexDirection: 'column',
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
transition: 'box-shadow 0.15s, transform 0.1s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.10)', transform: 'translateY(-1px)' },
}}>
{/* Data Header */}
<Box sx={{ bgcolor: headerBg, px: 2, pt: 1.75, pb: 1.5, borderBottom: `1px solid ${isControlled ? '#bbf7d0' : '#bfdbfe'}` }}>
{/* Asset type + location */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
</Typography>
<Box sx={{ bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.2, borderRadius: 1, fontSize: '0.63rem', fontWeight: 700, letterSpacing: 0.3 }}>
{badge}
</Box>
</Box>
{/* Opportunity headline */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
{headline}
</Typography>
{/* Key facts row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap' }}>
{vm.signalAreaSqmEstimate ? (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
~{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
</Typography>
) : null}
{vm.availabilityLabel && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
{vm.availabilityLabel}
</Typography>
)}
</Box>
</Box>
{/* Score + signal quality strip */}
<Box sx={{ px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
<Box sx={{
background: theme.gradient, borderRadius: '8px',
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
boxShadow: `0 2px 8px ${theme.glow}`,
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1.25rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
</Box>
<SignalQualityDots quality={vm.signalQuality} />
{isControlled && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.35, ml: 'auto', bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.625, py: 0.2 }}>
<ShieldCheck size={10} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.6rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
</Box>
)}
</Box>
{/* Probability bar */}
{probPct !== null && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>Eintretenswahrscheinlichkeit</Typography>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>
{probPct}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={probPct}
sx={{
height: 4, borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b',
borderRadius: 2,
},
}}
/>
</Box>
)}
{/* Card body */}
<Box sx={{ px: 2, pt: 1.25, pb: 1.5, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Market indicator */}
{vm.signalMarketIndicator && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', borderRadius: 1, px: 0.75, py: 0.4 }}>
<TrendingUp size={11} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 500 }}>
{vm.signalMarketIndicator}
</Typography>
{/* CONTROLLED: Confirmed facts */}
{isControlled && (vm.signalConfirmedFacts?.length ?? 0) > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
{(vm.signalConfirmedFacts ?? []).slice(0, 2).map((fact, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
</Box>
))}
</Box>
)}
{/* MARKET SIGNAL: Market indicators */}
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
<>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
Erkannte Marktindikatoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mb: 1.25 }}>
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
</Box>
))}
</Box>
</>
)}
{/* Why relevant (match reasons) */}
{vm.reasons.length > 0 && (
<>
<Divider sx={{ mb: 1 }} />
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
Warum relevant für Ihre Suche?
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
<Typography sx={{ fontSize: '0.69rem', 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.variant === 'primary' && {
bgcolor: accentColor,
'&:hover': { bgcolor: isControlled ? '#166534' : '#1e40af' },
}),
}}
>
{a.label}
</Button>
))}
</Box>
)}
{/* Disclaimer footnote */}
{vm.disclaimer && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontSize: '0.65rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
{vm.disclaimer}
</Typography>
)}
</Box>
</Box>
)
}
@@ -260,67 +259,61 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
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: isFuture
? '0 2px 16px rgba(109,40,217,0.15), 0 0 0 2px #7c3aed'
: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
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: isFuture
? '0 6px 28px rgba(109,40,217,0.2), 0 0 0 2px #7c3aed'
: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
transform: 'translateY(-1px)',
},
}}>
{/* ── Hero zone ── */}
{isFuture ? (
<SignalHero vm={vm} />
) : (
<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}%
<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>
{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' && (
<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 } }}
/>
)}
</Box>
)}
</Box>
)}
{/* ── Signal metadata (Schattenmarkt only) ── */}
{isFuture && <SignalMetaStrip vm={vm} />}
<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' && (
<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 } }}
/>
)}
</Box>
</Box>
{/* ── Card body ── */}
<Box sx={{ p: isFuture ? 1.75 : 2, pt: isFuture ? 1 : 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
<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>
@@ -328,25 +321,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography>
{/* AI Signal Summary (Schattenmarkt) */}
{isFuture && vm.aiSignalSummary && (
<>
<Divider sx={{ my: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, bgcolor: '#faf5ff', borderRadius: 1, p: 1 }}>
<Zap size={12} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="body2" sx={{
fontSize: '0.72rem', color: '#4c1d95', lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 4, WebkitBoxOrient: 'vertical',
}}>
{vm.aiSignalSummary}
</Typography>
</Box>
</>
)}
{/* Regular explainability summary (non-future) */}
{!isFuture && vm.explainabilitySummary && (
{/* Regular explainability summary */}
{vm.explainabilitySummary && (
<>
<Divider sx={{ my: 1.25 }} />
<Typography variant="body2" sx={{
@@ -392,8 +368,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
...(a.variant === 'primary' && {
bgcolor: isFuture ? '#7c3aed' : '#1e3a5f',
'&:hover': { bgcolor: isFuture ? '#6d28d9' : '#162d4a' },
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
}),
}}
>
@@ -402,13 +378,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
))}
</Box>
</Box>
{/* Disclaimer */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ borderRadius: 0, py: 0.25, '& .MuiAlert-message': { fontSize: '0.7rem' } }}>
{vm.disclaimer}
</Alert>
)}
</Box>
)
}
@@ -58,6 +58,13 @@ export interface MatchCardViewModel {
signalType?: string
signalIsVerified?: boolean
aiSignalSummary?: string
signalQuality?: 'HIGH' | 'MEDIUM' | 'LOW'
signalMarketIndicators?: string[]
signalStrategicInterpretation?: string
signalConfirmedFacts?: string[]
signalUnconfirmedFacts?: string[]
signalIsControlled?: boolean
signalAreaSqmEstimate?: number
// States
isSelected?: boolean
@@ -1,7 +1,9 @@
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
import {
AlertTriangle,
BarChart2,
Briefcase,
CheckCircle2,
ExternalLink,
FileCheck,
FileText,
@@ -74,7 +76,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Paper sx={{ p: 2.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Zap size={16} color="#7c3aed" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Schattenmarkt-Signal</Typography>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
{signal.isVerified && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}>
<ShieldCheck size={11} color="#1a7a4a" />
@@ -204,7 +206,64 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
</Box>
)}
{/* 4. Relevance bridge */}
<Divider sx={{ mb: 2 }} />
{/* 4. Erkannte Marktindikatoren */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Erkannte Marktindikatoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{signal.marketIndicators.map((indicator, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.45 }}>{indicator}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* 5. Strategische Interpretation */}
{signal.strategicInterpretation && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75, fontSize: '0.8125rem' }}>
Strategische Interpretation
</Typography>
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.65, fontStyle: 'italic' }}>
{signal.strategicInterpretation}
</Typography>
</Box>
</Box>
)}
{/* 6. Transparenz (Bestätigt / Nicht bestätigt) */}
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Transparenz
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{(signal.confirmedFacts ?? []).map((fact, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<CheckCircle2 size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
</Box>
))}
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AlertTriangle size={13} color="#d97706" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{fact}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* 7. Relevance bridge */}
{relevanceBridge && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1.25, mb: 2 }}>
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
+5 -5
View File
@@ -63,7 +63,7 @@ function Slide0() {
<FeatureRow
icon={<Zap size={18} />}
color="#7c3aed"
text="Schattenmarkt-Signale der KI"
text="Future Availability Intelligence"
/>
</Box>
</Box>
@@ -81,7 +81,7 @@ function Slide1() {
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien
und findet die besten Matches aus Portfolio, Markt und Schattenmarkt.
und findet die besten Matches aus Portfolio, Markt und Future Availability.
</Typography>
<Box className="w-full text-left">
<FeatureRow
@@ -106,11 +106,11 @@ function Slide2() {
<Zap size={48} color="#7c3aed" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Schattenmarkt Vor dem Markt informiert
Future Availability Intelligence
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Die KI überwacht Stelleninserate, Pressemeldungen und Baubewilligungen frühzeitige
Hinweise auf mögliche Verfügbarkeiten, bevor sie ausgeschrieben werden.
Frühzeitige Marktintelligenz zu potenziell verfügbaren Flächen verifizierte
Vertragsenden und erkannte Marktsignale, bevor sie öffentlich werden.
</Typography>
<Box className="w-full text-left">
<FeatureRow
+13 -13
View File
@@ -10,8 +10,8 @@ interface Props {
onFilterChange: (v: FilterSource) => void
sortBy: SortBy
onSortChange: (v: SortBy) => void
showSchattenmarkt: boolean
onShowSchattenmarktChange: (v: boolean) => void
showFutureAvailability: boolean
onShowFutureAvailabilityChange: (v: boolean) => void
showOwnProperties?: boolean
onShowOwnPropertiesChange?: (v: boolean) => void
isPropertyManager?: boolean
@@ -34,8 +34,8 @@ export function ResultFilterBar({
onFilterChange,
sortBy,
onSortChange,
showSchattenmarkt,
onShowSchattenmarktChange,
showFutureAvailability,
onShowFutureAvailabilityChange,
showOwnProperties,
onShowOwnPropertiesChange,
isPropertyManager,
@@ -68,19 +68,19 @@ export function ResultFilterBar({
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* Schattenmarkt toggle */}
{/* Future Availability toggle */}
<Chip
size="small"
clickable
icon={<Zap size={11} color={showSchattenmarkt ? '#7c3aed' : '#94a3b8'} />}
label="Schattenmarkt"
onClick={() => onShowSchattenmarktChange(!showSchattenmarkt)}
icon={<Zap size={11} color={showFutureAvailability ? '#7c3aed' : '#94a3b8'} />}
label="Future Availability"
onClick={() => onShowFutureAvailabilityChange(!showFutureAvailability)}
sx={{
bgcolor: showSchattenmarkt ? '#f3e8ff' : 'transparent',
color: showSchattenmarkt ? '#6d28d9' : 'text.disabled',
border: `1px solid ${showSchattenmarkt ? '#c4b5fd' : '#e2e8f0'}`,
fontWeight: showSchattenmarkt ? 600 : 400,
textDecoration: showSchattenmarkt ? 'none' : 'line-through',
bgcolor: showFutureAvailability ? '#f3e8ff' : 'transparent',
color: showFutureAvailability ? '#6d28d9' : 'text.disabled',
border: `1px solid ${showFutureAvailability ? '#c4b5fd' : '#e2e8f0'}`,
fontWeight: showFutureAvailability ? 600 : 400,
textDecoration: showFutureAvailability ? 'none' : 'line-through',
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
+6 -6
View File
@@ -309,7 +309,7 @@ function UnitStructurePanel({ p }: { p: Property }) {
)
}
// ── Schattenmarkt release panel ───────────────────────────────────────────────
// ── Pre-Market release panel ──────────────────────────────────────────────────
const MOCK_TODAY = new Date('2026-05-20')
@@ -346,7 +346,7 @@ function SchattenmarktReleasePanel({ p }: { p: Property }) {
await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } })
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
await queryClient.invalidateQueries({ queryKey: ['properties'] })
showToast(nextEnabled ? 'Schattenmarkt-Freigabe aktiviert.' : 'Schattenmarkt-Freigabe deaktiviert.', 'success')
showToast(nextEnabled ? 'Pre-Market Freigabe aktiviert.' : 'Pre-Market Freigabe deaktiviert.', 'success')
} catch {
showToast('Fehler beim Speichern.', 'error')
} finally {
@@ -367,7 +367,7 @@ function SchattenmarktReleasePanel({ p }: { p: Property }) {
return (
<>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Schattenmarkt" />
<SectionTitle title="Pre-Market Freigabe" />
<Box
sx={{
border: '1px solid',
@@ -383,10 +383,10 @@ function SchattenmarktReleasePanel({ p }: { p: Property }) {
<Zap size={15} color={enabled ? '#7c3aed' : '#94a3b8'} style={{ marginTop: 2 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
Für Schattenmarkt freigeben
Pre-Market freigeben
</Typography>
<Typography variant="caption" color="text.secondary">
Nachfragesuchende sehen dieses Objekt vor Vertragsende im Feed
Fläche vor offizieller Vermarktung im Markt sichtbar machen
</Typography>
</Box>
</Box>
@@ -455,7 +455,7 @@ function SchattenmarktReleasePanel({ p }: { p: Property }) {
{!enabled && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Wenn aktiviert, erscheint dieses Objekt {leadTimeMonths} Monate vor Vertragsende als
verifiziertes Schattenmarkt-Signal im Nachfrage-Feed.
verifiziertes Pre-Market Signal im Nachfrage-Feed.
</Typography>
)}
</Box>