From 585c4eeb2aa8f3ad9d50c1f912357ff1f63feec0 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 4 Jun 2026 14:45:47 +0200 Subject: [PATCH] feat: add campaign configuration controls --- app/dashboard/campaigns/page.tsx | 11 +- ...n-configuration-and-scheduling-controls.md | 33 +- components/campaigns/campaign-form-dialog.tsx | 407 ++++++++++++++++ components/campaigns/campaigns-board.tsx | 454 ++++++++++++++++++ components/ui/badge.tsx | 41 ++ components/ui/card.tsx | 74 +++ components/ui/dialog.tsx | 106 ++++ components/ui/form.tsx | 218 +++++++++ components/ui/input.tsx | 22 + components/ui/label.tsx | 19 + components/ui/select.tsx | 87 ++++ components/ui/separator.tsx | 17 + components/ui/skeleton.tsx | 19 + components/ui/switch.tsx | 25 + convex/campaigns.ts | 317 +++++++++++- convex/schema.ts | 5 +- lib/campaign-form.ts | 117 +++++ lib/campaign-scheduling.ts | 103 ++++ lib/campaign-validation.ts | 218 +++++++++ package.json | 5 +- pnpm-lock.yaml | 42 +- tests/campaign-form.test.ts | 265 ++++++++++ tests/campaign-scheduling.test.ts | 270 +++++++++++ tests/campaign-validation.test.ts | 100 ++++ 24 files changed, 2941 insertions(+), 34 deletions(-) create mode 100644 components/campaigns/campaign-form-dialog.tsx create mode 100644 components/campaigns/campaigns-board.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 lib/campaign-form.ts create mode 100644 lib/campaign-scheduling.ts create mode 100644 lib/campaign-validation.ts create mode 100644 tests/campaign-form.test.ts create mode 100644 tests/campaign-scheduling.test.ts create mode 100644 tests/campaign-validation.test.ts diff --git a/app/dashboard/campaigns/page.tsx b/app/dashboard/campaigns/page.tsx index 94ba0f8..a94ac69 100644 --- a/app/dashboard/campaigns/page.tsx +++ b/app/dashboard/campaigns/page.tsx @@ -1,10 +1,11 @@ -import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; +import { CampaignsBoard } from "@/components/campaigns/campaigns-board"; export default function CampaignsPage() { return ( - +
+
+ +
+
); } diff --git a/backlog/tasks/task-5 - Implement-campaign-configuration-and-scheduling-controls.md b/backlog/tasks/task-5 - Implement-campaign-configuration-and-scheduling-controls.md index c4c6ed4..e6f72dd 100644 --- a/backlog/tasks/task-5 - Implement-campaign-configuration-and-scheduling-controls.md +++ b/backlog/tasks/task-5 - Implement-campaign-configuration-and-scheduling-controls.md @@ -1,9 +1,10 @@ --- id: TASK-5 title: Implement campaign configuration and scheduling controls -status: To Do +status: Done assignee: [] created_date: '2026-06-03 19:12' +updated_date: '2026-06-04 12:44' labels: - mvp - campaigns @@ -13,7 +14,7 @@ dependencies: references: - PRD.md priority: high -ordinal: 5000 +ordinal: 21000 --- ## Description @@ -24,11 +25,11 @@ Build the campaign management UI and backend mutations for reusable local search ## Acceptance Criteria -- [ ] #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 -- [ ] #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 -- [ ] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits +- [x] #1 Campaign create/edit forms use React Hook Form, Zod, and shadcn form components +- [x] #2 Campaigns support predefined categories plus Anderes with a required custom input +- [x] #3 Campaigns store PLZ, radius, cadence, max new leads, max audits, active/paused state, and Germany-only context +- [x] #4 Each campaign has a Jetzt ausführen action and shows last run, next run, and current run status +- [x] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits ## 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. 5. Verify campaign forms and dashboard state transitions. + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/components/campaigns/campaign-form-dialog.tsx b/components/campaigns/campaign-form-dialog.tsx new file mode 100644 index 0000000..8e77eb6 --- /dev/null +++ b/components/campaigns/campaign-form-dialog.tsx @@ -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; + +type CampaignFormSeed = Partial & { + _id?: string; +}; + +type CampaignFormDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + campaign?: CampaignFormSeed | null; + onSubmit: ( + payload: Omit & + Required> & { + countryCode: "DE"; + country: "Deutschland"; + }, + ) => Promise; +}; + +const categoryOptions = [ + "Anwalt", + "Bauunternehmen", + "Friseur", + "Gastronomie", + "Handwerk", + "Immobilien", + "Kfz-Werkstatt", + "Marketing", + "Restaurant", + "Zahnarzt", + "Anderes", +] as const; + +const recurrenceOptions: Record = { + manual: "manuell", + daily: "täglich", + weekly: "wöchentlich", + monthly: "monatlich", +}; + +const statusLabel: Record = { + active: "Aktiv", + paused: "Pausiert", +}; + +const customCategoryValue = "Anderes"; + +export function CampaignFormDialog({ + open, + onOpenChange, + campaign, + onSubmit, +}: CampaignFormDialogProps) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const methods = useForm({ + 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); + + 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 ( + + + + {dialogTitle} + + Wähle Kategorie, PLZ, Radius und Limits je Kampagne. + + + + +
+ ( + + Name + + + + + + )} + /> + + ( + + Kategorie + + + + )} + /> + + {showCustomSearch && ( + ( + + Eigene Nische + + + + + + )} + /> + )} + +
+ ( + + PLZ + + + + + + )} + /> + + ( + + Radius (km) + + { + const value = Number(event.target.value); + field.onChange(Number.isFinite(value) ? value : 0); + }} + /> + + + + )} + /> +
+ + ( + + Wiederholung + + + + )} + /> + +
+ ( + + Max. neue Leads + + { + const value = Number(event.target.value); + field.onChange(Number.isFinite(value) ? value : 0); + }} + /> + + + + )} + /> + + ( + + Max. Audits + + { + const value = Number(event.target.value); + field.onChange(Number.isFinite(value) ? value : 0); + }} + /> + + + + )} + /> +
+ + ( + + Status +
+ + + field.onChange(checked ? "active" : "paused") + } + /> + + {statusLabel[selectedStatus ?? "paused"]} +
+ +
+ )} + /> + + {error ?

{error}

: null} + +
+ + +
+ +
+
+ ); +} diff --git a/components/campaigns/campaigns-board.tsx b/components/campaigns/campaigns-board.tsx new file mode 100644 index 0000000..2816495 --- /dev/null +++ b/components/campaigns/campaigns-board.tsx @@ -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; +type CampaignRow = NonNullable[number]; + +type RecurrenceLabel = Record; +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(null); + const [isFormOpen, setIsFormOpen] = useState(false); + const [actionBusyId, setActionBusyId] = useState | null>(null); + const [actionLabel, setActionLabel] = useState(null); + const [formError, setFormError] = useState(null); + const [rowError, setRowError] = useState(null); + const actionLabelTimerRef = useRef | 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 ( +
+
+
+
+
+ +
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+ ); + } + + return ( +
+ + +
+
+

Lokale Kampagnenverwaltung

+

+ Kampagnen +

+
+ + +
+ + {formError ?

{formError}

: null} + {rowError ?

{rowError}

: null} + {actionLabel ?

{actionLabel}

: null} + + {campaignsSorted.length === 0 ? ( + + + Keine Kampagnen + + Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an. + + + + ) : ( + <> +
+ + + + + + + + + + + + + + + {campaignsSorted.map((campaign) => ( + + + + + + + + + + + + + + + + ))} + +
KampagnePLZ / RadiusCadenceLimitsStatusLaufAktionen
+
+

{campaign.name}

+

+ {formatNiche(campaign)} +

+
+
+
+

+ + {campaign.postalCode} +

+

{campaign.radiusKm} km Umkreis

+
+
+ + {recurrenceLabel[campaign.recurrence]} + + +

+ Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "} + {campaign.maxAuditsPerRun} +

+
+ + {campaign.status === "active" ? "Aktiv" : "Pausiert"} + + +
+

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

+

Nächster Lauf: {formatDateTime(campaign.nextRunAt)}

+

Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}

+
+
+
+ + + +
+
+
+ +
+ {campaignsSorted.map((campaign) => ( + + +
+
+ {campaign.name} + + {formatNiche(campaign)} + +
+ + {campaign.status === "active" ? "Aktiv" : "Pausiert"} + +
+
+ + +
+
+ + {campaign.postalCode} +
+ {campaign.radiusKm} km +
+ +
+

Cadence: {recurrenceLabel[campaign.recurrence]}

+

+ Limits: L {campaign.maxNewLeadsPerRun}, A{" "} + {campaign.maxAuditsPerRun} +

+
+
+

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

+

Nächster Lauf: {formatDateTime(campaign.nextRunAt)}

+

+ Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus} +

+
+ +
+ + + +
+
+
+ ))} +
+ + )} +
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..5371279 --- /dev/null +++ b/components/ui/badge.tsx @@ -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 & + VariantProps; + +const Badge = ({ className, variant, ...props }: BadgeProps) => ( + +); + +Badge.displayName = "Badge"; + +export { Badge, badgeVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..ac83fc5 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..f6274d3 --- /dev/null +++ b/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = "DialogOverlay"; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +DialogContent.displayName = "DialogContent"; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = "DialogTitle"; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = "DialogDescription"; + +const DialogClose = DialogPrimitive.Close; + +const DialogCloseButton = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>((props, ref) => ( + + + +)); +DialogCloseButton.displayName = "DialogCloseButton"; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, + DialogClose, + DialogCloseButton, +}; diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..24dbf0f --- /dev/null +++ b/components/ui/form.tsx @@ -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 = Omit< + React.FormHTMLAttributes, + "onSubmit" | "children" +> & { + form: UseFormReturn; + onSubmit: SubmitHandler; + children: React.ReactNode; +}; + +const FormItemContext = React.createContext<{ id: string } | null>(null); + +type FormFieldContextValue = { + name: string; +}; + +const FormFieldContext = React.createContext(null); + +const Form = ({ + form, + onSubmit, + children, + className, + ...props +}: FormProps) => { + return ( + +
+ {children} +
+
+ ); +}; + +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 ."); + } + + return { + control, + id: itemContext.id, + name: fieldContext.name, + ...getFieldState(fieldContext.name, formState), + }; +}; + +const FormField = < + TFieldValues extends FieldValues, + TName extends FieldPath, +>({ + ...props +}: Omit, "render"> & { + render: ControllerProps["render"]; +}) => { + return ( + + + + ); +}; + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + HTMLLabelElement, + React.LabelHTMLAttributes +>(({ className, ...props }, ref) => { + const { error, id } = useFormField(); + + return ( +