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:
+68
-387
@@ -1,191 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { useNewListingForm } from '../../hooks/useNewListingForm'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react'
|
||||
import { useCreateProperty } from '../../hooks/useProperties'
|
||||
import { useParseListingText } from '../../hooks/useAI'
|
||||
import {
|
||||
ASSET_TYPE_LABELS,
|
||||
SOFT_FACTORS,
|
||||
LEVEL_OPTIONS,
|
||||
FIT_OUT_OPTIONS,
|
||||
type LocationState,
|
||||
} from './newListingConstants'
|
||||
import { buildCreatePropertyInput } from './newListingMapper'
|
||||
AiAssistCard,
|
||||
AreaDetailsSection,
|
||||
AddressSection,
|
||||
SoftFactorsSection,
|
||||
TechnicalDetailsSection,
|
||||
ImageUrlSection,
|
||||
ContactSection,
|
||||
CreatedScreen,
|
||||
} from '../../components/new-listing'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
import type { LocationState } from './newListingConstants'
|
||||
|
||||
export default function NewListing() {
|
||||
const navigate = useNavigate()
|
||||
const { state } = useLocation() as { state: LocationState | null }
|
||||
const pre = state?.prefill ?? {}
|
||||
|
||||
const createProperty = useCreateProperty()
|
||||
const parseListingMutation = useParseListingText()
|
||||
const form = useNewListingForm(pre)
|
||||
|
||||
// Core fields
|
||||
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('')
|
||||
|
||||
// Soft factors
|
||||
const [softLevels, setSoftLevels] = useState<Record<string, string>>(
|
||||
Object.fromEntries(SOFT_FACTORS.map(f => [f.key, '']))
|
||||
)
|
||||
|
||||
// Hard facts
|
||||
const [floor, setFloor] = useState('')
|
||||
const [fitOut, setFitOut] = useState('')
|
||||
const [parking, setParking] = useState('')
|
||||
const [ceilingHeight, setCeilingHeight] = useState('')
|
||||
|
||||
// Images
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [imageInput, setImageInput] = useState('')
|
||||
|
||||
// AI
|
||||
const [aiText, setAiText] = useState('')
|
||||
const [aiApplied, setAiApplied] = useState(false)
|
||||
|
||||
// Submit
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [created, setCreated] = useState(false)
|
||||
|
||||
const aiParsing = parseListingMutation.isPending
|
||||
const submitting = createProperty.isPending
|
||||
|
||||
const isPrefilled = !!pre.propertyId
|
||||
|
||||
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 addImage() {
|
||||
const url = imageInput.trim()
|
||||
if (url && !images.includes(url)) {
|
||||
setImages(prev => [...prev, url])
|
||||
}
|
||||
setImageInput('')
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
if (!street.trim()) return 'Strasse erforderlich'
|
||||
if (!postalCode.trim()) return 'PLZ erforderlich'
|
||||
if (!city.trim()) return 'Ort erforderlich'
|
||||
if (!areaSqm || isNaN(Number(areaSqm)) || Number(areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||
if (!rentPerSqm || isNaN(Number(rentPerSqm)) || Number(rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const err = validate()
|
||||
if (err) { setError(err); return }
|
||||
setError(null)
|
||||
|
||||
const input = buildCreatePropertyInput({
|
||||
assetType,
|
||||
street,
|
||||
houseNumber,
|
||||
postalCode,
|
||||
city,
|
||||
areaSqm: Number(areaSqm),
|
||||
rentPerSqm: Number(rentPerSqm),
|
||||
availableFrom,
|
||||
description,
|
||||
softLevels,
|
||||
floor,
|
||||
fitOut,
|
||||
parking,
|
||||
ceilingHeight,
|
||||
images,
|
||||
})
|
||||
|
||||
createProperty.mutate(input, {
|
||||
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(Object.fromEntries(SOFT_FACTORS.map(f => [f.key, ''])))
|
||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||
setImages([]); setImageInput('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
setCreated(false)
|
||||
}
|
||||
|
||||
if (created) {
|
||||
if (form.created) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
|
||||
<CheckCircle size={48} color="#16a34a" style={{ marginBottom: 16 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/supply/my-listings')}>
|
||||
Meine Inserate
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={resetForm}>
|
||||
Weiteres Inserat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<CreatedScreen
|
||||
onViewListings={() => navigate('/supply/my-listings')}
|
||||
onCreateAnother={form.resetForm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 760, mx: 'auto', px: 3, py: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ color: '#64748b', textTransform: 'none', px: 0 }}
|
||||
sx={{ color: DS_TEXT.muted, textTransform: 'none', px: 0 }}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
@@ -193,246 +47,73 @@ export default function NewListing() {
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{isPrefilled
|
||||
{form.isPrefilled
|
||||
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
|
||||
: 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'}
|
||||
</Typography>
|
||||
|
||||
{/* AI Hilfe */}
|
||||
<Card sx={{ p: 3, mb: 3, border: '1px solid #e0e7ff', bgcolor: '#f5f3ff' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Sparkles size={16} color="#7c3aed" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed' }}>
|
||||
KI-Hilfe — Formular automatisch ausfüllen
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
|
||||
value={aiText}
|
||||
onChange={e => setAiText(e.target.value)}
|
||||
sx={{ mb: 1.5, bgcolor: '#fff' }}
|
||||
/>
|
||||
{aiApplied && (
|
||||
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen.
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={aiParsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
|
||||
onClick={handleAiParse}
|
||||
disabled={!aiText.trim() || aiParsing}
|
||||
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
|
||||
>
|
||||
{aiParsing ? 'Analysiert…' : 'KI analysieren'}
|
||||
</Button>
|
||||
</Card>
|
||||
<AiAssistCard
|
||||
text={form.aiText}
|
||||
onTextChange={form.setAiText}
|
||||
onParse={form.handleAiParse}
|
||||
parsing={form.aiParsing}
|
||||
applied={form.aiApplied}
|
||||
/>
|
||||
|
||||
{/* Flächendetails */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
select label="Flächentyp" value={assetType}
|
||||
onChange={e => setAssetType(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
|
||||
<MenuItem key={v} value={v}>{l}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AreaDetailsSection
|
||||
assetType={form.assetType} onAssetTypeChange={form.setAssetType}
|
||||
areaSqm={form.areaSqm} onAreaSqmChange={form.setAreaSqm}
|
||||
rentPerSqm={form.rentPerSqm} onRentPerSqmChange={form.setRentPerSqm}
|
||||
availableFrom={form.availableFrom} onAvailableFromChange={form.setAvailableFrom}
|
||||
description={form.description} onDescriptionChange={form.setDescription}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => setAreaSqm(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
<AddressSection
|
||||
street={form.street} onStreetChange={form.setStreet}
|
||||
houseNumber={form.houseNumber} onHouseNumberChange={form.setHouseNumber}
|
||||
postalCode={form.postalCode} onPostalCodeChange={form.setPostalCode}
|
||||
city={form.city} onCityChange={form.setCity}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => setRentPerSqm(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
<SoftFactorsSection softLevels={form.softLevels} onChange={form.setSoftLevel} />
|
||||
|
||||
<TextField
|
||||
label="Verfügbar ab" value={availableFrom}
|
||||
onChange={e => setAvailableFrom(e.target.value)}
|
||||
size="small" type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Beschreibung (optional)" value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
<TechnicalDetailsSection
|
||||
floor={form.floor} onFloorChange={form.setFloor}
|
||||
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
|
||||
parking={form.parking} onParkingChange={form.setParking}
|
||||
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
|
||||
/>
|
||||
|
||||
{/* Adresse */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Strasse" value={street}
|
||||
onChange={e => setStreet(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Nr." value={houseNumber}
|
||||
onChange={e => setHouseNumber(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="PLZ" value={postalCode}
|
||||
onChange={e => setPostalCode(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Ort" value={city}
|
||||
onChange={e => setCity(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<ImageUrlSection
|
||||
images={form.images}
|
||||
imageInput={form.imageInput}
|
||||
onImageInputChange={form.setImageInput}
|
||||
onAdd={form.addImage}
|
||||
onRemove={form.removeImage}
|
||||
/>
|
||||
|
||||
{/* Lage & Ausstrahlung */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Diese Angaben verbessern die Match-Qualität erheblich.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
|
||||
{SOFT_FACTORS.map(({ key, label }) => (
|
||||
<TextField
|
||||
key={key}
|
||||
select
|
||||
label={label}
|
||||
value={softLevels[key] ?? ''}
|
||||
onChange={e => setSoftLevels(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{LEVEL_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
<ContactSection
|
||||
name={form.contactName} onNameChange={form.setContactName}
|
||||
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
||||
phone={form.contactPhone} onPhoneChange={form.setContactPhone}
|
||||
/>
|
||||
|
||||
{/* Technische Details */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Stockwerk" value={floor}
|
||||
onChange={e => setFloor(e.target.value)}
|
||||
size="small" type="number" fullWidth
|
||||
placeholder="0 = EG"
|
||||
/>
|
||||
<TextField
|
||||
select label="Ausbaustandard" value={fitOut}
|
||||
onChange={e => setFitOut(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{FIT_OUT_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => setParking(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
onChange={e => setCeilingHeight(e.target.value)}
|
||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Bilder */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||
<TextField
|
||||
label="Bild-URL eingeben"
|
||||
value={imageInput}
|
||||
onChange={e => setImageInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ImagePlus size={15} />}
|
||||
onClick={addImage}
|
||||
disabled={!imageInput.trim()}
|
||||
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
{images.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{images.map((url, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
|
||||
size="small"
|
||||
onDelete={() => setImages(prev => prev.filter((_, j) => j !== i))}
|
||||
sx={{ maxWidth: 300 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name" value={contactName}
|
||||
onChange={e => setContactName(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon" value={contactPhone}
|
||||
onChange={e => setContactPhone(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="E-Mail" value={contactEmail}
|
||||
onChange={e => setContactEmail(e.target.value)}
|
||||
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{form.error && <Alert severity="error" sx={{ mb: 2 }}>{form.error}</Alert>}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={() => navigate(-1)} disabled={submitting}>
|
||||
<Button variant="outlined" onClick={() => navigate(-1)} disabled={form.submitting}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
startIcon={submitting ? <CircularProgress size={14} color="inherit" /> : null}
|
||||
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }}
|
||||
onClick={form.handleSubmit}
|
||||
disabled={form.submitting}
|
||||
startIcon={form.submitting ? <CircularProgress size={14} color="inherit" /> : null}
|
||||
>
|
||||
{submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
|
||||
{form.submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user