feat(demand): Ergebnis-Grid — Spalten-Umschalter, sauberere Karten, mehr Abstand
- 3/5/10-Spalten-Umschalter im Ergebnis-Header (nur Grid-Ansicht, in localStorage gemerkt) analog zu "Meine Objekte" - Karten-Bild auf aspectRatio 15/8 statt fester Höhe → keine Verzerrung bei beliebiger Spaltenzahl - Orts-Overlay vom Bild entfernt → Match-Score überdeckt das Ort-Label nicht mehr - Sidebar bleibt bis < lg (1200px) ausgeklappt → Navigation + Account auf Laptops sichtbar - Größere vertikale Abstände im Ergebnis-Feed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,13 +27,13 @@ export function AppShell() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||||
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
|
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
// Auto-collapse sidebar on compact screens (laptops < 1536px)
|
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
|
||||||
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
||||||
|
|
||||||
// Sync active workspace with URL
|
// Sync active workspace with URL
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
overlayTypeLabel?: string
|
|
||||||
overlayLocationLabel?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
@@ -59,7 +57,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} aspectRatio="15/8" />
|
||||||
|
|
||||||
{/* Score badge — bottom-left editorial */}
|
{/* Score badge — bottom-left editorial */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface Props {
|
|||||||
futureCount: number
|
futureCount: number
|
||||||
view?: 'list' | 'grid'
|
view?: 'list' | 'grid'
|
||||||
onViewChange?: (v: 'list' | 'grid') => void
|
onViewChange?: (v: 'list' | 'grid') => void
|
||||||
|
columns?: 3 | 5 | 10
|
||||||
|
onColumnsChange?: (n: 3 | 5 | 10) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange }: Props) {
|
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange, columns = 3, onColumnsChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -21,9 +23,30 @@ export function ResultFeedHeader({ total, platformCount, maisonWorkCount, future
|
|||||||
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
|
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
{view === 'grid' && onColumnsChange && (
|
||||||
|
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||||
|
{([3, 5, 10] as const).map(n => (
|
||||||
|
<Box
|
||||||
|
key={n}
|
||||||
|
onClick={() => onColumnsChange(n)}
|
||||||
|
sx={{
|
||||||
|
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
|
||||||
|
bgcolor: columns === n ? '#152642' : 'transparent',
|
||||||
|
color: columns === n ? 'white' : '#64748b',
|
||||||
|
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{onViewChange && (
|
{onViewChange && (
|
||||||
<ViewToggle view={view} onChange={onViewChange} />
|
<ViewToggle view={view} onChange={onViewChange} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -98,17 +97,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
? result.property.location.city
|
? result.property.location.city
|
||||||
: result.signal.locationHint ?? undefined
|
: result.signal.locationHint ?? undefined
|
||||||
|
|
||||||
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
|
||||||
? resolveImageLabel({
|
|
||||||
assetType: result.property.assetType,
|
|
||||||
city: result.property.location.city,
|
|
||||||
district: result.property.location.district,
|
|
||||||
prestige: result.property.softFactors?.prestige,
|
|
||||||
floorLevel: result.property.floorLevel,
|
|
||||||
propertyId: result.property.id,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntelligenceMatchCard
|
<IntelligenceMatchCard
|
||||||
vm={vm}
|
vm={vm}
|
||||||
@@ -116,8 +104,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
lat={lat}
|
lat={lat}
|
||||||
lng={lng}
|
lng={lng}
|
||||||
cityLabel={cityLabel}
|
cityLabel={cityLabel}
|
||||||
overlayTypeLabel={overlayLabels?.type}
|
|
||||||
overlayLocationLabel={overlayLabels?.location}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SCORE_THEME } from '../shared/scoreTheme'
|
|||||||
interface Props {
|
interface Props {
|
||||||
results: UnifiedMatchResult[]
|
results: UnifiedMatchResult[]
|
||||||
view?: 'list' | 'grid'
|
view?: 'list' | 'grid'
|
||||||
|
columns?: 3 | 5 | 10
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_CONFIG = [
|
const TIER_CONFIG = [
|
||||||
@@ -40,7 +41,7 @@ interface TierHeaderProps {
|
|||||||
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
|
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
|
||||||
const theme = SCORE_THEME[tierKey]
|
const theme = SCORE_THEME[tierKey]
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 4.5, mb: 2.5 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 4,
|
width: 4,
|
||||||
@@ -72,7 +73,7 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
export function UnifiedResultFeed({ results, view = 'list', columns = 3 }: Props) {
|
||||||
// All results — real properties and Schattenmarkt signals — are sorted into
|
// All results — real properties and Schattenmarkt signals — are sorted into
|
||||||
// the same score tiers so they appear in a single unified ranked list.
|
// the same score tiers so they appear in a single unified ranked list.
|
||||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
||||||
@@ -80,7 +81,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
|||||||
|
|
||||||
function renderCards(items: UnifiedMatchResult[]) {
|
function renderCards(items: UnifiedMatchResult[]) {
|
||||||
return view === 'grid' ? (
|
return view === 'grid' ? (
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3 }}>
|
||||||
{items.map(result => (
|
{items.map(result => (
|
||||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
/** When set, the hero scales proportionally with width (e.g. '15/8') instead of using a fixed pixel height. */
|
||||||
|
aspectRatio?: string
|
||||||
overlayTypeLabel?: string
|
overlayTypeLabel?: string
|
||||||
overlayLocationLabel?: string
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
@@ -23,13 +25,13 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
|||||||
return 'https://www.google.com/maps'
|
return 'https://www.google.com/maps'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, aspectRatio, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||||
const showImage = !!imageUrl && !imgError
|
const showImage = !!imageUrl && !imgError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'relative', width: '100%', height, overflow: 'hidden', flexShrink: 0 }}>
|
<Box sx={{ position: 'relative', width: '100%', ...(aspectRatio ? { aspectRatio } : { height }), overflow: 'hidden', flexShrink: 0 }}>
|
||||||
{showImage ? (
|
{showImage ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default function Results() {
|
|||||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||||
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
||||||
)
|
)
|
||||||
|
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
|
||||||
|
(Number(localStorage.getItem('view-results-cols')) as 3 | 5 | 10) || 3
|
||||||
|
)
|
||||||
|
|
||||||
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
||||||
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
||||||
@@ -107,9 +110,11 @@ export default function Results() {
|
|||||||
futureCount={futureCount}
|
futureCount={futureCount}
|
||||||
view={view}
|
view={view}
|
||||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
||||||
|
columns={columns}
|
||||||
|
onColumnsChange={n => { setColumns(n); localStorage.setItem('view-results-cols', String(n)) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 4, pb: 12 }}>
|
||||||
|
|
||||||
{/* Aktive Suche */}
|
{/* Aktive Suche */}
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
@@ -169,7 +174,7 @@ export default function Results() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<UnifiedResultFeed results={sorted} view={view} />
|
<UnifiedResultFeed results={sorted} view={view} columns={columns} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user