383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
"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 Lead-Limit 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>
|
|
)}
|
|
/>
|
|
|
|
<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="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>
|
|
);
|
|
}
|