feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool

- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?;
  FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular
- Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen,
  Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button
- Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel,
  newListingConstants, MyListings, propertyHelpers)
- "Zum Originalinserat"-Button nur bei Maison-Work-Objekten
- Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder),
  nur Innenaufnahmen, rotate()-Trick für Sub-Pools
- Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 22:12:43 +02:00
parent 3ab6eeccf2
commit e95490eb72
39 changed files with 685 additions and 147 deletions
+1
View File
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
RETAIL: 'Retail',
GASTRO: 'Gastronomie',
LOGISTICS: 'Logistik',
LIGHT_INDUSTRIAL: 'Gewerbe',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
}
+144
View File
@@ -0,0 +1,144 @@
import { AssetType } from '../domain/enums'
// ── Image pools (curated Unsplash IDs, all w=800&h=400&fit=crop) ──────────────
// Rule: ALL images are interior shots — no exteriors, no city skylines, no landscapes.
// prettier-ignore
const OFFICE_IDS = [
'photo-1497366858526-0766c4080517', // premium reception & lobby
'photo-1497366811353-6870744d04b2', // floor-to-ceiling glass, elegant interior
'photo-1497366216548-37526070297c', // clean open-plan office
'photo-1454165804606-c3d57bc86b40', // corporate meeting / workspace
'photo-1568992687947-868a62a9f521', // contemporary office with plants
'photo-1572025442646-866d16c84a54', // bright modern office
'photo-1553028826-f4804a6dba3b', // open workspace, natural light
'photo-1534536281715-e28d76689b4d', // converted warehouse loft
'photo-1556761175-5973dc0f32e7', // industrial co-working, exposed concrete
'photo-1541746972996-4e0b0f43e02a', // creative loft workspace
'photo-1515187029135-18ee286d815b', // brick + open ceiling office
'photo-1504384308090-c894fdcc538d', // industrial-chic open space
]
// Rotate so each sub-pool maps the same sequential index to a different photo
const rotate = <T>(arr: T[], n: number): T[] => [...arr.slice(n), ...arr.slice(0, n)]
const POOL = {
office_premium: rotate(OFFICE_IDS, 0),
office_modern: rotate(OFFICE_IDS, 4),
office_industrial: rotate(OFFICE_IDS, 8),
logistics: [
'photo-1586528116311-ad8dd3c8310d',
'photo-1553413077-190dd305871c',
'photo-1587293852726-70cfa4d5f99f',
'photo-1566932769119-25a49e6b5c6d',
'photo-1474396651759-b49538a26a2d',
],
retail: [
'photo-1441986300917-64674bd600d8',
'photo-1567958451986-2de427a4a0be',
'photo-1555529669-e69e7aa0ba9a',
'photo-1604719312566-8912e9227c6a',
'photo-1555396273-367ea4eb4db5',
'photo-1472851294608-062f824d29cc',
'photo-1483985988355-763728e1802b',
'photo-1445205170230-053b83016050',
],
gastro: [
'photo-1414235077428-338989a2e8c0',
'photo-1517248135467-4c7edcad34c4',
'photo-1544148103-0773bf10d330',
'photo-1559339352-11d035aa65ce',
'photo-1466978913421-dad2ebd01d17',
'photo-1521017432531-fbd92d768814',
],
light_industrial: [
'photo-1565515636339-5de8c80eba38',
'photo-1581091226825-a6a2a5aee158',
'photo-1571008887538-b36bb32f4571',
'photo-1504307651254-35680f356dfd',
],
}
const BASE = 'https://images.unsplash.com/'
const PARAMS = '?w=800&h=400&fit=crop'
// Sequential pick — guaranteed unique until pool wraps (used at init time in mock data)
function pickAt(pool: string[], index: number): string {
return `${BASE}${pool[index % pool.length]}${PARAMS}`
}
// ── District sets (exported so properties.ts can replicate the pool-key logic) ──
export const PREMIUM_DISTRICTS = new Set([
'innenstadt', 'kreis 1', 'altstadt', 'seefeld', 'kreis 8',
'city', 'zürich city', 'bern innenstadt', 'zug innenstadt',
])
export const INDUSTRIAL_DISTRICTS = new Set([
'zürich-west', 'zürich west', 'west', 'binz', 'zürich-binz',
'altstetten', 'hürlimann', 'technopark', 'industrial', 'industriezone',
])
// ── Pool-key helper (exported so properties.ts can count per pool) ─────────────
export type PoolKey = keyof typeof POOL
export function getPoolKey(assetType: string | undefined, district: string, prestige: number): PoolKey {
switch (assetType) {
case AssetType.LOGISTICS: return 'logistics'
case AssetType.RETAIL: return 'retail'
case AssetType.GASTRO: return 'gastro'
case AssetType.LIGHT_INDUSTRIAL:
case AssetType.PRODUCTION: return 'light_industrial'
default: {
if (prestige >= 78 || PREMIUM_DISTRICTS.has(district)) return 'office_premium'
if (INDUSTRIAL_DISTRICTS.has(district)) return 'office_industrial'
return 'office_modern'
}
}
}
// ── Main resolver ─────────────────────────────────────────────────────────────
export interface ImageResolverParams {
assetType?: string
city?: string
district?: string
prestige?: number
floorLevel?: number
propertyId?: string
poolIndex?: number // sequential index within pool — set by properties.ts forEach for duplicate-free assignment
}
export function resolvePropertyImage(p: ImageResolverParams): string {
const district = (p.district ?? '').toLowerCase()
const prestige = p.prestige ?? 60
const key = getPoolKey(p.assetType, district, prestige)
const pool = POOL[key]
// Sequential index (set at mock-data init) guarantees no duplicates within a pool
if (p.poolIndex !== undefined) return pickAt(pool, p.poolIndex)
// Fallback: hash-based (used when poolIndex is unavailable, e.g. runtime calls)
let h = 0
const seed = p.propertyId ?? `${p.city}${p.district}${p.assetType}`
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
return pickAt(pool, h)
}
// ── Image overlay label ───────────────────────────────────────────────────────
const ASSET_LABELS: Partial<Record<string, string>> = {
[AssetType.OFFICE]: 'Bürofläche',
[AssetType.RETAIL]: 'Retail EG',
[AssetType.GASTRO]: 'Gastrofläche',
[AssetType.LIGHT_INDUSTRIAL]: 'Gewerbe',
[AssetType.LOGISTICS]: 'Logistik',
[AssetType.PRODUCTION]: 'Produktion',
[AssetType.MIXED]: 'Gewerbefläche',
}
export function resolveImageLabel(p: ImageResolverParams): { type: string; location: string } {
const type = ASSET_LABELS[p.assetType ?? ''] ?? 'Gewerbefläche'
const district = p.district ?? p.city ?? ''
const location = p.city && district !== p.city ? `${p.city} · ${district}` : (p.city ?? district)
return { type, location }
}