feat: add campaign configuration controls

This commit is contained in:
2026-06-04 14:45:47 +02:00
parent 07841aea0f
commit 585c4eeb2a
24 changed files with 2941 additions and 34 deletions

View File

@@ -0,0 +1,407 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod/v4";
import {
campaignFormDefaults,
campaignFormSchema,
mapCampaignFormToPayload,
} from "@/lib/campaign-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
type CampaignFormValues = z.infer<typeof campaignFormSchema>;
type CampaignFormSeed = Partial<CampaignFormValues> & {
_id?: string;
};
type CampaignFormDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
campaign?: CampaignFormSeed | null;
onSubmit: (
payload: Omit<CampaignFormValues, "status"> &
Required<Pick<CampaignFormValues, "status">> & {
countryCode: "DE";
country: "Deutschland";
},
) => Promise<void>;
};
const categoryOptions = [
"Anwalt",
"Bauunternehmen",
"Friseur",
"Gastronomie",
"Handwerk",
"Immobilien",
"Kfz-Werkstatt",
"Marketing",
"Restaurant",
"Zahnarzt",
"Anderes",
] as const;
const recurrenceOptions: Record<CampaignFormValues["recurrence"], string> = {
manual: "manuell",
daily: "täglich",
weekly: "wöchentlich",
monthly: "monatlich",
};
const statusLabel: Record<CampaignFormValues["status"], string> = {
active: "Aktiv",
paused: "Pausiert",
};
const customCategoryValue = "Anderes";
export function CampaignFormDialog({
open,
onOpenChange,
campaign,
onSubmit,
}: CampaignFormDialogProps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const methods = useForm<CampaignFormValues>({
resolver: zodResolver(campaignFormSchema),
defaultValues: campaignFormDefaults,
});
const {
control,
reset,
setValue,
formState: { isSubmitting },
} = methods;
const selectedCategory = useWatch({
control,
name: "category",
defaultValue: campaignFormDefaults.category,
});
const selectedStatus = useWatch({
control,
name: "status",
defaultValue: campaignFormDefaults.status,
});
const showCustomSearch = selectedCategory === customCategoryValue;
useEffect(() => {
const defaults = campaign
? {
status: campaign.status ?? campaignFormDefaults.status,
categoryMode: campaign.categoryMode ?? campaignFormDefaults.categoryMode,
recurrence: campaign.recurrence ?? campaignFormDefaults.recurrence,
radiusKm: campaign.radiusKm ?? campaignFormDefaults.radiusKm,
maxNewLeadsPerRun:
campaign.maxNewLeadsPerRun ?? campaignFormDefaults.maxNewLeadsPerRun,
maxAuditsPerRun:
campaign.maxAuditsPerRun ?? campaignFormDefaults.maxAuditsPerRun,
name: campaign.name ?? "",
category: campaign.category ?? "",
customSearchTerm: campaign.customSearchTerm ?? "",
postalCode: campaign.postalCode ?? campaignFormDefaults.postalCode,
}
: campaignFormDefaults;
reset(defaults);
}, [campaign, reset]);
useEffect(() => {
if (showCustomSearch) {
setValue("categoryMode", "custom");
return;
}
setValue("categoryMode", "preset");
setValue("customSearchTerm", "");
}, [showCustomSearch, setValue]);
const dialogTitle = useMemo(
() => (campaign ? "Kampagne bearbeiten" : "Kampagne anlegen"),
[campaign],
);
const submitLabel = useMemo(
() => (campaign ? "Speichern" : "Erstellen"),
[campaign],
);
const submitForm = async (values: CampaignFormValues) => {
setPending(true);
setError(null);
try {
const payload = mapCampaignFormToPayload(values as Record<string, unknown>);
await onSubmit({
...payload,
status: values.status,
categoryMode: values.categoryMode,
category: values.category,
customSearchTerm: values.customSearchTerm || undefined,
postalCode: values.postalCode,
radiusKm: values.radiusKm,
recurrence: values.recurrence,
maxNewLeadsPerRun: values.maxNewLeadsPerRun,
maxAuditsPerRun: values.maxAuditsPerRun,
name: values.name,
});
onOpenChange(false);
} catch {
setError("Speichern fehlgeschlagen.");
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
</DialogDescription>
<DialogCloseButton />
</DialogHeader>
<Form form={methods} onSubmit={submitForm}>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOptions.map((category) => (
<SelectItem value={category} key={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{showCustomSearch && (
<FormField
control={control}
name="customSearchTerm"
render={({ field }) => (
<FormItem>
<FormLabel>Eigene Nische</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="Beispiel: Webdesigner für Restaurants"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>PLZ</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
inputMode="numeric"
maxLength={5}
placeholder="10115"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="radiusKm"
render={({ field }) => (
<FormItem>
<FormLabel>Radius (km)</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
step={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="recurrence"
render={({ field }) => (
<FormItem>
<FormLabel>Wiederholung</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Wiederholung wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(recurrenceOptions).map(([value, label]) => (
<SelectItem value={value} key={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="maxNewLeadsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. neue Leads</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="maxAuditsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Audits</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<div className="flex items-center gap-3">
<FormControl>
<Switch
checked={field.value === "active"}
onCheckedChange={(checked) =>
field.onChange(checked ? "active" : "paused")
}
/>
</FormControl>
<span>{statusLabel[selectedStatus ?? "paused"]}</span>
</div>
<FormMessage />
</FormItem>
)}
/>
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
<div className="flex flex-wrap gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting || pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || pending}>
{isSubmitting || pending ? "Speichert..." : submitLabel}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);
}