feat: add campaign configuration controls

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

41
components/ui/badge.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };

View 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 };

View 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
View 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 };