feat: add campaign configuration controls
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
import { CampaignsBoard } from "@/components/campaigns/campaigns-board";
|
||||||
|
|
||||||
export default function CampaignsPage() {
|
export default function CampaignsPage() {
|
||||||
return (
|
return (
|
||||||
<DashboardPlaceholderPage
|
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||||
description="Kampagnen-Konfiguration, PLZ, Radius, Limits und Laufplanung folgen in TASK-5."
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||||
title="Kampagnen"
|
<CampaignsBoard />
|
||||||
/>
|
</div>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-5
|
id: TASK-5
|
||||||
title: Implement campaign configuration and scheduling controls
|
title: Implement campaign configuration and scheduling controls
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:12'
|
created_date: '2026-06-03 19:12'
|
||||||
|
updated_date: '2026-06-04 12:44'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- campaigns
|
- campaigns
|
||||||
@@ -13,7 +14,7 @@ dependencies:
|
|||||||
references:
|
references:
|
||||||
- PRD.md
|
- PRD.md
|
||||||
priority: high
|
priority: high
|
||||||
ordinal: 5000
|
ordinal: 21000
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
@@ -24,11 +25,11 @@ Build the campaign management UI and backend mutations for reusable local search
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Campaign create/edit forms use React Hook Form, Zod, and shadcn form components
|
- [x] #1 Campaign create/edit forms use React Hook Form, Zod, and shadcn form components
|
||||||
- [ ] #2 Campaigns support predefined categories plus Anderes with a required custom input
|
- [x] #2 Campaigns support predefined categories plus Anderes with a required custom input
|
||||||
- [ ] #3 Campaigns store PLZ, radius, cadence, max new leads, max audits, active/paused state, and Germany-only context
|
- [x] #3 Campaigns store PLZ, radius, cadence, max new leads, max audits, active/paused state, and Germany-only context
|
||||||
- [ ] #4 Each campaign has a Jetzt ausführen action and shows last run, next run, and current run status
|
- [x] #4 Each campaign has a Jetzt ausführen action and shows last run, next run, and current run status
|
||||||
- [ ] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits
|
- [x] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -40,3 +41,21 @@ Build the campaign management UI and backend mutations for reusable local search
|
|||||||
4. Add run metadata fields for last run, next run, and current status.
|
4. Add run metadata fields for last run, next run, and current status.
|
||||||
5. Verify campaign forms and dashboard state transitions.
|
5. Verify campaign forms and dashboard state transitions.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started subagent-driven, test-driven implementation with Codex Spark workers. Orchestrator will enforce red-green cycles, spec review, code quality review, and final verification before requesting manual confirmation.
|
||||||
|
|
||||||
|
Implemented TASK-5 subagent-driven and test-driven. Backend/domain slice passed spec and quality review after fix loops. Frontend slice passed spec review and quality re-review after accessibility/timer fixes. Verification: pnpm test passed 40/40; pnpm exec tsc -p tsconfig.json passed; pnpm lint passed with only existing generated Convex warnings; pnpm build passed with network allowed for next/font assets. Local route check: /dashboard/campaigns returns 307 to /login without session; /login returns 200. Browser visual flow still needs authenticated manual testing before closing task as Done.
|
||||||
|
|
||||||
|
Bugfix after manual testing: campaign create dialog crashed because SelectContent was placed inside FormControl, giving FormControl multiple children and triggering React.Children.only. Fixed category and recurrence Select composition so FormControl wraps only SelectTrigger while SelectContent remains inside Select as a sibling. Verification after fix: pnpm exec tsc -p tsconfig.json passed; pnpm lint passed with only existing generated warnings; pnpm test passed 40/40; pnpm build passed.
|
||||||
|
|
||||||
|
Bugfix after manual testing: campaign create dialog emitted controlled/uncontrolled input warning because campaignFormDefaults lacked name/customSearchTerm while RHF rendered controlled inputs. Added empty string defaults and defensive customSearchTerm value fallback. Verification after fix: pnpm exec tsc -p tsconfig.json passed; pnpm test passed 40/40; pnpm lint passed with only existing generated warnings; pnpm build passed.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
TASK-5 shipped campaign configuration and scheduling controls: React Hook Form/Zod/shadcn create/edit forms, predefined categories plus Anderes custom niche, Convex campaign persistence with Germany-only context, run request/status metadata, pause/resume controls, German validation, and post-manual-test bugfixes for Select composition and controlled inputs.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
407
components/campaigns/campaign-form-dialog.tsx
Normal file
407
components/campaigns/campaign-form-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
454
components/campaigns/campaigns-board.tsx
Normal file
454
components/campaigns/campaigns-board.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { FunctionReturnType } from "convex/server";
|
||||||
|
import { MapPin, Pencil, Play, RefreshCcw, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { campaignFormDefaults } from "@/lib/campaign-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
||||||
|
|
||||||
|
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
||||||
|
type CampaignRow = NonNullable<CampaignsListResult>[number];
|
||||||
|
|
||||||
|
type RecurrenceLabel = Record<CampaignRow["recurrence"], string>;
|
||||||
|
type CurrentRunStatusLabel = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurrenceLabel: RecurrenceLabel = {
|
||||||
|
manual: "manuell",
|
||||||
|
daily: "täglich",
|
||||||
|
weekly: "wöchentlich",
|
||||||
|
monthly: "monatlich",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: CurrentRunStatusLabel = {
|
||||||
|
running: "Läuft",
|
||||||
|
pending: "Ausstehend",
|
||||||
|
succeeded: "Erledigt",
|
||||||
|
failed: "Fehlgeschlagen",
|
||||||
|
canceled: "Abgebrochen",
|
||||||
|
idle: "Leerlauf",
|
||||||
|
paused: "Pausiert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDateTime(value?: number | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "Nicht gesetzt";
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateFormatter.format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const formPayloadFromCampaign = (campaign?: CampaignRow | null) => {
|
||||||
|
if (!campaign) {
|
||||||
|
return campaignFormDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: campaign.status,
|
||||||
|
categoryMode: campaign.categoryMode,
|
||||||
|
recurrence: campaign.recurrence,
|
||||||
|
radiusKm: campaign.radiusKm,
|
||||||
|
maxNewLeadsPerRun: campaign.maxNewLeadsPerRun,
|
||||||
|
maxAuditsPerRun: campaign.maxAuditsPerRun,
|
||||||
|
name: campaign.name,
|
||||||
|
category: campaign.category,
|
||||||
|
customSearchTerm: campaign.customSearchTerm ?? "",
|
||||||
|
postalCode: campaign.postalCode,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNiche = (campaign: CampaignRow): string => {
|
||||||
|
if (campaign.category !== "Anderes") {
|
||||||
|
return campaign.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign.customSearchTerm?.trim()
|
||||||
|
? `${campaign.category}: ${campaign.customSearchTerm}`
|
||||||
|
: campaign.category;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CampaignsBoard() {
|
||||||
|
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
||||||
|
const createCampaign = useMutation(api.campaigns.create);
|
||||||
|
const updateCampaign = useMutation(api.campaigns.update);
|
||||||
|
const setStatus = useMutation(api.campaigns.setStatus);
|
||||||
|
const requestRun = useMutation(api.campaigns.requestRun);
|
||||||
|
|
||||||
|
const [editingCampaign, setEditingCampaign] = useState<CampaignRow | null>(null);
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [actionBusyId, setActionBusyId] = useState<Id<"campaigns"> | null>(null);
|
||||||
|
const [actionLabel, setActionLabel] = useState<string | null>(null);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [rowError, setRowError] = useState<string | null>(null);
|
||||||
|
const actionLabelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearActionLabelTimer = () => {
|
||||||
|
if (actionLabelTimerRef.current) {
|
||||||
|
clearTimeout(actionLabelTimerRef.current);
|
||||||
|
actionLabelTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActionLabelWithTimeout = (
|
||||||
|
label: string,
|
||||||
|
clearAfterMs = 1200,
|
||||||
|
) => {
|
||||||
|
clearActionLabelTimer();
|
||||||
|
setActionLabel(label);
|
||||||
|
|
||||||
|
if (clearAfterMs > 0) {
|
||||||
|
actionLabelTimerRef.current = setTimeout(() => setActionLabel(null), clearAfterMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearActionLabelTimer();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const campaignsSorted = useMemo(() => {
|
||||||
|
if (!campaigns) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...campaigns].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}, [campaigns]);
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setEditingCampaign(null);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setFormError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingCampaign(null);
|
||||||
|
setRowError(null);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (campaign: CampaignRow) => {
|
||||||
|
setEditingCampaign(campaign);
|
||||||
|
setRowError(null);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCampaign = async (payload: {
|
||||||
|
status: CampaignRow["status"];
|
||||||
|
categoryMode: CampaignRow["categoryMode"];
|
||||||
|
category: string;
|
||||||
|
customSearchTerm?: string;
|
||||||
|
postalCode: string;
|
||||||
|
radiusKm: number;
|
||||||
|
maxNewLeadsPerRun: number;
|
||||||
|
maxAuditsPerRun: number;
|
||||||
|
recurrence: CampaignRow["recurrence"];
|
||||||
|
countryCode: "DE";
|
||||||
|
country: "Deutschland";
|
||||||
|
name: string;
|
||||||
|
}) => {
|
||||||
|
setActionLabel("Speichere...");
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
if (!editingCampaign) {
|
||||||
|
await createCampaign(payload);
|
||||||
|
} else {
|
||||||
|
await updateCampaign({
|
||||||
|
id: editingCampaign._id,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setActionLabelWithTimeout("Gespeichert");
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingCampaign(null);
|
||||||
|
} catch {
|
||||||
|
setFormError("Speichern fehlgeschlagen.");
|
||||||
|
setActionLabelWithTimeout("Fehler", 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCampaign = async (campaign: CampaignRow) => {
|
||||||
|
setActionBusyId(campaign._id);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
await requestRun({ id: campaign._id });
|
||||||
|
setActionLabelWithTimeout(`${campaign.name}: Lauf gestartet`);
|
||||||
|
} catch {
|
||||||
|
setRowError("Kampagne konnte nicht gestartet werden.");
|
||||||
|
setActionLabelWithTimeout("Kampagne konnte nicht gestartet werden.", 2400);
|
||||||
|
} finally {
|
||||||
|
setActionBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCampaign = async (campaign: CampaignRow) => {
|
||||||
|
const nextStatus = campaign.status === "active" ? "paused" : "active";
|
||||||
|
setActionBusyId(campaign._id);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
await setStatus({ id: campaign._id, status: nextStatus });
|
||||||
|
setActionLabelWithTimeout(
|
||||||
|
`${campaign.name}: ${nextStatus === "active" ? "Aktiviert" : "Pausiert"}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setRowError("Status konnte nicht geändert werden.");
|
||||||
|
setActionLabelWithTimeout("Status konnte nicht geändert werden.", 2400);
|
||||||
|
} finally {
|
||||||
|
setActionBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (campaigns === undefined) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="h-7 w-48 rounded-md bg-muted" />
|
||||||
|
<div className="h-8 w-24 rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<Skeleton className="h-28 rounded-lg" key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<CampaignFormDialog
|
||||||
|
campaign={editingCampaign ? formPayloadFromCampaign(editingCampaign) : null}
|
||||||
|
open={isFormOpen}
|
||||||
|
onOpenChange={closeDialog}
|
||||||
|
onSubmit={submitCampaign}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
||||||
|
Kampagnen
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Kampagne anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError ? <p className="text-sm text-destructive" role="alert">{formError}</p> : null}
|
||||||
|
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
|
||||||
|
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||||
|
|
||||||
|
{campaignsSorted.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Keine Kampagnen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden overflow-x-auto rounded-lg border bg-card md:block">
|
||||||
|
<table className="w-full min-w-[820px] border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-sm text-muted-foreground">
|
||||||
|
<th className="sticky left-0 bg-card p-3 font-normal">Kampagne</th>
|
||||||
|
<th className="p-3 font-normal">PLZ / Radius</th>
|
||||||
|
<th className="p-3 font-normal">Cadence</th>
|
||||||
|
<th className="p-3 font-normal">Limits</th>
|
||||||
|
<th className="p-3 font-normal">Status</th>
|
||||||
|
<th className="p-3 font-normal">Lauf</th>
|
||||||
|
<th className="p-3 font-normal">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{campaignsSorted.map((campaign) => (
|
||||||
|
<tr
|
||||||
|
className="border-t"
|
||||||
|
key={campaign._id}
|
||||||
|
>
|
||||||
|
<td className="max-w-[220px] p-3 align-top">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="truncate font-medium">{campaign.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatNiche(campaign)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="max-w-[180px] p-3 align-top">
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p className="inline-flex items-center gap-1">
|
||||||
|
<MapPin className="size-3" />
|
||||||
|
<span>{campaign.postalCode}</span>
|
||||||
|
</p>
|
||||||
|
<p>{campaign.radiusKm} km Umkreis</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3 align-top">
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1 text-sm">
|
||||||
|
{recurrenceLabel[campaign.recurrence]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3 align-top">
|
||||||
|
<p className="text-sm">
|
||||||
|
Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "}
|
||||||
|
{campaign.maxAuditsPerRun}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3 align-top">
|
||||||
|
<Badge
|
||||||
|
variant={campaign.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3 align-top">
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
|
||||||
|
<p>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
|
||||||
|
<p>Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3 align-top">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEditDialog(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => toggleCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4" />
|
||||||
|
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => runCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Jetzt ausführen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:hidden">
|
||||||
|
{campaignsSorted.map((campaign) => (
|
||||||
|
<Card key={campaign._id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="truncate">{campaign.name}</CardTitle>
|
||||||
|
<CardDescription className="truncate">
|
||||||
|
{formatNiche(campaign)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={campaign.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-2 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<MapPin className="size-3" />
|
||||||
|
<span>{campaign.postalCode}</span>
|
||||||
|
</div>
|
||||||
|
<span>{campaign.radiusKm} km</span>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-border" />
|
||||||
|
<div>
|
||||||
|
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
||||||
|
<p>
|
||||||
|
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
||||||
|
{campaign.maxAuditsPerRun}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
|
||||||
|
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEditDialog(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => toggleCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4" />
|
||||||
|
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => runCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Jetzt ausführen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/ui/badge.tsx
Normal file
41
components/ui/badge.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
outline:
|
||||||
|
"text-foreground border-border bg-background hover:bg-muted/40",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
VariantProps<typeof badgeVariants>;
|
||||||
|
|
||||||
|
const Badge = ({ className, variant, ...props }: BadgeProps) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
badgeVariants({
|
||||||
|
variant,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
Badge.displayName = "Badge";
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
74
components/ui/card.tsx
Normal file
74
components/ui/card.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg border bg-card text-card-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLHeadingElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-base leading-none font-semibold tracking-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-4 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
106
components/ui/dialog.tsx
Normal file
106
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/40", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = "DialogOverlay";
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-4 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = "DialogContent";
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("mb-3 flex items-center justify-between gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-base font-semibold tracking-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = "DialogTitle";
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = "DialogDescription";
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogCloseButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
>((props, ref) => (
|
||||||
|
<DialogClose
|
||||||
|
ref={ref}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Dialog schließen"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<button {...props}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DialogClose>
|
||||||
|
));
|
||||||
|
DialogCloseButton.displayName = "DialogCloseButton";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogCloseButton,
|
||||||
|
};
|
||||||
218
components/ui/form.tsx
Normal file
218
components/ui/form.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
type SubmitHandler,
|
||||||
|
type UseFormReturn,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FormProps<TFieldValues extends FieldValues> = Omit<
|
||||||
|
React.FormHTMLAttributes<HTMLFormElement>,
|
||||||
|
"onSubmit" | "children"
|
||||||
|
> & {
|
||||||
|
form: UseFormReturn<TFieldValues>;
|
||||||
|
onSubmit: SubmitHandler<TFieldValues>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<{ id: string } | null>(null);
|
||||||
|
|
||||||
|
type FormFieldContextValue = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
|
||||||
|
|
||||||
|
const Form = <TFieldValues extends FieldValues>({
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: FormProps<TFieldValues>) => {
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form
|
||||||
|
className={cn("w-full space-y-4", className)}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const { getFieldState, formState, control } = useFormContext();
|
||||||
|
|
||||||
|
if (!itemContext || !fieldContext) {
|
||||||
|
throw new Error("useFormField must be used within a <FormField>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
control,
|
||||||
|
id: itemContext.id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
...getFieldState(fieldContext.name, formState),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: Omit<ControllerProps<TFieldValues, TName>, "render"> & {
|
||||||
|
render: ControllerProps<TFieldValues, TName>["render"];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: String(props.name) }}>
|
||||||
|
<Controller
|
||||||
|
control={props.control}
|
||||||
|
name={props.name}
|
||||||
|
render={props.render}
|
||||||
|
/>
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("grid gap-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormItem.displayName = "FormItem";
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, id } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
htmlFor={props.htmlFor ?? id}
|
||||||
|
className={cn("text-sm leading-none font-medium", className)}
|
||||||
|
style={error ? { color: "var(--destructive)" } : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = "FormLabel";
|
||||||
|
|
||||||
|
const getFormControlAriaDescribedBy = (fieldId: string, hasError: boolean) => {
|
||||||
|
const descriptionId = `${fieldId}-description`;
|
||||||
|
const messageId = `${fieldId}-message`;
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return `${descriptionId} ${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.HTMLAttributes<HTMLElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { id, error } = useFormField();
|
||||||
|
const controlId = props.id ?? id;
|
||||||
|
|
||||||
|
const control = React.Children.only(children);
|
||||||
|
|
||||||
|
if (!React.isValidElement(control)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedControl = control as React.ReactElement<
|
||||||
|
React.ClassAttributes<unknown> & Record<string, unknown>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const controlClassName = (typedControl.props as { className?: string })
|
||||||
|
.className;
|
||||||
|
|
||||||
|
return React.cloneElement(typedControl, {
|
||||||
|
id: controlId,
|
||||||
|
ref: ref,
|
||||||
|
className: cn("relative", className, controlClassName),
|
||||||
|
...props,
|
||||||
|
"aria-invalid": error ? "true" : "false",
|
||||||
|
"aria-describedby": getFormControlAriaDescribedBy(
|
||||||
|
controlId,
|
||||||
|
!!error?.message,
|
||||||
|
),
|
||||||
|
"aria-errormessage": error?.message ? `${controlId}-message` : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
FormControl.displayName = "FormControl";
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { id } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={`${id}-description`}
|
||||||
|
className={cn("text-xs leading-5 text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormDescription.displayName = "FormDescription";
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, id } = useFormField();
|
||||||
|
|
||||||
|
if (!error?.message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={`${id}-message`}
|
||||||
|
className={cn("text-xs text-destructive", className)}
|
||||||
|
role="alert"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{typeof error.message === "string" ? error.message : String(error.message)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormMessage.displayName = "FormMessage";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Form,
|
||||||
|
useFormField,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
};
|
||||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full rounded-md border border-input bg-background px-2.5 text-sm text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
components/ui/label.tsx
Normal file
19
components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-medium leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
87
components/ui/select.tsx
Normal file
87
components/ui/select.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronDown, Check } from "lucide-react";
|
||||||
|
import * as Radix from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = Radix.Select.Root;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Radix.Select.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Radix.Select.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<Radix.Select.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Radix.Select.Icon asChild>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</Radix.Select.Icon>
|
||||||
|
</Radix.Select.Trigger>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectTrigger.displayName = "SelectTrigger";
|
||||||
|
|
||||||
|
const SelectValue = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Radix.Select.Value>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Radix.Select.Value>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<Radix.Select.Value
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectValue.displayName = "SelectValue";
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Radix.Select.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Radix.Select.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<Radix.Select.Portal>
|
||||||
|
<Radix.Select.Content
|
||||||
|
ref={ref}
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-[var(--radix-select-trigger-width)] min-w-44 rounded-md border bg-popover text-popover-foreground shadow-md outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Radix.Select.Viewport className="rounded-md p-1">
|
||||||
|
<Radix.Select.Group>{children}</Radix.Select.Group>
|
||||||
|
</Radix.Select.Viewport>
|
||||||
|
</Radix.Select.Content>
|
||||||
|
</Radix.Select.Portal>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectContent.displayName = "SelectContent";
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Radix.Select.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Radix.Select.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<Radix.Select.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1 text-sm outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Radix.Select.ItemText>{children}</Radix.Select.ItemText>
|
||||||
|
<Radix.Select.ItemIndicator className="absolute right-2 inline-flex items-center">
|
||||||
|
<Check className="size-4" />
|
||||||
|
</Radix.Select.ItemIndicator>
|
||||||
|
</Radix.Select.Item>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectItem.displayName = "SelectItem";
|
||||||
|
|
||||||
|
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };
|
||||||
17
components/ui/separator.tsx
Normal file
17
components/ui/separator.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
decorative
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
19
components/ui/skeleton.tsx
Normal file
19
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Skeleton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-md bg-muted/60 before:absolute before:inset-0 before:translate-x-[-100%] before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Skeleton.displayName = "Skeleton";
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
25
components/ui/switch.tsx
Normal file
25
components/ui/switch.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-input bg-background p-[2px] transition-all disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||||
|
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb className="block size-5 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" />
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
|
||||||
|
Switch.displayName = "Switch";
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -1,10 +1,77 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import {
|
||||||
|
CAMPAIGN_COUNTRY_CODE,
|
||||||
|
CAMPAIGN_COUNTRY_NAME,
|
||||||
|
CAMPAIGN_RECURRENCES,
|
||||||
|
CAMPAIGN_STATUSES,
|
||||||
|
} from "../lib/campaign-form";
|
||||||
|
import {
|
||||||
|
calculateNextRunAt,
|
||||||
|
getCampaignCurrentRunStatus,
|
||||||
|
} from "../lib/campaign-scheduling";
|
||||||
|
import {
|
||||||
|
validateCampaignCreateInput,
|
||||||
|
validateCampaignUpdateInput,
|
||||||
|
} from "../lib/campaign-validation";
|
||||||
|
|
||||||
import { normalizeListLimit } from "./domain";
|
import { normalizeListLimit } from "./domain";
|
||||||
import { mutation, query } from "./_generated/server";
|
import { Doc } from "./_generated/dataModel";
|
||||||
|
import { mutation, query, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
type CampaignDoc = Doc<"campaigns">;
|
||||||
|
|
||||||
|
type CampaignWithRunStatus = Omit<CampaignDoc, "lastRunAt"> & {
|
||||||
|
currentRunStatus: string;
|
||||||
|
lastRunAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const campaignStatus = v.union(
|
||||||
|
...CAMPAIGN_STATUSES.map((status) => v.literal(status)),
|
||||||
|
);
|
||||||
|
const campaignRecurrence = v.union(
|
||||||
|
...CAMPAIGN_RECURRENCES.map((recurrence) => v.literal(recurrence)),
|
||||||
|
);
|
||||||
|
const optionalNextRunAt = v.optional(v.union(v.number(), v.null()));
|
||||||
const limitArg = v.optional(v.number());
|
const limitArg = v.optional(v.number());
|
||||||
|
|
||||||
|
function normalizeNextRunAt(args: {
|
||||||
|
status: CampaignDoc["status"];
|
||||||
|
recurrence: CampaignDoc["recurrence"];
|
||||||
|
lastRunAt?: number | null;
|
||||||
|
now: number;
|
||||||
|
}): number | null {
|
||||||
|
return calculateNextRunAt({
|
||||||
|
status: args.status,
|
||||||
|
recurrence: args.recurrence,
|
||||||
|
lastRunAt: args.lastRunAt,
|
||||||
|
now: args.now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichCampaignWithRunStatus(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
campaign: CampaignDoc,
|
||||||
|
): Promise<CampaignWithRunStatus> {
|
||||||
|
const latestRun = await ctx.db
|
||||||
|
.query("agentRuns")
|
||||||
|
.withIndex("by_campaignId_and_updatedAt", (q) =>
|
||||||
|
q.eq("campaignId", campaign._id),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(1);
|
||||||
|
|
||||||
|
const run = latestRun.at(0) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...campaign,
|
||||||
|
currentRunStatus: getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: campaign.status,
|
||||||
|
agentRuns: run ? [run] : [],
|
||||||
|
}),
|
||||||
|
lastRunAt: campaign.lastRunAt ?? run?.updatedAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
@@ -18,31 +85,247 @@ export const create = mutation({
|
|||||||
radiusKm: v.number(),
|
radiusKm: v.number(),
|
||||||
maxNewLeadsPerRun: v.number(),
|
maxNewLeadsPerRun: v.number(),
|
||||||
maxAuditsPerRun: v.number(),
|
maxAuditsPerRun: v.number(),
|
||||||
recurrence: v.union(
|
recurrence: campaignRecurrence,
|
||||||
v.literal("manual"),
|
status: v.optional(campaignStatus),
|
||||||
v.literal("daily"),
|
countryCode: v.optional(v.literal(CAMPAIGN_COUNTRY_CODE)),
|
||||||
v.literal("weekly"),
|
country: v.optional(v.literal(CAMPAIGN_COUNTRY_NAME)),
|
||||||
v.literal("monthly"),
|
nextRunAt: optionalNextRunAt,
|
||||||
),
|
|
||||||
status: v.optional(v.union(v.literal("active"), v.literal("paused"))),
|
|
||||||
nextRunAt: v.optional(v.number()),
|
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const status = args.status ?? "paused";
|
||||||
|
|
||||||
|
const sanitized = validateCampaignCreateInput({
|
||||||
|
status,
|
||||||
|
recurrence: args.recurrence,
|
||||||
|
postalCode: args.postalCode,
|
||||||
|
radiusKm: args.radiusKm,
|
||||||
|
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
|
||||||
|
maxAuditsPerRun: args.maxAuditsPerRun,
|
||||||
|
countryCode: args.countryCode,
|
||||||
|
country: args.country,
|
||||||
|
});
|
||||||
|
|
||||||
return await ctx.db.insert("campaigns", {
|
return await ctx.db.insert("campaigns", {
|
||||||
...args,
|
name: args.name,
|
||||||
status: args.status ?? "paused",
|
categoryMode: args.categoryMode,
|
||||||
|
category: args.category,
|
||||||
|
customSearchTerm: args.customSearchTerm,
|
||||||
|
postalCode: args.postalCode,
|
||||||
|
region: args.region,
|
||||||
|
latitude: args.latitude,
|
||||||
|
longitude: args.longitude,
|
||||||
|
radiusKm: args.radiusKm,
|
||||||
|
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
|
||||||
|
maxAuditsPerRun: args.maxAuditsPerRun,
|
||||||
|
recurrence: sanitized.recurrence,
|
||||||
|
status: sanitized.status,
|
||||||
|
countryCode: sanitized.countryCode,
|
||||||
|
country: sanitized.country,
|
||||||
|
nextRunAt:
|
||||||
|
args.nextRunAt === undefined
|
||||||
|
? normalizeNextRunAt({
|
||||||
|
status: sanitized.status,
|
||||||
|
recurrence: sanitized.recurrence,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
: args.nextRunAt,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("campaigns"),
|
||||||
|
name: v.optional(v.string()),
|
||||||
|
categoryMode: v.optional(v.union(v.literal("preset"), v.literal("custom"))),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
customSearchTerm: v.optional(v.string()),
|
||||||
|
postalCode: v.optional(v.string()),
|
||||||
|
region: v.optional(v.string()),
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
radiusKm: v.optional(v.number()),
|
||||||
|
maxNewLeadsPerRun: v.optional(v.number()),
|
||||||
|
maxAuditsPerRun: v.optional(v.number()),
|
||||||
|
recurrence: v.optional(campaignRecurrence),
|
||||||
|
status: v.optional(campaignStatus),
|
||||||
|
countryCode: v.optional(v.literal(CAMPAIGN_COUNTRY_CODE)),
|
||||||
|
country: v.optional(v.literal(CAMPAIGN_COUNTRY_NAME)),
|
||||||
|
nextRunAt: optionalNextRunAt,
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const campaign = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new Error("Kampagne nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = validateCampaignUpdateInput({
|
||||||
|
postalCode: args.postalCode,
|
||||||
|
radiusKm: args.radiusKm,
|
||||||
|
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
|
||||||
|
maxAuditsPerRun: args.maxAuditsPerRun,
|
||||||
|
recurrence: args.recurrence,
|
||||||
|
status: args.status,
|
||||||
|
countryCode: args.countryCode,
|
||||||
|
country: args.country,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
updatedAt: now,
|
||||||
|
countryCode: sanitized.countryCode,
|
||||||
|
country: sanitized.country,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.name !== undefined) {
|
||||||
|
patch.name = args.name;
|
||||||
|
}
|
||||||
|
if (args.categoryMode !== undefined) {
|
||||||
|
patch.categoryMode = args.categoryMode;
|
||||||
|
}
|
||||||
|
if (args.category !== undefined) {
|
||||||
|
patch.category = args.category;
|
||||||
|
}
|
||||||
|
if (args.customSearchTerm !== undefined) {
|
||||||
|
patch.customSearchTerm = args.customSearchTerm;
|
||||||
|
}
|
||||||
|
if (args.postalCode !== undefined) {
|
||||||
|
patch.postalCode = args.postalCode;
|
||||||
|
}
|
||||||
|
if (args.region !== undefined) {
|
||||||
|
patch.region = args.region;
|
||||||
|
}
|
||||||
|
if (args.latitude !== undefined) {
|
||||||
|
patch.latitude = args.latitude;
|
||||||
|
}
|
||||||
|
if (args.longitude !== undefined) {
|
||||||
|
patch.longitude = args.longitude;
|
||||||
|
}
|
||||||
|
if (args.radiusKm !== undefined) {
|
||||||
|
patch.radiusKm = args.radiusKm;
|
||||||
|
}
|
||||||
|
if (args.maxNewLeadsPerRun !== undefined) {
|
||||||
|
patch.maxNewLeadsPerRun = args.maxNewLeadsPerRun;
|
||||||
|
}
|
||||||
|
if (args.maxAuditsPerRun !== undefined) {
|
||||||
|
patch.maxAuditsPerRun = args.maxAuditsPerRun;
|
||||||
|
}
|
||||||
|
if (args.recurrence !== undefined) {
|
||||||
|
patch.recurrence = sanitized.recurrence;
|
||||||
|
}
|
||||||
|
if (args.status !== undefined) {
|
||||||
|
patch.status = sanitized.status;
|
||||||
|
}
|
||||||
|
if (args.nextRunAt !== undefined) {
|
||||||
|
patch.nextRunAt = args.nextRunAt;
|
||||||
|
} else if (
|
||||||
|
(args.status !== undefined && args.status !== campaign.status)
|
||||||
|
|| (args.recurrence !== undefined && args.recurrence !== campaign.recurrence)
|
||||||
|
) {
|
||||||
|
const nextStatus = args.status ?? campaign.status;
|
||||||
|
const nextRecurrence = args.recurrence ?? campaign.recurrence;
|
||||||
|
|
||||||
|
patch.nextRunAt = normalizeNextRunAt({
|
||||||
|
status: nextStatus,
|
||||||
|
recurrence: nextRecurrence,
|
||||||
|
lastRunAt: campaign.lastRunAt,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, patch);
|
||||||
|
return args.id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setStatus = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("campaigns"),
|
||||||
|
status: campaignStatus,
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const campaign = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new Error("Kampagne nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
status: args.status,
|
||||||
|
nextRunAt:
|
||||||
|
args.status === "paused"
|
||||||
|
? null
|
||||||
|
: calculateNextRunAt({
|
||||||
|
recurrence: campaign.recurrence,
|
||||||
|
status: args.status,
|
||||||
|
lastRunAt: campaign.lastRunAt,
|
||||||
|
now,
|
||||||
|
}),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return args.id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestRun = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("campaigns"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const campaign = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new Error("Kampagne nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = await ctx.db.insert("agentRuns", {
|
||||||
|
type: "campaign",
|
||||||
|
campaignId: args.id,
|
||||||
|
status: "pending",
|
||||||
|
counters: {
|
||||||
|
leadsFound: 0,
|
||||||
|
leadsCreated: 0,
|
||||||
|
auditsCreated: 0,
|
||||||
|
outreachPrepared: 0,
|
||||||
|
errors: 0,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextRunAt = calculateNextRunAt({
|
||||||
|
recurrence: campaign.recurrence,
|
||||||
|
status: campaign.status,
|
||||||
|
lastRunAt: now,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
lastRunAt: now,
|
||||||
|
nextRunAt,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return runId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: { id: v.id("campaigns") },
|
args: { id: v.id("campaigns") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db.get(args.id);
|
const campaign = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await enrichCampaignWithRunStatus(ctx, campaign);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,16 +337,18 @@ export const list = query({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
|
||||||
if (args.status) {
|
let campaigns;
|
||||||
|
if (args.status !== undefined) {
|
||||||
const status = args.status;
|
const status = args.status;
|
||||||
|
campaigns = await ctx.db
|
||||||
return await ctx.db
|
|
||||||
.query("campaigns")
|
.query("campaigns")
|
||||||
.withIndex("by_status", (q) => q.eq("status", status))
|
.withIndex("by_status", (q) => q.eq("status", status))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(limit);
|
.take(limit);
|
||||||
|
} else {
|
||||||
|
campaigns = await ctx.db.query("campaigns").order("desc").take(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ctx.db.query("campaigns").order("desc").take(limit);
|
return await Promise.all(campaigns.map((campaign) => enrichCampaignWithRunStatus(ctx, campaign)));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ export default defineSchema({
|
|||||||
v.literal("monthly"),
|
v.literal("monthly"),
|
||||||
),
|
),
|
||||||
status: campaignStatus,
|
status: campaignStatus,
|
||||||
|
countryCode: v.optional(v.string()),
|
||||||
|
country: v.optional(v.string()),
|
||||||
lastRunAt: v.optional(v.number()),
|
lastRunAt: v.optional(v.number()),
|
||||||
nextRunAt: v.optional(v.number()),
|
nextRunAt: v.optional(v.union(v.number(), v.null())),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
@@ -276,6 +278,7 @@ export default defineSchema({
|
|||||||
})
|
})
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
.index("by_type_and_status", ["type", "status"])
|
.index("by_type_and_status", ["type", "status"])
|
||||||
|
.index("by_campaignId_and_updatedAt", ["campaignId", "updatedAt"])
|
||||||
.index("by_campaignId_and_status", ["campaignId", "status"])
|
.index("by_campaignId_and_status", ["campaignId", "status"])
|
||||||
.index("by_auditId", ["auditId"]),
|
.index("by_auditId", ["auditId"]),
|
||||||
|
|
||||||
|
|||||||
117
lib/campaign-form.ts
Normal file
117
lib/campaign-form.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const CAMPAIGN_NAME_MIN = 3;
|
||||||
|
const CAMPAIGN_NAME_MAX = 120;
|
||||||
|
export const CAMPAIGN_COUNTRY_CODE = "DE";
|
||||||
|
export const CAMPAIGN_COUNTRY_NAME = "Deutschland";
|
||||||
|
const MIN_RADIUS_KM = 1;
|
||||||
|
const MAX_RADIUS_KM = 5000;
|
||||||
|
const MIN_LEADS_PER_RUN = 1;
|
||||||
|
const MAX_LEADS_PER_RUN = 9999;
|
||||||
|
const MIN_AUDITS_PER_RUN = 1;
|
||||||
|
const MAX_AUDITS_PER_RUN = 9999;
|
||||||
|
|
||||||
|
export const CAMPAIGN_CATEGORY_MODES = ["preset", "custom"] as const;
|
||||||
|
export type CampaignCategoryMode = (typeof CAMPAIGN_CATEGORY_MODES)[number];
|
||||||
|
|
||||||
|
export const CAMPAIGN_RECURRENCES = [
|
||||||
|
"manual",
|
||||||
|
"daily",
|
||||||
|
"weekly",
|
||||||
|
"monthly",
|
||||||
|
] as const;
|
||||||
|
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
|
||||||
|
|
||||||
|
export const CAMPAIGN_STATUSES = ["active", "paused"] as const;
|
||||||
|
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||||
|
|
||||||
|
export const ORTHODOX_POSTAL_CODE_MESSAGE =
|
||||||
|
"Bitte eine gültige deutsche PLZ (Postleitzahl) mit genau 5 Ziffern angeben.";
|
||||||
|
|
||||||
|
const postalCodeSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{5}$/, ORTHODOX_POSTAL_CODE_MESSAGE);
|
||||||
|
|
||||||
|
const nonEmptyString = (label: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, `${label} ist erforderlich.`);
|
||||||
|
|
||||||
|
const positiveBoundedInt = (label: string, min: number, max: number) =>
|
||||||
|
z
|
||||||
|
.number({ message: `${label} muss eine Zahl sein.` })
|
||||||
|
.finite(`${label} muss eine Zahl sein.`)
|
||||||
|
.min(min, `${label} muss mindestens ${min} sein.`)
|
||||||
|
.int(`${label} muss eine ganze Zahl sein.`)
|
||||||
|
.max(max, `${label} muss maximal ${max} sein.`);
|
||||||
|
|
||||||
|
export const campaignFormSchema = z
|
||||||
|
.object({
|
||||||
|
name: nonEmptyString("Name").min(CAMPAIGN_NAME_MIN).max(CAMPAIGN_NAME_MAX),
|
||||||
|
categoryMode: z.union(
|
||||||
|
CAMPAIGN_CATEGORY_MODES.map((value) => z.literal(value)),
|
||||||
|
{
|
||||||
|
error: "Bitte zwischen vorgegebener Kategorie oder eigener Kategorie wählen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
category: nonEmptyString("Kategorie"),
|
||||||
|
customSearchTerm: z.string().optional(),
|
||||||
|
postalCode: postalCodeSchema,
|
||||||
|
radiusKm: positiveBoundedInt("Radius", MIN_RADIUS_KM, MAX_RADIUS_KM),
|
||||||
|
maxNewLeadsPerRun: positiveBoundedInt(
|
||||||
|
"Max. neue Leads",
|
||||||
|
MIN_LEADS_PER_RUN,
|
||||||
|
MAX_LEADS_PER_RUN,
|
||||||
|
),
|
||||||
|
maxAuditsPerRun: positiveBoundedInt(
|
||||||
|
"Max. Audits",
|
||||||
|
MIN_AUDITS_PER_RUN,
|
||||||
|
MAX_AUDITS_PER_RUN,
|
||||||
|
),
|
||||||
|
recurrence: z.union(
|
||||||
|
CAMPAIGN_RECURRENCES.map((value) => z.literal(value)),
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Bitte eine gültige Häufigkeit wählen: manuell, täglich, wöchentlich oder monatlich.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
status: z.union(
|
||||||
|
CAMPAIGN_STATUSES.map((value) => z.literal(value)),
|
||||||
|
{ error: "Status ist ungültig." },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
const needsCustomSearchTerm =
|
||||||
|
values.categoryMode === "custom" || values.category === "Anderes";
|
||||||
|
|
||||||
|
if (needsCustomSearchTerm && !values.customSearchTerm?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["customSearchTerm"],
|
||||||
|
message:
|
||||||
|
"Für Kategorie 'Anderes' ist ein eigener Suchbegriff erforderlich.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const campaignFormDefaults = {
|
||||||
|
name: "",
|
||||||
|
status: "active" as CampaignStatus,
|
||||||
|
categoryMode: "preset" as CampaignCategoryMode,
|
||||||
|
category: "Anwalt",
|
||||||
|
customSearchTerm: "",
|
||||||
|
recurrence: "daily" as CampaignRecurrence,
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
postalCode: "10115",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapCampaignFormToPayload(values: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
countryCode: CAMPAIGN_COUNTRY_CODE as "DE",
|
||||||
|
country: CAMPAIGN_COUNTRY_NAME as "Deutschland",
|
||||||
|
};
|
||||||
|
}
|
||||||
103
lib/campaign-scheduling.ts
Normal file
103
lib/campaign-scheduling.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES } from "./campaign-form";
|
||||||
|
|
||||||
|
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
|
||||||
|
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||||
|
|
||||||
|
export type CampaignFormRecurrenceInput = {
|
||||||
|
recurrence: CampaignRecurrence | (string & {});
|
||||||
|
status: CampaignStatus;
|
||||||
|
lastRunAt?: number | null;
|
||||||
|
now?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRunInfo = {
|
||||||
|
campaignStatus: CampaignStatus;
|
||||||
|
agentRuns?: Array<{
|
||||||
|
status: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAllowedCampaignRecurrence(value: string): boolean {
|
||||||
|
return CAMPAIGN_RECURRENCES.includes(value as CampaignRecurrence);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysUTC(base: number, days: number): number {
|
||||||
|
const date = new Date(base);
|
||||||
|
return Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth(),
|
||||||
|
date.getUTCDate() + days,
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
date.getUTCSeconds(),
|
||||||
|
date.getUTCMilliseconds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonthsUTC(base: number, months: number): number {
|
||||||
|
const date = new Date(base);
|
||||||
|
return Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth() + months,
|
||||||
|
date.getUTCDate(),
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
date.getUTCSeconds(),
|
||||||
|
date.getUTCMilliseconds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateNextRunAt(input: CampaignFormRecurrenceInput): number | null {
|
||||||
|
if (input.status !== "active") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedCampaignRecurrence(input.recurrence)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recurrence === "manual") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = input.lastRunAt ?? input.now ?? Date.now();
|
||||||
|
if (input.recurrence === "daily") {
|
||||||
|
return addDaysUTC(anchor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recurrence === "weekly") {
|
||||||
|
return addDaysUTC(anchor, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addMonthsUTC(anchor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaignCurrentRunStatus(input: CampaignRunInfo): string {
|
||||||
|
const agentRuns = input.agentRuns ?? [];
|
||||||
|
if (agentRuns.length > 0) {
|
||||||
|
const ordered = [...agentRuns].sort((a, b) => {
|
||||||
|
const aUpdatedAt = typeof a.updatedAt === "number" ? a.updatedAt : 0;
|
||||||
|
const bUpdatedAt = typeof b.updatedAt === "number" ? b.updatedAt : 0;
|
||||||
|
|
||||||
|
return bUpdatedAt - aUpdatedAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestStatus = ordered.at(0)?.status;
|
||||||
|
if (latestStatus === "running") {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
if (latestStatus === "pending") {
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
latestStatus === "succeeded" ||
|
||||||
|
latestStatus === "failed" ||
|
||||||
|
latestStatus === "canceled"
|
||||||
|
) {
|
||||||
|
return latestStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.campaignStatus === "paused" ? "paused" : "idle";
|
||||||
|
}
|
||||||
218
lib/campaign-validation.ts
Normal file
218
lib/campaign-validation.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { CAMPAIGN_COUNTRY_CODE, CAMPAIGN_COUNTRY_NAME, CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES, ORTHODOX_POSTAL_CODE_MESSAGE } from "./campaign-form";
|
||||||
|
import { CampaignRecurrence, CampaignStatus } from "./campaign-scheduling";
|
||||||
|
|
||||||
|
const CAMPAIGN_POSTAL_CODE_REGEX = /^\d{5}$/;
|
||||||
|
const MIN_RADIUS_KM = 1;
|
||||||
|
const MAX_RADIUS_KM = 5000;
|
||||||
|
const MIN_LEADS_PER_RUN = 1;
|
||||||
|
const MAX_LEADS_PER_RUN = 9999;
|
||||||
|
const MIN_AUDITS_PER_RUN = 1;
|
||||||
|
const MAX_AUDITS_PER_RUN = 9999;
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteInteger(
|
||||||
|
value: number,
|
||||||
|
fieldLabel: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
) {
|
||||||
|
assert(Number.isFinite(value), `${fieldLabel} muss eine Zahl sein.`);
|
||||||
|
assert(Number.isInteger(value), `${fieldLabel} muss eine ganze Zahl sein.`);
|
||||||
|
assert(
|
||||||
|
value >= min,
|
||||||
|
`${fieldLabel} muss mindestens ${min} sein.`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
value <= max,
|
||||||
|
`${fieldLabel} darf höchstens ${max} sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAllowed<T extends string>(
|
||||||
|
value: string,
|
||||||
|
allowed: readonly T[],
|
||||||
|
errorMessage: string,
|
||||||
|
): T {
|
||||||
|
assert(allowed.includes(value as T), errorMessage);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCountryContext(countryCode?: string, country?: string) {
|
||||||
|
const hasCountryCode = countryCode !== undefined;
|
||||||
|
const hasCountry = country !== undefined;
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!(hasCountryCode || hasCountry) || (hasCountryCode && hasCountry),
|
||||||
|
"Deutschland-Kontext muss vollständig gesetzt oder ausgelassen werden.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCountryCode || hasCountry) {
|
||||||
|
assert(
|
||||||
|
countryCode === CAMPAIGN_COUNTRY_CODE && country === CAMPAIGN_COUNTRY_NAME,
|
||||||
|
"Nur Deutschland-Context ist erlaubt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CampaignCreatePayload = {
|
||||||
|
status: CampaignStatus;
|
||||||
|
recurrence: CampaignRecurrence;
|
||||||
|
postalCode: string;
|
||||||
|
radiusKm: number;
|
||||||
|
maxNewLeadsPerRun: number;
|
||||||
|
maxAuditsPerRun: number;
|
||||||
|
countryCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignUpdatePayload = Partial<
|
||||||
|
Omit<CampaignCreatePayload, "status" | "recurrence">
|
||||||
|
> & {
|
||||||
|
status?: CampaignStatus;
|
||||||
|
recurrence?: CampaignRecurrence;
|
||||||
|
countryCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CampaignCreateInput = {
|
||||||
|
status: string;
|
||||||
|
recurrence: string;
|
||||||
|
postalCode: string;
|
||||||
|
radiusKm: number;
|
||||||
|
maxNewLeadsPerRun: number;
|
||||||
|
maxAuditsPerRun: number;
|
||||||
|
countryCode?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CampaignUpdateInput = {
|
||||||
|
postalCode?: string;
|
||||||
|
radiusKm?: number;
|
||||||
|
maxNewLeadsPerRun?: number;
|
||||||
|
maxAuditsPerRun?: number;
|
||||||
|
status?: string;
|
||||||
|
recurrence?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateCampaignCreateInput(
|
||||||
|
input: CampaignCreateInput,
|
||||||
|
): CampaignCreatePayload {
|
||||||
|
assertCountryContext(input.countryCode, input.country);
|
||||||
|
const status = assertAllowed(
|
||||||
|
input.status,
|
||||||
|
CAMPAIGN_STATUSES,
|
||||||
|
"Status ist ungültig.",
|
||||||
|
);
|
||||||
|
const recurrence = assertAllowed(
|
||||||
|
input.recurrence,
|
||||||
|
CAMPAIGN_RECURRENCES,
|
||||||
|
"Frequenz ist ungültig.",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
|
||||||
|
ORTHODOX_POSTAL_CODE_MESSAGE,
|
||||||
|
);
|
||||||
|
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
|
||||||
|
assertFiniteInteger(
|
||||||
|
input.maxNewLeadsPerRun,
|
||||||
|
"Max. neue Leads",
|
||||||
|
MIN_LEADS_PER_RUN,
|
||||||
|
MAX_LEADS_PER_RUN,
|
||||||
|
);
|
||||||
|
assertFiniteInteger(
|
||||||
|
input.maxAuditsPerRun,
|
||||||
|
"Max. Audits",
|
||||||
|
MIN_AUDITS_PER_RUN,
|
||||||
|
MAX_AUDITS_PER_RUN,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
recurrence,
|
||||||
|
postalCode: input.postalCode,
|
||||||
|
radiusKm: input.radiusKm,
|
||||||
|
maxNewLeadsPerRun: input.maxNewLeadsPerRun,
|
||||||
|
maxAuditsPerRun: input.maxAuditsPerRun,
|
||||||
|
countryCode: CAMPAIGN_COUNTRY_CODE,
|
||||||
|
country: CAMPAIGN_COUNTRY_NAME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCampaignUpdateInput(
|
||||||
|
input: CampaignUpdateInput,
|
||||||
|
): CampaignUpdatePayload {
|
||||||
|
const updates: CampaignUpdatePayload = {
|
||||||
|
countryCode: CAMPAIGN_COUNTRY_CODE,
|
||||||
|
country: CAMPAIGN_COUNTRY_NAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
assertCountryContext(input.countryCode, input.country);
|
||||||
|
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
updates.status = assertAllowed(
|
||||||
|
input.status,
|
||||||
|
CAMPAIGN_STATUSES,
|
||||||
|
"Status ist ungültig.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.recurrence !== undefined) {
|
||||||
|
updates.recurrence = assertAllowed(
|
||||||
|
input.recurrence,
|
||||||
|
CAMPAIGN_RECURRENCES,
|
||||||
|
"Frequenz ist ungültig.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.postalCode !== undefined) {
|
||||||
|
assert(
|
||||||
|
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
|
||||||
|
ORTHODOX_POSTAL_CODE_MESSAGE,
|
||||||
|
);
|
||||||
|
updates.postalCode = input.postalCode;
|
||||||
|
}
|
||||||
|
if (input.radiusKm !== undefined) {
|
||||||
|
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
|
||||||
|
updates.radiusKm = input.radiusKm;
|
||||||
|
}
|
||||||
|
if (input.maxNewLeadsPerRun !== undefined) {
|
||||||
|
assertFiniteInteger(
|
||||||
|
input.maxNewLeadsPerRun,
|
||||||
|
"Max. neue Leads",
|
||||||
|
MIN_LEADS_PER_RUN,
|
||||||
|
MAX_LEADS_PER_RUN,
|
||||||
|
);
|
||||||
|
updates.maxNewLeadsPerRun = input.maxNewLeadsPerRun;
|
||||||
|
}
|
||||||
|
if (input.maxAuditsPerRun !== undefined) {
|
||||||
|
assertFiniteInteger(
|
||||||
|
input.maxAuditsPerRun,
|
||||||
|
"Max. Audits",
|
||||||
|
MIN_AUDITS_PER_RUN,
|
||||||
|
MAX_AUDITS_PER_RUN,
|
||||||
|
);
|
||||||
|
updates.maxAuditsPerRun = input.maxAuditsPerRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.countryCode !== undefined
|
||||||
|
|| input.country !== undefined
|
||||||
|
|| input.status !== undefined
|
||||||
|
|| input.recurrence !== undefined
|
||||||
|
|| input.postalCode !== undefined
|
||||||
|
|| input.radiusKm !== undefined
|
||||||
|
|| input.maxNewLeadsPerRun !== undefined
|
||||||
|
|| input.maxAuditsPerRun !== undefined
|
||||||
|
) {
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/better-auth": "^0.12.2",
|
"@convex-dev/better-auth": "^0.12.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"better-auth": "^1.6.14",
|
"better-auth": "^1.6.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -21,9 +22,11 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
"shadcn": "^4.10.0",
|
"shadcn": "^4.10.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@convex-dev/better-auth':
|
'@convex-dev/better-auth':
|
||||||
specifier: ^0.12.2
|
specifier: ^0.12.2
|
||||||
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^5.4.0
|
||||||
|
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.6.14
|
specifier: ^1.6.14
|
||||||
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -38,6 +41,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.77.0
|
||||||
|
version: 7.77.0(react@19.2.4)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.10.0
|
specifier: ^4.10.0
|
||||||
version: 4.10.0(@types/node@20.19.41)(typescript@5.9.3)
|
version: 4.10.0(@types/node@20.19.41)(typescript@5.9.3)
|
||||||
@@ -47,6 +53,9 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
@@ -528,6 +537,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.4.0':
|
||||||
|
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.55.0
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -1585,6 +1599,9 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -3614,6 +3631,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
|
react-hook-form@7.77.0:
|
||||||
|
resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -4382,7 +4405,7 @@ snapshots:
|
|||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@opentelemetry/semantic-conventions': 1.41.1
|
'@opentelemetry/semantic-conventions': 1.41.1
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
better-call: 1.3.5(zod@3.25.76)
|
better-call: 1.3.5(zod@4.4.3)
|
||||||
jose: 6.2.3
|
jose: 6.2.3
|
||||||
kysely: 0.29.2
|
kysely: 0.29.2
|
||||||
nanostores: 1.3.0
|
nanostores: 1.3.0
|
||||||
@@ -4624,6 +4647,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.23
|
hono: 4.12.23
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.4))':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
react-hook-form: 7.77.0(react@19.2.4)
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@@ -5645,6 +5673,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -6067,7 +6097,7 @@ snapshots:
|
|||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@noble/ciphers': 2.2.0
|
'@noble/ciphers': 2.2.0
|
||||||
'@noble/hashes': 2.2.0
|
'@noble/hashes': 2.2.0
|
||||||
better-call: 1.3.5(zod@3.25.76)
|
better-call: 1.3.5(zod@4.4.3)
|
||||||
defu: 6.1.7
|
defu: 6.1.7
|
||||||
jose: 6.2.3
|
jose: 6.2.3
|
||||||
kysely: 0.29.2
|
kysely: 0.29.2
|
||||||
@@ -6081,14 +6111,14 @@ snapshots:
|
|||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
|
|
||||||
better-call@1.3.5(zod@3.25.76):
|
better-call@1.3.5(zod@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/utils': 0.4.1
|
'@better-auth/utils': 0.4.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
rou3: 0.7.12
|
rou3: 0.7.12
|
||||||
set-cookie-parser: 3.1.0
|
set-cookie-parser: 3.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 3.25.76
|
zod: 4.4.3
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7754,6 +7784,10 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-hook-form@7.77.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.4):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.4):
|
||||||
|
|||||||
265
tests/campaign-form.test.ts
Normal file
265
tests/campaign-form.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import * as campaignForm from "../lib/campaign-form";
|
||||||
|
|
||||||
|
type CampaignFormModule = {
|
||||||
|
campaignFormSchema: {
|
||||||
|
safeParse: (value: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: {
|
||||||
|
flatten?: () => { fieldErrors: Record<string, string[] | undefined> };
|
||||||
|
issues?: Array<{
|
||||||
|
path?: Array<string | number>;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
mapCampaignFormToPayload: (values: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorLike = CampaignFormModule["campaignFormSchema"]["safeParse"] extends (
|
||||||
|
value: unknown,
|
||||||
|
) => infer Result
|
||||||
|
? Result
|
||||||
|
: never;
|
||||||
|
|
||||||
|
const formModule = campaignForm as unknown as CampaignFormModule;
|
||||||
|
|
||||||
|
function toString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFieldMessages(
|
||||||
|
result: ErrorLike,
|
||||||
|
field: string,
|
||||||
|
): string[] {
|
||||||
|
const messages: string[] = [];
|
||||||
|
const flatten = result.error?.flatten?.();
|
||||||
|
const flattenedMessages = flatten?.fieldErrors?.[field];
|
||||||
|
|
||||||
|
if (flattenedMessages && Array.isArray(flattenedMessages)) {
|
||||||
|
for (const message of flattenedMessages) {
|
||||||
|
if (typeof message === "string" && message.length > 0) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const issue of result.error?.issues ?? []) {
|
||||||
|
const isTargetField = issue.path?.at(-1) === field;
|
||||||
|
if (
|
||||||
|
isTargetField &&
|
||||||
|
typeof issue.message === "string" &&
|
||||||
|
issue.message.length > 0
|
||||||
|
) {
|
||||||
|
messages.push(issue.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
const fallback = JSON.stringify(result.error);
|
||||||
|
if (fallback.length > 2) {
|
||||||
|
messages.push(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMinimalValidForm(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
name: "Malerbetrieb Konstruktiv",
|
||||||
|
categoryMode: "preset",
|
||||||
|
category: "Maler",
|
||||||
|
customSearchTerm: "",
|
||||||
|
postalCode: "79098",
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
recurrence: "daily",
|
||||||
|
status: "active",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("valid minimal campaign form maps to a Germany-only payload", () => {
|
||||||
|
const schemaResult = formModule.campaignFormSchema.safeParse(
|
||||||
|
createMinimalValidForm(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(schemaResult.success, true);
|
||||||
|
const payload = formModule.mapCampaignFormToPayload(
|
||||||
|
schemaResult.data as Record<string, unknown>,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const germanyIndicators = [
|
||||||
|
payload.countryCode,
|
||||||
|
payload.country,
|
||||||
|
payload.region,
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasGermany = germanyIndicators.some((value) => {
|
||||||
|
const normalized = toString(value)?.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "de" ||
|
||||||
|
normalized === "deutschland" ||
|
||||||
|
normalized?.includes("deutschland")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
hasGermany,
|
||||||
|
true,
|
||||||
|
"Mapped payload should enforce Germany-only context",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("German PLZ validation enforces exactly five digits with clear German feedback", () => {
|
||||||
|
const schema = formModule.campaignFormSchema;
|
||||||
|
const invalidPostcodes = [
|
||||||
|
"",
|
||||||
|
"1234",
|
||||||
|
"123456",
|
||||||
|
"abcde",
|
||||||
|
"12 34",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const postalCode of invalidPostcodes) {
|
||||||
|
const result = schema.safeParse(createMinimalValidForm({ postalCode }));
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
const messages = extractFieldMessages(result as ErrorLike, "postalCode");
|
||||||
|
assert.ok(messages.length > 0, "Expected a validation message for postal code");
|
||||||
|
|
||||||
|
const messageText = messages.join(" ");
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/PLZ|Postleitzahl/i,
|
||||||
|
`Expected postal-code message in German semantics, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/5|fünf|5-stellig|stellig/i,
|
||||||
|
`Expected explicit five-digit guidance, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("predefined categories do not require custom niche input", () => {
|
||||||
|
const preset = createMinimalValidForm({
|
||||||
|
categoryMode: "preset",
|
||||||
|
category: "Maler",
|
||||||
|
customSearchTerm: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formModule.campaignFormSchema.safeParse(preset);
|
||||||
|
|
||||||
|
assert.equal(result.success, true, "Predefined category should parse without custom term");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Anderes/custom category requires a non-empty custom niche field", () => {
|
||||||
|
const anderes = formModule.campaignFormSchema.safeParse(
|
||||||
|
createMinimalValidForm({
|
||||||
|
categoryMode: "custom",
|
||||||
|
category: "Anderes",
|
||||||
|
customSearchTerm: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(anderes.success, false);
|
||||||
|
const messages = extractFieldMessages(anderes as ErrorLike, "customSearchTerm");
|
||||||
|
assert.ok(messages.length > 0, "Expected a validation message for missing custom term");
|
||||||
|
|
||||||
|
const messageText = messages.join(" ");
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/Anderes|eigene|benötigt|erforderlich|muss/i,
|
||||||
|
`Expected explicit guidance for Anderes custom term, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("radius and lead/audit limits reject invalid zero, negative, and out-of-range values", () => {
|
||||||
|
const schema = formModule.campaignFormSchema;
|
||||||
|
const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [
|
||||||
|
{ field: "radiusKm", invalid: [0, -1, 10000] },
|
||||||
|
{ field: "maxNewLeadsPerRun", invalid: [0, -1, 10000] },
|
||||||
|
{ field: "maxAuditsPerRun", invalid: [0, -1, 10000] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { field, invalid } of fieldLimits) {
|
||||||
|
for (const value of invalid) {
|
||||||
|
const values = createMinimalValidForm({ [field]: value });
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
const messages = extractFieldMessages(result as ErrorLike, field);
|
||||||
|
assert.ok(
|
||||||
|
messages.length > 0,
|
||||||
|
`Expected validation for invalid value on ${field}: ${value}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageText = messages.join(" ");
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/muss|mindestens|zwischen|gültig|positiv/i,
|
||||||
|
`Expected German limit/range explanation for ${field}, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("radius and lead/audit limits reject decimal values", () => {
|
||||||
|
const schema = formModule.campaignFormSchema;
|
||||||
|
const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [
|
||||||
|
{ field: "radiusKm", invalid: [10.5, 0.1, 12.99] },
|
||||||
|
{ field: "maxNewLeadsPerRun", invalid: [1.9, 5.25] },
|
||||||
|
{ field: "maxAuditsPerRun", invalid: [0.01, 2.75] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { field, invalid } of fieldLimits) {
|
||||||
|
for (const value of invalid) {
|
||||||
|
const values = createMinimalValidForm({ [field]: value });
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
const messages = extractFieldMessages(result as ErrorLike, field);
|
||||||
|
assert.ok(
|
||||||
|
messages.length > 0,
|
||||||
|
`Expected validation for decimal value on ${field}: ${value}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageText = messages.join(" ");
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/Ganzzahl|ganze|integer|Nachkommastellen|dezim|komma/i,
|
||||||
|
`Expected decimal-rejection guidance for ${field}, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recurrence/cadence only accepts manual, daily, weekly, monthly", () => {
|
||||||
|
const schema = formModule.campaignFormSchema;
|
||||||
|
const validValues = ["manual", "daily", "weekly", "monthly"] as const;
|
||||||
|
const invalidValues = ["hourly", "biweekly", "yearly", ""] as const;
|
||||||
|
|
||||||
|
for (const recurrence of validValues) {
|
||||||
|
const valid = schema.safeParse(createMinimalValidForm({ recurrence }));
|
||||||
|
assert.equal(valid.success, true, `Expected recurrence ${recurrence} to parse`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recurrence of invalidValues) {
|
||||||
|
const invalid = schema.safeParse(createMinimalValidForm({ recurrence }));
|
||||||
|
assert.equal(invalid.success, false, `Expected recurrence ${recurrence} to be rejected`);
|
||||||
|
|
||||||
|
const messages = extractFieldMessages(invalid as ErrorLike, "recurrence");
|
||||||
|
assert.ok(messages.length > 0);
|
||||||
|
const messageText = messages.join(" ");
|
||||||
|
assert.match(
|
||||||
|
messageText,
|
||||||
|
/manuell|täglich|wöchentlich|monatlich|Auswahl|gültig/i,
|
||||||
|
`Expected actionable German cadence guidance, got: ${messageText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
270
tests/campaign-scheduling.test.ts
Normal file
270
tests/campaign-scheduling.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import * as campaignScheduling from "../lib/campaign-scheduling";
|
||||||
|
|
||||||
|
type CampaignSchedulingModule = {
|
||||||
|
calculateNextRunAt: (input: {
|
||||||
|
recurrence: string;
|
||||||
|
status: "active" | "paused";
|
||||||
|
lastRunAt?: number | null;
|
||||||
|
now?: number;
|
||||||
|
}) => number | null;
|
||||||
|
getCampaignCurrentRunStatus: (
|
||||||
|
input: {
|
||||||
|
campaignStatus: "active" | "paused";
|
||||||
|
agentRuns?: Array<{
|
||||||
|
status: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
isAllowedCampaignRecurrence?: (value: string) => boolean;
|
||||||
|
CAMPAIGN_RECURRENCES?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SchedulingRun = {
|
||||||
|
status: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulingModule = campaignScheduling as unknown as CampaignSchedulingModule;
|
||||||
|
|
||||||
|
function addDaysUTC(timestamp: number, days: number) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth(),
|
||||||
|
date.getUTCDate() + days,
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
date.getUTCSeconds(),
|
||||||
|
date.getUTCMilliseconds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonthsUTC(timestamp: number, months: number) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth() + months,
|
||||||
|
date.getUTCDate(),
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
date.getUTCSeconds(),
|
||||||
|
date.getUTCMilliseconds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFunction<T>(value: unknown, name: string): T {
|
||||||
|
assert.equal(
|
||||||
|
typeof value,
|
||||||
|
"function",
|
||||||
|
`Expected ${name} to be implemented and exported`,
|
||||||
|
);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithLatest(updatedRuns: SchedulingRun[]) {
|
||||||
|
return updatedRuns.sort((a, b) => {
|
||||||
|
const aTime = typeof a.updatedAt === "number" ? a.updatedAt : 0;
|
||||||
|
const bTime = typeof b.updatedAt === "number" ? b.updatedAt : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("recurrence validation only allows manual/daily/weekly/monthly", () => {
|
||||||
|
const allowed = ["manual", "daily", "weekly", "monthly"];
|
||||||
|
const forbidden = ["hourly", "2xweekly", "biweekly", ""];
|
||||||
|
|
||||||
|
if (typeof schedulingModule.isAllowedCampaignRecurrence === "function") {
|
||||||
|
for (const value of allowed) {
|
||||||
|
assert.equal(
|
||||||
|
schedulingModule.isAllowedCampaignRecurrence(value),
|
||||||
|
true,
|
||||||
|
`${value} should be accepted`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const value of forbidden) {
|
||||||
|
assert.equal(
|
||||||
|
schedulingModule.isAllowedCampaignRecurrence(value),
|
||||||
|
false,
|
||||||
|
`${value} should be rejected`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
Array.isArray(schedulingModule.CAMPAIGN_RECURRENCES),
|
||||||
|
"Expected recurrence validation helper or CAMPAIGN_RECURRENCES list",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...schedulingModule.CAMPAIGN_RECURRENCES!].sort(),
|
||||||
|
[...allowed].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("next run is null for manual or paused campaigns and scheduled for active recurring campaigns", () => {
|
||||||
|
const calculateNextRunAt = expectFunction<(input: {
|
||||||
|
recurrence: string;
|
||||||
|
status: "active" | "paused";
|
||||||
|
lastRunAt?: number | null;
|
||||||
|
now?: number;
|
||||||
|
}) => number | null>(schedulingModule.calculateNextRunAt, "calculateNextRunAt");
|
||||||
|
|
||||||
|
const lastRunAt = Date.UTC(2026, 5, 1, 8, 0, 0);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
calculateNextRunAt({
|
||||||
|
recurrence: "manual",
|
||||||
|
status: "active",
|
||||||
|
lastRunAt,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calculateNextRunAt({
|
||||||
|
recurrence: "daily",
|
||||||
|
status: "paused",
|
||||||
|
lastRunAt,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyNext = calculateNextRunAt({
|
||||||
|
recurrence: "daily",
|
||||||
|
status: "active",
|
||||||
|
lastRunAt,
|
||||||
|
});
|
||||||
|
const weeklyNext = calculateNextRunAt({
|
||||||
|
recurrence: "weekly",
|
||||||
|
status: "active",
|
||||||
|
lastRunAt,
|
||||||
|
});
|
||||||
|
const monthlyNext = calculateNextRunAt({
|
||||||
|
recurrence: "monthly",
|
||||||
|
status: "active",
|
||||||
|
lastRunAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(dailyNext, addDaysUTC(lastRunAt, 1));
|
||||||
|
assert.equal(weeklyNext, addDaysUTC(lastRunAt, 7));
|
||||||
|
assert.equal(monthlyNext, addMonthsUTC(lastRunAt, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run status favors running/pending over finished states", () => {
|
||||||
|
const getCampaignCurrentRunStatus = expectFunction<
|
||||||
|
(input: {
|
||||||
|
campaignStatus: "active" | "paused";
|
||||||
|
agentRuns?: Array<{
|
||||||
|
status: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}>;
|
||||||
|
}) => string
|
||||||
|
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
|
||||||
|
|
||||||
|
const activeWithRunningAndFinished = runWithLatest([
|
||||||
|
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
|
||||||
|
{ status: "running", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
|
||||||
|
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runningStatus = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: activeWithRunningAndFinished,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
runningStatus,
|
||||||
|
"running",
|
||||||
|
"Active running campaign should surface running as current status",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingOverFinished = runWithLatest([
|
||||||
|
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
|
||||||
|
{ status: "pending", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
|
||||||
|
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
|
||||||
|
]);
|
||||||
|
const pendingStatus = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: pendingOverFinished,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
pendingStatus,
|
||||||
|
"pending",
|
||||||
|
"Pending should outrank finished statuses when no running is active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pausedWithoutRuns = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "paused",
|
||||||
|
agentRuns: [],
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
pausedWithoutRuns,
|
||||||
|
"paused",
|
||||||
|
"Paused campaigns should not report campaign activity by default",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run status uses latest run first (running, pending, otherwise terminal)", () => {
|
||||||
|
const getCampaignCurrentRunStatus = expectFunction<
|
||||||
|
(input: {
|
||||||
|
campaignStatus: "active" | "paused";
|
||||||
|
agentRuns?: Array<{
|
||||||
|
status: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}>;
|
||||||
|
}) => string
|
||||||
|
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
|
||||||
|
|
||||||
|
const activeWithoutRuns = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: [],
|
||||||
|
});
|
||||||
|
assert.equal(activeWithoutRuns, "idle");
|
||||||
|
|
||||||
|
const unsortedRuns = [
|
||||||
|
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
|
||||||
|
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
|
||||||
|
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
|
||||||
|
];
|
||||||
|
const latestRunWins = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: unsortedRuns,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
latestRunWins,
|
||||||
|
"failed",
|
||||||
|
"Latest status should determine current status, not any older run",
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsortedPending = [
|
||||||
|
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
|
||||||
|
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 9, 5, 0) },
|
||||||
|
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
|
||||||
|
];
|
||||||
|
const latestPending = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: unsortedPending,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
latestPending,
|
||||||
|
"pending",
|
||||||
|
"Pending latest run should be surfaced when it is the most recent.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsortedRunning = [
|
||||||
|
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
|
||||||
|
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 10, 5, 0) },
|
||||||
|
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
|
||||||
|
];
|
||||||
|
const latestRunning = getCampaignCurrentRunStatus({
|
||||||
|
campaignStatus: "active",
|
||||||
|
agentRuns: unsortedRunning,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
latestRunning,
|
||||||
|
"running",
|
||||||
|
"Running should be surfaced when it is the latest run.",
|
||||||
|
);
|
||||||
|
});
|
||||||
100
tests/campaign-validation.test.ts
Normal file
100
tests/campaign-validation.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
validateCampaignCreateInput,
|
||||||
|
validateCampaignUpdateInput,
|
||||||
|
} from "../lib/campaign-validation";
|
||||||
|
|
||||||
|
test("campaign mutation validation normalizes and enforces fixed Germany context", () => {
|
||||||
|
const payload = validateCampaignCreateInput({
|
||||||
|
status: "active",
|
||||||
|
recurrence: "daily",
|
||||||
|
postalCode: "10115",
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.countryCode, "DE");
|
||||||
|
assert.equal(payload.country, "Deutschland");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("campaign validation rejects invalid German PLZ", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCampaignCreateInput({
|
||||||
|
status: "active",
|
||||||
|
recurrence: "daily",
|
||||||
|
postalCode: "1234",
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
}),
|
||||||
|
(error: unknown) => {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("5") &&
|
||||||
|
/PLZ|Postleitzahl/i.test(error.message)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("campaign validation rejects decimal limits", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCampaignCreateInput({
|
||||||
|
status: "active",
|
||||||
|
recurrence: "daily",
|
||||||
|
postalCode: "10115",
|
||||||
|
radiusKm: 10.5,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
}),
|
||||||
|
(error: unknown) => {
|
||||||
|
return error instanceof Error && error.message.includes("ganze");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("campaign validation rejects invalid recurrence/status in German", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCampaignCreateInput({
|
||||||
|
status: "running",
|
||||||
|
recurrence: "daily",
|
||||||
|
postalCode: "10115",
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof Error && error.message.includes("Status"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCampaignCreateInput({
|
||||||
|
status: "active",
|
||||||
|
recurrence: "hourly",
|
||||||
|
postalCode: "10115",
|
||||||
|
radiusKm: 10,
|
||||||
|
maxNewLeadsPerRun: 5,
|
||||||
|
maxAuditsPerRun: 5,
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof Error && /Frequenz|ungültig/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("campaign update validation rejects partial Germany-context payloads", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCampaignUpdateInput({
|
||||||
|
countryCode: "DE",
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof Error &&
|
||||||
|
/vollständig|Deutschland-Kontext/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user