feat: add campaign configuration controls
This commit is contained in:
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 };
|
||||
Reference in New Issue
Block a user