Files
property-match/src/components/results/UnifiedResultFeed.tsx
T
Benjamin Sutter 9cf3e72033 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>
2026-05-18 21:59:08 +02:00

160 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { UnifiedResultCard } from './UnifiedResultCard'
import { SCORE_THEME } from '../shared/scoreTheme'
interface Props {
results: UnifiedMatchResult[]
view?: 'list' | 'grid'
}
const TIER_CONFIG = [
{
key: 'gold' as const,
label: 'Top Matches',
sublabel: '90100 Punkte',
filter: (r: UnifiedMatchResult) => r.matchScore >= 90,
},
{
key: 'silver' as const,
label: 'Starke Matches',
sublabel: '8089 Punkte',
filter: (r: UnifiedMatchResult) => r.matchScore >= 80 && r.matchScore < 90,
},
{
key: 'bronze' as const,
label: 'Weitere Treffer',
sublabel: 'unter 80 Punkte',
filter: (r: UnifiedMatchResult) => r.matchScore < 80,
},
]
interface TierHeaderProps {
label: string
sublabel: string
tierKey: 'gold' | 'silver' | 'bronze'
count: number
isFirst: boolean
}
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
const theme = SCORE_THEME[tierKey]
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
<Box
sx={{
width: 4,
height: 28,
borderRadius: 2,
background: theme.gradient,
flexShrink: 0,
}}
/>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: theme.text === '#7a4f00' ? '#92400e' : theme.text === '#334155' ? '#1e293b' : '#7c3d12' }}>
{label}
</Typography>
<Typography variant="caption" color="text.secondary">{sublabel}</Typography>
</Box>
<Chip
label={count}
size="small"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
background: theme.gradient,
color: theme.text,
border: `1px solid ${theme.border}`,
}}
/>
<Divider sx={{ flex: 1 }} />
</Box>
)
}
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) {
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)
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 (
<Box>
{/* Real properties — scored tiers */}
{tiers.map((tier, idx) => (
<Box key={tier.key}>
<TierHeader
label={tier.label}
sublabel={tier.sublabel}
tierKey={tier.key}
count={tier.items.length}
isFirst={idx === 0}
/>
{renderCards(tier.items)}
</Box>
))}
{/* Schattenmarkt signals — dedicated section */}
{signalResults.length > 0 && (
<Box>
<SchattenmarktSectionHeader count={signalResults.length} />
{renderCards(signalResults)}
</Box>
)}
</Box>
)
}