img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..37fb2d9
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ InputGroup,
+ InputGroupAddon,
+} from "@/components/ui/input-group"
+import { SearchIcon, CheckIcon } from "lucide-react"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = false,
+ ...props
+}: Omit, "children"> & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/src/components/ui/creatable-combobox.tsx b/src/components/ui/creatable-combobox.tsx
new file mode 100644
index 0000000..1ba3f06
--- /dev/null
+++ b/src/components/ui/creatable-combobox.tsx
@@ -0,0 +1,116 @@
+import * as React from "react"
+import { Check, ChevronsUpDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+interface CreatableComboboxProps {
+ options: string[]
+ value?: string
+ onChange: (value: string) => void
+ placeholder?: string
+ emptyText?: string
+}
+
+export function CreatableCombobox({
+ options,
+ value,
+ onChange,
+ placeholder = "Select or create...",
+ emptyText = "No results found.",
+}: CreatableComboboxProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState("")
+
+ const exactMatchExists = options.some(
+ option => option.toLowerCase() === inputValue.toLowerCase()
+ )
+ const showCreateOption = inputValue.trim() !== "" && !exactMatchExists
+
+ return (
+
+
+ }
+ >
+ {value || placeholder}
+
+
+
+ {
+ if (value.toLowerCase().includes(search.toLowerCase())) return 1
+ return 0
+ }}>
+
+
+
+ {showCreateOption ? null : emptyText}
+
+
+ {options.map((option) => (
+ {
+ onChange(option)
+ setOpen(false)
+ setInputValue("")
+ }}
+ >
+
+ {option}
+
+ ))}
+ {showCreateOption && (
+ {
+ onChange(inputValue.trim())
+ setOpen(false)
+ setInputValue("")
+ }}
+ >
+
+ Create "{inputValue}"
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..208b13b
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,266 @@
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..9d364bd
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -0,0 +1,156 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+ "block-start":
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+ "block-end":
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 7d21bab..54d2fc8 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 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 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+ "h-9 w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2 text-base transition-colors outline-none file:inline-flex file:h-6 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 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..1428fdd
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..3172cf3
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { Radio as RadioPrimitive } from "@base-ui/react/radio"
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8c56aa
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..9498efe
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,30 @@
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..ac9585e
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ |
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/context/CampaignRuleContext.tsx b/src/context/CampaignRuleContext.tsx
index 983840f..2c16a99 100644
--- a/src/context/CampaignRuleContext.tsx
+++ b/src/context/CampaignRuleContext.tsx
@@ -1,5 +1,6 @@
-import { createContext, useContext, useState } from "react";
+import { createContext, useContext, useState, useEffect } from "react";
import type { ReactNode } from "react";
+import type { Event } from "../types/event";
export type RuleCondition = {
id: string;
@@ -29,6 +30,9 @@ export type CampaignRuleState = {
};
formula: any;
notification: any;
+ availableEvents: Event[];
+ isLoadingEvents: boolean;
+ eventsError: string | null;
};
type CampaignRuleContextType = {
@@ -52,6 +56,9 @@ const initialState: CampaignRuleState = {
},
formula: {},
notification: {},
+ availableEvents: [],
+ isLoadingEvents: false,
+ eventsError: null,
};
const CampaignRuleContext = createContext(undefined);
@@ -60,6 +67,21 @@ export const CampaignRuleProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState(initialState);
const [currentStep, setCurrentStep] = useState(0);
+ useEffect(() => {
+ const fetchEvents = async () => {
+ setState((prev) => ({ ...prev, isLoadingEvents: true, eventsError: null }));
+ try {
+ const res = await fetch('/api/v1/events');
+ if (!res.ok) throw new Error('Failed to fetch events');
+ const data = await res.json();
+ setState((prev) => ({ ...prev, availableEvents: data, isLoadingEvents: false }));
+ } catch (err: any) {
+ setState((prev) => ({ ...prev, eventsError: err.message, isLoadingEvents: false }));
+ }
+ };
+ fetchEvents();
+ }, []);
+
const updateState = (section: keyof CampaignRuleState, payload: any) => {
setState((prev) => ({
...prev,
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..225b170 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,8 +3,18 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
+async function enableMocking() {
+ if (import.meta.env.MODE !== 'development') {
+ return
+ }
+ const { worker } = await import('./mocks/browser')
+ return worker.start()
+}
+
+enableMocking().then(() => {
+ createRoot(document.getElementById('root')!).render(
+
+
+ ,
+ )
+})
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
new file mode 100644
index 0000000..4dd03f0
--- /dev/null
+++ b/src/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw/browser'
+import { handlers } from './handlers'
+
+export const worker = setupWorker(...handlers)
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 0000000..eeea9f6
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,276 @@
+import { http, HttpResponse, delay } from 'msw'
+import type { Event } from '../types/event'
+
+let events: Event[] = [
+ {
+ id: 'event-login-unified',
+ name: 'Login',
+ type: 'COUNTER',
+ targetTopic: 'tracking-topic',
+ attributes: [
+ { id: 'attr-login-1', key: 'customerId', displayName: 'Mã Khách Hàng', type: 'STRING', required: true }
+ ],
+ instructions: [
+ {
+ id: 'inst-login-1',
+ name: 'First Login Bonus',
+ entityType: 'Customer',
+ attributeCode: 'first_login_flag',
+ operation: 'SET_TRUE_ONCE',
+ triggerEventOnSuccess: 'First_Login_Reward'
+ },
+ {
+ id: 'inst-login-2',
+ name: 'Login Streak',
+ entityType: 'Customer',
+ attributeCode: 'current_streak',
+ operation: 'STREAK_INCREMENT',
+ thresholds: [
+ { id: 'th-login-2', operator: '>=', value: 7, triggerEvent: 'Login_Streak_7_Days', resetPolicy: 'NONE' },
+ { id: 'th-login-3', operator: '>=', value: 30, triggerEvent: 'Login_Streak_30_Days', resetPolicy: 'RESET_ON_TRIGGER' }
+ ]
+ },
+ {
+ id: 'inst-login-3',
+ name: 'Inactivity Monitor',
+ entityType: 'Customer',
+ attributeCode: 'last_login_date',
+ operation: 'CHECK_AND_UPDATE_TIMER',
+ thresholds: [
+ { id: 'th-login-4', operator: '>=', value: 90, triggerEvent: 'Account_Reactivated', resetPolicy: 'NONE' }
+ ]
+ }
+ ]
+ },
+ {
+ id: 'uc-03',
+ name: 'View_Item',
+ type: 'COUNTER',
+ targetTopic: 'tracking-topic',
+ attributes: [
+ { id: 'attr-uc03-1', key: 'customerId', displayName: 'Mã Khách Hàng', type: 'STRING', required: true },
+ { id: 'attr-uc03-2', key: 'itemId', displayName: 'Mã Sản Phẩm', type: 'STRING', required: true }
+ ],
+ instructions: [
+ {
+ id: 'inst-uc03-1',
+ name: 'Weekly View Count',
+ entityType: 'Customer',
+ attributeCode: 'weekly_view_count',
+ operation: 'INCREMENT',
+ periodType: 'WEEKLY',
+ step: 1,
+ thresholds: [
+ { id: 'th-uc03-1', operator: '>=', value: 10, triggerEvent: 'View_10_Items_Reward', resetPolicy: 'RESET_ON_TRIGGER' }
+ ]
+ },
+ {
+ id: 'inst-uc03-2',
+ name: 'Item View Count',
+ entityType: 'Item',
+ attributeCode: 'item_view_count',
+ operation: 'INCREMENT',
+ step: 1,
+ thresholds: []
+ },
+ {
+ id: 'inst-uc03-3',
+ name: 'Recently Viewed Items',
+ entityType: 'Customer',
+ attributeCode: 'recently_viewed',
+ operation: 'APPEND_UNIQUE',
+ sourceField: 'event.itemId',
+ maxLength: 50,
+ thresholds: []
+ }
+ ]
+ },
+ {
+ id: 'uc-04',
+ name: 'Transfer_Balance',
+ type: 'DIRECT',
+ targetTopic: 'events-topic',
+ transactionCode: 'TRANSFER',
+ attributes: [
+ { id: 'attr-uc04-1', key: 'fromCustomerId', displayName: 'Người Gửi', type: 'STRING', required: true },
+ { id: 'attr-uc04-2', key: 'toCustomerId', displayName: 'Người Nhận', type: 'STRING', required: true },
+ { id: 'attr-uc04-3', key: 'amount', displayName: 'Số Tiền', type: 'NUMBER', required: true }
+ ]
+ },
+ {
+ id: 'uc-06',
+ name: 'Welcome_Offer',
+ type: 'DIRECT',
+ targetTopic: 'events-topic',
+ transactionCode: 'NEW_CARD_REWARD',
+ attributes: [
+ { id: 'attr-uc06-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc06-2', key: 'cardId', type: 'STRING', required: true }
+ ]
+ },
+ {
+ id: 'uc-07',
+ name: 'Profile_Completed',
+ type: 'DIRECT',
+ targetTopic: 'events-topic',
+ transactionCode: 'PROFILE_COMPLETE_REWARD',
+ attributes: [
+ { id: 'attr-uc07-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc07-2', key: 'completedAt', type: 'DATETIME', required: true }
+ ]
+ },
+ {
+ id: 'uc-08',
+ name: 'Survey_Completed',
+ type: 'COUNTER',
+ targetTopic: 'events-topic',
+ transactionCode: 'SURVEY_REWARD',
+ attributes: [
+ { id: 'attr-uc08-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc08-2', key: 'surveyId', type: 'STRING', required: true }
+ ],
+ instructions: [
+ {
+ id: 'inst-uc08-1',
+ name: 'Mark Survey Completed',
+ entityType: 'Customer',
+ attributeCode: 'survey_completed',
+ operation: 'SET_TRUE_ONCE',
+ triggerEventOnSuccess: 'Survey_Completed_Reward',
+ thresholds: []
+ }
+ ]
+ },
+ {
+ id: 'uc-09',
+ name: 'Redeem_Item',
+ type: 'DIRECT',
+ targetTopic: 'events-topic',
+ transactionCode: 'REDEEM',
+ attributes: [
+ { id: 'attr-uc09-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc09-2', key: 'itemId', type: 'STRING', required: true },
+ { id: 'attr-uc09-3', key: 'pointCost', type: 'NUMBER', required: true }
+ ]
+ },
+ {
+ id: 'uc-10',
+ name: 'Voucher_Used',
+ type: 'NOTIFICATION',
+ targetTopic: 'noti-topic',
+ notificationId: 'TPL_VOUCHER_USED',
+ attributes: [
+ { id: 'attr-uc10-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc10-2', key: 'voucherName', type: 'STRING', required: true }
+ ]
+ },
+ {
+ id: 'uc-11',
+ name: 'Password_Expiring',
+ type: 'NOTIFICATION',
+ targetTopic: 'noti-topic',
+ notificationId: 'TPL_PWD_EXP',
+ attributes: [
+ { id: 'attr-uc11-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc11-2', key: 'expiryDate', type: 'DATETIME', required: true }
+ ]
+ },
+ {
+ id: 'uc-12',
+ name: 'Item_Redeemed_Count',
+ type: 'COUNTER',
+ targetTopic: 'tracking-topic',
+ attributes: [
+ { id: 'attr-uc12-1', key: 'itemId', type: 'STRING', required: true }
+ ],
+ instructions: [
+ {
+ id: 'inst-uc12-1',
+ name: 'Total Redeem Count',
+ entityType: 'Item',
+ attributeCode: 'total_redeem_count',
+ operation: 'INCREMENT',
+ thresholds: []
+ }
+ ]
+ },
+ {
+ id: 'uc-13',
+ name: 'Dormant_User',
+ type: 'DIRECT',
+ targetTopic: 'events-topic',
+ transactionCode: 'DORMANT_REWARD',
+ attributes: [
+ { id: 'attr-uc13-1', key: 'customerId', type: 'STRING', required: true },
+ { id: 'attr-uc13-2', key: 'inactiveDays', type: 'NUMBER', required: true }
+ ]
+ }
+].map(e => ({ ...e, status: e.status || 'ACTIVE' })) as Event[];
+
+export const handlers = [
+ http.get('/api/v1/events', async () => {
+ await delay(300);
+ return HttpResponse.json(events);
+ }),
+
+ http.get('/api/v1/events/:id', async ({ params }) => {
+ await delay(300);
+ const event = events.find(e => e.id === params.id);
+ if (!event) return new HttpResponse(null, { status: 404 });
+ return HttpResponse.json(event);
+ }),
+
+ http.post('/api/v1/events', async ({ request }) => {
+ await delay(500);
+ const newEvent = await request.json() as Event;
+ newEvent.id = Math.random().toString(36).substring(7);
+ events.push(newEvent);
+ return HttpResponse.json(newEvent, { status: 201 });
+ }),
+
+ http.put('/api/v1/events/:id', async ({ request, params }) => {
+ await delay(500);
+ const updatedEvent = await request.json() as Event;
+ events = events.map(e => e.id === params.id ? { ...updatedEvent, id: params.id as string } : e);
+ return HttpResponse.json(updatedEvent);
+ }),
+
+ http.delete('/api/v1/events/:id', async ({ params }) => {
+ await delay(300);
+ events = events.filter(e => e.id !== params.id);
+ return new HttpResponse(null, { status: 204 });
+ }),
+
+ http.patch('/api/v1/events/:id/status', async ({ request, params }) => {
+ await delay(300);
+ const { status } = await request.json() as { status: 'ACTIVE' | 'INACTIVE' };
+ events = events.map(e => e.id === params.id ? { ...e, status } : e);
+ const updated = events.find(e => e.id === params.id);
+ if (!updated) return new HttpResponse(null, { status: 404 });
+ return HttpResponse.json(updated);
+ }),
+
+ http.get('/api/v1/attributes/schema', async () => {
+ await delay(300);
+ const schema = {
+ CUSTOMER: [
+ { code: 'total_spent', type: 'NUMBER' },
+ { code: 'first_login_flag', type: 'BOOLEAN' },
+ { code: 'survey_completed', type: 'BOOLEAN' },
+ { code: 'weekly_view_count', type: 'NUMBER' },
+ { code: 'last_login_date', type: 'DATETIME' },
+ { code: 'current_streak', type: 'NUMBER' },
+ { code: 'recently_viewed', type: 'STRING' }
+ ],
+ ITEM: [
+ { code: 'total_redeem_count', type: 'NUMBER' },
+ { code: 'item_view_count', type: 'NUMBER' }
+ ],
+ ORGANIZATION: [
+ { code: 'active_campaigns', type: 'NUMBER' },
+ { code: 'total_budget', type: 'NUMBER' }
+ ]
+ };
+ return HttpResponse.json(schema);
+ })
+]
diff --git a/src/pages/CampaignRuleWizard/CriteriaBuilder/RuleCondition.tsx b/src/pages/CampaignRuleWizard/CriteriaBuilder/RuleCondition.tsx
index f631ba3..62a5afc 100644
--- a/src/pages/CampaignRuleWizard/CriteriaBuilder/RuleCondition.tsx
+++ b/src/pages/CampaignRuleWizard/CriteriaBuilder/RuleCondition.tsx
@@ -1,7 +1,9 @@
import type { RuleCondition } from "@/context/CampaignRuleContext";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { X } from "lucide-react";
+import { X, ChevronDown } from "lucide-react";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useCampaignRule } from "@/context/CampaignRuleContext";
type ConditionProps = {
@@ -45,23 +47,72 @@ export function Condition({ condition, groupId }: ConditionProps) {
{condition.label}
-
+
- handleUpdate("value", e.target.value)}
- />
+ {condition.attribute === "SYS_EVENT_ID" ? (
+
+
+
+
+
+
+
Available Events
+ {state.availableEvents.map(evt => {
+ const isSelected = condition.value.split(",").map(v => v.trim()).includes(evt.id);
+ return (
+
+ );
+ })}
+
+
+
+ ) : (
+ handleUpdate("value", e.target.value)}
+ />
+ )}