feat: Schattenmarkt UX — separate section, relevance bridge, source links

Signals are now in their own feed section with an explanation banner
so users understand they are not confirmed properties. Each signal card
shows WHY it appears in their search (e.g. 'Fläche könnte frei werden').
Source pills are now clickable links. FutureAvailabilityContextPanel
redesigned with full AI summary, source button, and relevance bridge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 21:59:08 +02:00
parent b5cdb0353b
commit 9cf3e72033
5 changed files with 281 additions and 70 deletions
@@ -4,6 +4,7 @@ import {
Briefcase,
Building2,
CheckCircle2,
ExternalLink,
FileCheck,
FileText,
Newspaper,
@@ -42,6 +43,16 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
}
// 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',
}
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
@@ -54,6 +65,7 @@ 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)
@@ -66,7 +78,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
{/* Gradient hero background */}
<Box
sx={{
height: 130,
height: 140,
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
display: 'flex',
flexDirection: 'column',
@@ -75,6 +87,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
gap: 0.5,
position: 'relative',
overflow: 'hidden',
px: 7,
}}
>
{/* Subtle pattern overlay */}
@@ -86,25 +99,32 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
{/* Company initials */}
<Box sx={{
width: 44, height: 44, borderRadius: '50%',
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,
zIndex: 1, flexShrink: 0,
}}>
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1rem', letterSpacing: 1 }}>
<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={11} color="#c4b5fd" />
<Typography sx={{ color: '#c4b5fd', fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
<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 }}>
@@ -153,10 +173,24 @@ function SignalMetaStrip({ vm }: { vm: MatchCardViewModel }) {
{/* Source + credibility row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
{sourceMeta && (
<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>
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>
@@ -51,6 +51,7 @@ export interface MatchCardViewModel {
// Schattenmarkt / FUTURE_AVAILABILITY signal fields
signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
signalSourceUrl?: string
signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH'
signalProbability?: number
signalMarketIndicator?: string