refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble) fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy - Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds() with optional refetchOnMount/gcTime overrides - NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under src/components/new-listing/; page shell reduced to 121 lines - AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/ fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns, MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection, prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision) - Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react'
|
||||
import { useCreateProperty } from './useProperties'
|
||||
import { useParseListingText } from './useAI'
|
||||
import { SOFT_FACTORS } from '../pages/supply/newListingConstants'
|
||||
import { buildCreatePropertyInput } from '../pages/supply/newListingMapper'
|
||||
import type { Prefill } from '../pages/supply/newListingConstants'
|
||||
|
||||
function emptySoftLevels(): Record<string, string> {
|
||||
return Object.fromEntries(SOFT_FACTORS.map(f => [f.key, '']))
|
||||
}
|
||||
|
||||
function validate(fields: {
|
||||
street: string
|
||||
postalCode: string
|
||||
city: string
|
||||
areaSqm: string
|
||||
rentPerSqm: string
|
||||
}): string | null {
|
||||
if (!fields.street.trim()) return 'Strasse erforderlich'
|
||||
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
|
||||
if (!fields.city.trim()) return 'Ort erforderlich'
|
||||
if (!fields.areaSqm || isNaN(Number(fields.areaSqm))
|
||||
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
||||
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||
return null
|
||||
}
|
||||
|
||||
export interface NewListingFormState {
|
||||
// Core
|
||||
assetType: string
|
||||
areaSqm: string
|
||||
rentPerSqm: string
|
||||
availableFrom: string
|
||||
description: string
|
||||
// Address
|
||||
street: string
|
||||
houseNumber: string
|
||||
postalCode: string
|
||||
city: string
|
||||
// Soft factors
|
||||
softLevels: Record<string, string>
|
||||
// Technical
|
||||
floor: string
|
||||
fitOut: string
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
// Contact
|
||||
contactName: string
|
||||
contactEmail: string
|
||||
contactPhone: string
|
||||
// Images
|
||||
images: string[]
|
||||
imageInput: string
|
||||
// AI
|
||||
aiText: string
|
||||
aiApplied: boolean
|
||||
// Status
|
||||
error: string | null
|
||||
created: boolean
|
||||
aiParsing: boolean
|
||||
submitting: boolean
|
||||
isPrefilled: boolean
|
||||
}
|
||||
|
||||
export interface NewListingFormHandlers {
|
||||
setAssetType: (v: string) => void
|
||||
setAreaSqm: (v: string) => void
|
||||
setRentPerSqm: (v: string) => void
|
||||
setAvailableFrom: (v: string) => void
|
||||
setDescription: (v: string) => void
|
||||
setStreet: (v: string) => void
|
||||
setHouseNumber: (v: string) => void
|
||||
setPostalCode: (v: string) => void
|
||||
setCity: (v: string) => void
|
||||
setSoftLevel: (key: string, value: string) => void
|
||||
setFloor: (v: string) => void
|
||||
setFitOut: (v: string) => void
|
||||
setParking: (v: string) => void
|
||||
setCeilingHeight: (v: string) => void
|
||||
setContactName: (v: string) => void
|
||||
setContactEmail: (v: string) => void
|
||||
setContactPhone: (v: string) => void
|
||||
setImageInput: (v: string) => void
|
||||
setAiText: (v: string) => void
|
||||
addImage: () => void
|
||||
removeImage: (index: number) => void
|
||||
handleAiParse: () => void
|
||||
handleSubmit: () => void
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
export function useNewListingForm(pre: Prefill): NewListingFormState & NewListingFormHandlers {
|
||||
const createProperty = useCreateProperty()
|
||||
const parseListingMutation = useParseListingText()
|
||||
|
||||
const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE')
|
||||
const [street, setStreet] = useState(pre.street ?? '')
|
||||
const [houseNumber, setHouseNumber] = useState(pre.houseNumber ?? '')
|
||||
const [postalCode, setPostalCode] = useState(pre.postalCode ?? '')
|
||||
const [city, setCity] = useState(pre.city ?? '')
|
||||
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
|
||||
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
|
||||
const [availableFrom, setAvailableFrom] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contactName, setContactName] = useState('')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [contactPhone, setContactPhone] = useState('')
|
||||
const [softLevels, setSoftLevels] = useState<Record<string, string>>(emptySoftLevels)
|
||||
const [floor, setFloor] = useState('')
|
||||
const [fitOut, setFitOut] = useState('')
|
||||
const [parking, setParking] = useState('')
|
||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [imageInput, setImageInput] = useState('')
|
||||
const [aiText, setAiText] = useState('')
|
||||
const [aiApplied, setAiApplied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [created, setCreated] = useState(false)
|
||||
|
||||
function setSoftLevel(key: string, value: string) {
|
||||
setSoftLevels(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
const url = imageInput.trim()
|
||||
if (url && !images.includes(url)) setImages(prev => [...prev, url])
|
||||
setImageInput('')
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
setImages(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleAiParse() {
|
||||
if (!aiText.trim()) return
|
||||
parseListingMutation.mutate(aiText, {
|
||||
onSuccess: (parsed) => {
|
||||
if (parsed.assetType) setAssetType(parsed.assetType)
|
||||
if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm))
|
||||
if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm))
|
||||
if (parsed.city && !city) setCity(parsed.city)
|
||||
if (parsed.fitOut) setFitOut(parsed.fitOut)
|
||||
if (parsed.parking) setParking(String(parsed.parking))
|
||||
if (parsed.softLevels) setSoftLevels(prev => ({ ...prev, ...parsed.softLevels }))
|
||||
setAiApplied(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
|
||||
if (err) { setError(err); return }
|
||||
setError(null)
|
||||
createProperty.mutate(
|
||||
buildCreatePropertyInput({
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||
availableFrom, description, softLevels,
|
||||
floor, fitOut, parking, ceilingHeight, images,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => setCreated(true),
|
||||
onError: () => setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.'),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setAssetType('OFFICE')
|
||||
setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('')
|
||||
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||
setImages([]); setImageInput('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
setCreated(false); setError(null)
|
||||
}
|
||||
|
||||
return {
|
||||
assetType, areaSqm, rentPerSqm, availableFrom, description,
|
||||
street, houseNumber, postalCode, city,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||
contactName, contactEmail, contactPhone,
|
||||
images, imageInput, aiText, aiApplied,
|
||||
error, created,
|
||||
aiParsing: parseListingMutation.isPending,
|
||||
submitting: createProperty.isPending,
|
||||
isPrefilled: !!pre.propertyId,
|
||||
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
|
||||
setStreet, setHouseNumber, setPostalCode, setCity,
|
||||
setSoftLevel,
|
||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||
setContactName, setContactEmail, setContactPhone,
|
||||
setImageInput, setAiText,
|
||||
addImage, removeImage,
|
||||
handleAiParse, handleSubmit, resetForm,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user