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:
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user