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, Briefcase,
Building2, Building2,
CheckCircle2, CheckCircle2,
ExternalLink,
FileCheck, FileCheck,
FileText, FileText,
Newspaper, Newspaper,
@@ -42,6 +43,16 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
SPACE_CONSOLIDATION: 'Flächenkonsolidierung', 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 }> = { const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' }, HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' }, MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
@@ -54,6 +65,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
const tier = getScoreTier(vm.matchScore) const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier] const theme = SCORE_THEME[tier]
const signalLabel = vm.signalType ? (SIGNAL_TYPE_LABELS[vm.signalType] ?? vm.signalType) : 'Signal' 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 ?? '?') const initials = (vm.title ?? '?')
.split(/\s+/) .split(/\s+/)
.slice(0, 2) .slice(0, 2)
@@ -66,7 +78,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
{/* Gradient hero background */} {/* Gradient hero background */}
<Box <Box
sx={{ sx={{
height: 130, height: 140,
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)', background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -75,6 +87,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
gap: 0.5, gap: 0.5,
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
px: 7,
}} }}
> >
{/* Subtle pattern overlay */} {/* Subtle pattern overlay */}
@@ -86,25 +99,32 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
{/* Company initials */} {/* Company initials */}
<Box sx={{ <Box sx={{
width: 44, height: 44, borderRadius: '50%', width: 40, height: 40, borderRadius: '50%',
bgcolor: 'rgba(255,255,255,0.15)', bgcolor: 'rgba(255,255,255,0.15)',
border: '1.5px solid rgba(255,255,255,0.3)', border: '1.5px solid rgba(255,255,255,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', 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} {initials}
</Typography> </Typography>
</Box> </Box>
{/* Signal type label */} {/* Signal type label */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}>
<Zap size={11} color="#c4b5fd" /> <Zap size={10} color="#c4b5fd" />
<Typography sx={{ color: '#c4b5fd', fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}> <Typography sx={{ color: '#c4b5fd', fontSize: '0.63rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{signalLabel} {signalLabel}
</Typography> </Typography>
</Box> </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 */} {/* Verified badge */}
{vm.signalIsVerified && ( {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 }}> <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 */} {/* Source + credibility row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
{sourceMeta && ( {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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25 }}>
{sourceMeta.icon} {sourceMeta.icon}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography> <Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box> </Box>
)
)} )}
{credMeta && ( {credMeta && (
<Tooltip title={credMeta.label} arrow> <Tooltip title={credMeta.label} arrow>
@@ -51,6 +51,7 @@ export interface MatchCardViewModel {
// Schattenmarkt / FUTURE_AVAILABILITY signal fields // Schattenmarkt / FUTURE_AVAILABILITY signal fields
signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
signalSourceUrl?: string
signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH' signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH'
signalProbability?: number signalProbability?: number
signalMarketIndicator?: string signalMarketIndicator?: string
@@ -1,4 +1,15 @@
import { Alert, Box, Chip, Paper, Typography } from '@mui/material' import { Alert, Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
import {
BarChart2,
Briefcase,
ExternalLink,
FileCheck,
FileText,
Newspaper,
ShieldCheck,
User,
Zap,
} from 'lucide-react'
import type { Match } from '../../domain/match' import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
@@ -11,20 +22,36 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
SPACE_CONSOLIDATION: 'Flächenkonsolidierung', SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
} }
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
EXPANSION: 'Ein Unternehmen sucht neue Flächen — Ihr Angebot könnte gefragt sein.',
POSSIBLE_MOVE_OUT: 'Dieses Unternehmen könnte seinen Standort aufgeben — die Fläche wird für Sie verfügbar.',
CONSTRUCTION_PROJECT:'Ein Neubau entsteht — neue Flächen könnten zur Vermietung angeboten werden.',
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
}
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
}
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' },
}
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = { const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' }, CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
INTERNAL: { label: 'Intern', color: 'warning' }, INTERNAL: { label: 'Intern', color: 'warning' },
PUBLIC: { label: 'Öffentlich', color: 'default' }, PUBLIC: { label: 'Öffentlich', color: 'default' },
} }
const REVIEW_STATUS_META: Record<string, { label: string; color: 'warning' | 'success' | 'default' | 'error' }> = {
UNREVIEWED: { label: 'Ungeprüft', color: 'warning' },
IN_REVIEW: { label: 'In Prüfung', color: 'warning' },
APPROVED: { label: 'Genehmigt', color: 'success' },
REJECTED: { label: 'Abgelehnt', color: 'error' },
FLAGGED: { label: 'Markiert', color: 'warning' },
}
interface Props { interface Props {
match: Match match: Match
signal: FutureSignal | null signal: FutureSignal | null
@@ -33,64 +60,160 @@ interface Props {
export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) { export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
if (!signal) return null if (!signal) return null
const sourceMeta = SOURCE_META[signal.source.type] ?? null
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
const reviewMeta = signal.reviewStatus ? (REVIEW_STATUS_META[signal.reviewStatus] ?? null) : null const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
const probPct = Math.round(signal.probability * 100)
const confPct = Math.round(signal.confidenceScore * 100)
return ( return (
<Paper sx={{ p: 2.5, mb: 2 }}> <Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Zukunftssignal Kontext</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Zap size={16} color="#7c3aed" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Schattenmarkt-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" />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
</Box>
)}
</Box>
{/* Mandatory disclaimer */} {/* Mandatory disclaimer */}
<Alert severity="warning" sx={{ mb: 2 }}> <Alert severity="warning" sx={{ mb: 2, fontSize: '0.8rem' }}>
{signal.disclaimer} {signal.disclaimer}
</Alert> </Alert>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> {/* 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 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#4c1d95', mb: 0.25 }}>Warum erscheint dieses Signal in Ihrer Suche?</Typography>
<Typography sx={{ fontSize: '0.8rem', color: '#4c1d95', lineHeight: 1.5 }}>{relevanceBridge}</Typography>
</Box>
</Box>
)}
{/* Key metrics row */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
{signal.signalType && ( {signal.signalType && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Signaltyp</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
<Typography variant="body2">{SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType}</Typography> <Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
</Box> </Box>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Konfidenz</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
{Math.round(signal.confidenceScore * 100)}%
</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Wahrscheinlichkeit</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Konfidenz</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>{confPct}%</Typography>
{Math.round(signal.probability * 100)}%
</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Zeithorizont</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
<Typography variant="body2">~{signal.timeHorizonMonths} Monate</Typography> <Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {signal.areaSqmEstimate && (
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Vertraulichkeit</Typography> <Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Vertraulichkeit</Typography>
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" /> <Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
</Box> </Box>
{reviewMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Prüfstatus</Typography>
<Chip label={reviewMeta.label} size="small" color={reviewMeta.color} />
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Quellglaubwürdigkeit</Typography>
<Typography variant="body2">{signal.source.credibility}</Typography>
</Box>
</Box> </Box>
{signal.evidence?.summary && ( <Divider sx={{ mb: 2 }} />
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}> {/* AI Summary */}
Evidenz {signal.aiSummary && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
<Zap size={14} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95' }}>KI-Analyse</Typography>
</Box>
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.65 }}>
{signal.aiSummary}
</Typography> </Typography>
</Box>
</Box>
)}
{/* Evidence */}
{signal.evidence?.summary && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography> <Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
</Box> </Box>
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* Source section */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>Quelle</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{sourceMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
)}
{credMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: credMeta.color }} />
<Typography variant="body2" sx={{ color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
</Box>
)}
{signal.source.publishedAt && (
<Typography variant="caption" color="text.secondary">
Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
</Typography>
)}
{signal.source.url && (
<Button
component="a"
href={signal.source.url}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="outlined"
startIcon={<ExternalLink size={13} />}
sx={{ textTransform: 'none', color: '#7c3aed', borderColor: '#c4b5fd', '&:hover': { borderColor: '#7c3aed', bgcolor: '#faf5ff' } }}
>
Originalquelle öffnen
</Button>
)}
</Box>
{signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Weitere Belege ({signal.evidence.sourceUrls.length})
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.evidence.sourceUrls.map((url, i) => (
<Box
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#7c3aed', fontSize: '0.75rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
<ExternalLink size={11} />
<Typography variant="caption" sx={{ color: 'inherit' }}>{url}</Typography>
</Box>
))}
</Box>
</Box>
)} )}
</Paper> </Paper>
) )
+65 -13
View File
@@ -1,4 +1,5 @@
import { Box, Chip, Divider, Typography } from '@mui/material' import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { UnifiedResultCard } from './UnifiedResultCard' import { UnifiedResultCard } from './UnifiedResultCard'
import { SCORE_THEME } from '../shared/scoreTheme' import { SCORE_THEME } from '../shared/scoreTheme'
@@ -72,12 +73,67 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
) )
} }
function SchattenmarktSectionHeader({ count }: { count: number }) {
return (
<Box sx={{ mt: 4, mb: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<Box sx={{ width: 4, height: 28, borderRadius: 2, background: 'linear-gradient(180deg, #7c3aed, #4c1d95)', flexShrink: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Zap size={15} color="#7c3aed" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: '#4c1d95' }}>
Schattenmarkt
</Typography>
<Chip
label={count}
size="small"
sx={{ fontWeight: 700, fontSize: '0.75rem', bgcolor: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}
/>
</Box>
<Divider sx={{ flex: 1 }} />
</Box>
<Alert
severity="info"
icon={<Zap size={16} />}
sx={{
mb: 2,
bgcolor: '#faf5ff',
border: '1px solid #e9d5ff',
color: '#4c1d95',
'& .MuiAlert-icon': { color: '#7c3aed' },
}}
>
<strong>Was sind Schattenmarkt-Signale?</strong> Die KI hat aus öffentlichen Quellen (Stelleninseraten, Pressemeldungen, Baubewilligungen etc.) Hinweise gefunden, dass in Ihrer Zielregion in absehbarer Zeit Flächen frei werden könnten. Dies sind <strong>keine bestätigten Angebote</strong> aber frühzeitige Hinweise, die Sie im Auge behalten sollten. Klicken Sie auf Details für Quellen und die vollständige KI-Analyse.
</Alert>
</Box>
)
}
export function UnifiedResultFeed({ results, view = 'list' }: Props) { export function UnifiedResultFeed({ results, view = 'list' }: Props) {
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) })) const realResults = results.filter(r => r.resultType !== 'FUTURE_AVAILABILITY')
const signalResults = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY')
const tiers = TIER_CONFIG.map(t => ({ ...t, items: realResults.filter(t.filter) }))
.filter(t => t.items.length > 0) .filter(t => t.items.length > 0)
function renderCards(items: UnifiedMatchResult[]) {
return view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
{items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
))}
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="list" />
))}
</Box>
)
}
return ( return (
<Box> <Box>
{/* Real properties — scored tiers */}
{tiers.map((tier, idx) => ( {tiers.map((tier, idx) => (
<Box key={tier.key}> <Box key={tier.key}>
<TierHeader <TierHeader
@@ -87,21 +143,17 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
count={tier.items.length} count={tier.items.length}
isFirst={idx === 0} isFirst={idx === 0}
/> />
{view === 'grid' ? ( {renderCards(tier.items)}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
{tier.items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
))}
</Box> </Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{tier.items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="list" />
))} ))}
{/* Schattenmarkt signals — dedicated section */}
{signalResults.length > 0 && (
<Box>
<SchattenmarktSectionHeader count={signalResults.length} />
{renderCards(signalResults)}
</Box> </Box>
)} )}
</Box> </Box>
))}
</Box>
) )
} }
@@ -113,6 +113,7 @@ export function buildMatchCardViewModel(
isStaleData: false, isStaleData: false,
// Schattenmarkt signal fields // Schattenmarkt signal fields
signalSourceType: signal?.source?.type, signalSourceType: signal?.source?.type,
signalSourceUrl: signal?.source?.url,
signalSourceCredibility: signal?.source?.credibility, signalSourceCredibility: signal?.source?.credibility,
signalProbability: signal?.probability, signalProbability: signal?.probability,
signalMarketIndicator: signal?.marketIndicator, signalMarketIndicator: signal?.marketIndicator,