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:
Benjamin Sutter
2026-05-24 16:10:39 +02:00
parent e36c5bc979
commit e1f4beb898
44 changed files with 1610 additions and 1058 deletions
+7 -1
View File
@@ -2,11 +2,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
export function useNeeds() {
interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
gcTime?: number
}
export function useNeeds(options?: UseNeedsOptions) {
return useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
select: (res) => res.data ?? [],
...options,
})
}
+200
View File
@@ -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,
}
}