feat: expand NewListing with AI, soft factors, hard facts, images + MyListings manager

- KI-Hilfe: text description → auto-fills all fields (parseListingText mock parser)
- Lage & Ausstrahlung: 9 soft factor selects (Tief/Mittel/Hoch) mapped to scoring engine
- Technische Details: floor, fit-out, parking, ceiling height
- Bilder: URL list with add/remove
- New /supply/my-listings page: list, status toggle, delete for DIRECT listings
- Added sourceType filter to PropertyFilters + MockupPropertyProvider
- Nav entry "Meine Inserate" added to supply sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-21 21:19:45 +02:00
parent 0dac528f98
commit c9324e9cc8
7 changed files with 635 additions and 95 deletions
+75
View File
@@ -445,3 +445,78 @@ export const aiService = {
}
},
}
// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
export interface ParsedListingData {
assetType?: string
areaSqm?: number
rentPerSqm?: number
city?: string
softLevels?: Record<string, string>
parking?: number
fitOut?: string
}
export async function parseListingText(text: string): Promise<ParsedListingData> {
await new Promise(r => setTimeout(r, 900))
const t = text.toLowerCase()
const result: ParsedListingData = {}
// Asset type
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
result.assetType = 'RETAIL'
} else if (t.includes('lager') || t.includes('logistik')) {
result.assetType = 'LOGISTICS'
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
result.assetType = 'PRODUCTION'
} else {
result.assetType = 'OFFICE'
}
// Area
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
// Price — if raw < 500 assume monthly → convert to annual
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
if (priceMatch) {
const raw = parseInt(priceMatch[1])
result.rentPerSqm = raw < 500 ? raw * 12 : raw
}
// City
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
for (const city of CITIES) {
if (t.includes(city.toLowerCase())) { result.city = city; break }
}
// Soft factors
const soft: Record<string, string> = {}
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
? 'LOW' : 'MEDIUM'
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
? 'LOW' : 'MEDIUM'
if (result.assetType === 'RETAIL') {
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
soft.footfall = soft.visibility
}
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
soft.prestige = 'HIGH'
result.fitOut = 'PREMIUM'
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
result.fitOut = 'BASIC'
}
result.softLevels = soft
// Parking
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
return result
}