diff options
Diffstat (limited to 'frontend/src/components')
-rw-r--r-- | frontend/src/components/auth/LoginForm.js | 99 | ||||
-rw-r--r-- | frontend/src/components/auth/RegisterForm.js | 167 | ||||
-rw-r--r-- | frontend/src/components/layouts/AdminLayout.js | 300 | ||||
-rw-r--r-- | frontend/src/components/layouts/MainLayout.js | 256 | ||||
-rw-r--r-- | frontend/src/components/ui/button.jsx | 55 | ||||
-rw-r--r-- | frontend/src/components/ui/form.jsx | 144 | ||||
-rw-r--r-- | frontend/src/components/ui/input.jsx | 24 | ||||
-rw-r--r-- | frontend/src/components/ui/label.jsx | 23 | ||||
-rw-r--r-- | frontend/src/components/ui/sonner.jsx | 26 |
9 files changed, 1094 insertions, 0 deletions
diff --git a/frontend/src/components/auth/LoginForm.js b/frontend/src/components/auth/LoginForm.js new file mode 100644 index 0000000..fd5aeb7 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.js @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAuth } from '@/context/auth-context'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +// Form validation schema +const formSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + +const LoginForm = () => { + const { login } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + const onSubmit = async (values) => { + try { + setIsLoading(true); + setError(''); + await login({ + email: values.email, + password: values.password, + }); + } catch (error) { + setError(error.message || 'Login failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( + <div className="w-full max-w-md mx-auto"> + <div className="bg-white dark:bg-slate-800 p-8 rounded-lg shadow-md"> + <h2 className="text-2xl font-bold mb-6 text-center">Login</h2> + + {error && ( + <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-md mb-4"> + {error} + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="[email protected]" type="email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input placeholder="••••••••" type="password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Button + type="submit" + className="w-full" + disabled={isLoading} + > + {isLoading ? 'Logging in...' : 'Login'} + </Button> + </form> + </Form> + </div> + </div> + ); +}; + +export default LoginForm;
\ No newline at end of file diff --git a/frontend/src/components/auth/RegisterForm.js b/frontend/src/components/auth/RegisterForm.js new file mode 100644 index 0000000..3c321ea --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.js @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAuth } from '@/context/auth-context'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +// Form validation schema +const formSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Invalid email address'), + password: z.string().min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().min(6, 'Password must be at least 6 characters'), + phone: z.string().optional(), + address: z.string().optional(), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], +}); + +const RegisterForm = () => { + const { register } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + email: '', + password: '', + confirmPassword: '', + phone: '', + address: '', + }, + }); + + const onSubmit = async (values) => { + try { + setIsLoading(true); + setError(''); + + // Remove confirmPassword from the data sent to API + const { confirmPassword, ...userData } = values; + + await register(userData); + } catch (error) { + setError(error.message || 'Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( + <div className="w-full max-w-md mx-auto"> + <div className="bg-white dark:bg-slate-800 p-8 rounded-lg shadow-md"> + <h2 className="text-2xl font-bold mb-6 text-center">Create Account</h2> + + {error && ( + <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-md mb-4"> + {error} + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input placeholder="John Doe" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="[email protected]" type="email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input placeholder="••••••••" type="password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input placeholder="••••••••" type="password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone (Optional)</FormLabel> + <FormControl> + <Input placeholder="(123) 456-7890" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>Address (Optional)</FormLabel> + <FormControl> + <Input placeholder="123 Main St, City, State" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Button + type="submit" + className="w-full" + disabled={isLoading} + > + {isLoading ? 'Creating Account...' : 'Register'} + </Button> + </form> + </Form> + </div> + </div> + ); +}; + +export default RegisterForm;
\ No newline at end of file diff --git a/frontend/src/components/layouts/AdminLayout.js b/frontend/src/components/layouts/AdminLayout.js new file mode 100644 index 0000000..fc05708 --- /dev/null +++ b/frontend/src/components/layouts/AdminLayout.js @@ -0,0 +1,300 @@ +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/auth-context'; +import { useTheme } from '@/context/theme-context'; + +const AdminLayout = ({ children }) => { + const { user, logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); + const router = useRouter(); + + // Redirect if not admin + if (user && user.role !== 'admin') { + router.push('/login'); + return null; + } + + return ( + <div className="flex min-h-screen bg-gray-50 dark:bg-slate-900"> + {/* Sidebar */} + <aside className="w-64 bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 fixed h-full"> + <div className="p-4 border-b border-gray-200 dark:border-slate-700"> + <Link href="/admin" className="text-xl font-bold text-primary"> + Admin Dashboard + </Link> + </div> + + <nav className="p-4"> + <ul className="space-y-2"> + <li> + <Link + href="/admin" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <rect width="7" height="9" x="3" y="3" rx="1" /> + <rect width="7" height="5" x="14" y="3" rx="1" /> + <rect width="7" height="9" x="14" y="12" rx="1" /> + <rect width="7" height="5" x="3" y="16" rx="1" /> + </svg> + Dashboard + </Link> + </li> + <li> + <Link + href="/admin/users" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> + <circle cx="9" cy="7" r="4" /> + <path d="M22 21v-2a4 4 0 0 0-3-3.87" /> + <path d="M16 3.13a4 4 0 0 1 0 7.75" /> + </svg> + Users + </Link> + </li> + <li> + <Link + href="/admin/menu" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <path d="M3 3h18v18H3V3z" /> + <path d="M9 3v18" /> + </svg> + Menu Management + </Link> + </li> + <li> + <Link + href="/admin/orders" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <path d="M5 7 3 5" /> + <path d="M9 3 3 9H2v4.5L7 9" /> + <circle cx="9" cy="15" r="6" /> + </svg> + Orders + </Link> + </li> + <li> + <Link + href="/admin/reservations" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <rect width="18" height="18" x="3" y="4" rx="2" ry="2" /> + <line x1="16" x2="16" y1="2" y2="6" /> + <line x1="8" x2="8" y1="2" y2="6" /> + <line x1="3" x2="21" y1="10" y2="10" /> + </svg> + Reservations + </Link> + </li> + <li> + <Link + href="/admin/reports" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <path d="M22 12h-4l-3 9L9 3l-3 9H2" /> + </svg> + Reports + </Link> + </li> + <li> + <Link + href="/admin/feedback" + className="flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="mr-2" + > + <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> + </svg> + Feedback + </Link> + </li> + </ul> + </nav> + </aside> + + {/* Main Content */} + <div className="ml-64 flex-1 flex flex-col"> + {/* Header */} + <header className="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 py-4 px-6 flex justify-between items-center"> + <h1 className="text-xl font-semibold">Admin Dashboard</h1> + + <div className="flex items-center space-x-4"> + {/* Theme Toggle */} + <button onClick={toggleTheme} className="hover:text-primary transition-colors"> + {theme === 'light' ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="4" /> + <path d="M12 2v2" /> + <path d="M12 20v2" /> + <path d="m4.93 4.93 1.41 1.41" /> + <path d="m17.66 17.66 1.41 1.41" /> + <path d="M2 12h2" /> + <path d="M20 12h2" /> + <path d="m6.34 17.66-1.41 1.41" /> + <path d="m19.07 4.93-1.41 1.41" /> + </svg> + )} + </button> + + {/* User Menu */} + <div className="relative group"> + <button className="flex items-center hover:text-primary transition-colors"> + <span className="mr-2">{user?.name || 'Admin'}</span> + <svg + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="m6 9 6 6 6-6" /> + </svg> + </button> + + {/* Dropdown Menu */} + <div className="absolute right-0 mt-2 w-48 bg-white dark:bg-slate-800 rounded-md shadow-lg border border-gray-200 dark:border-slate-700 hidden group-hover:block"> + <div className="py-1"> + <Link + href="/profile" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-700" + > + Profile + </Link> + <Link + href="/" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-700" + > + View Website + </Link> + <button + onClick={logout} + className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-700 text-red-600" + > + Logout + </button> + </div> + </div> + </div> + </div> + </header> + + {/* Main Content */} + <main className="flex-1 p-6 bg-gray-50 dark:bg-slate-900">{children}</main> + </div> + </div> + ); +}; + +export default AdminLayout;
\ No newline at end of file diff --git a/frontend/src/components/layouts/MainLayout.js b/frontend/src/components/layouts/MainLayout.js new file mode 100644 index 0000000..51e0e7e --- /dev/null +++ b/frontend/src/components/layouts/MainLayout.js @@ -0,0 +1,256 @@ +import Link from 'next/link'; +import { useAuth } from '@/context/auth-context'; +import { useCart } from '@/context/cart-context'; +import { useTheme } from '@/context/theme-context'; + +const MainLayout = ({ children }) => { + const { user, isAuthenticated, logout } = useAuth(); + const { getItemCount } = useCart(); + const { theme, toggleTheme } = useTheme(); + + const cartItemCount = getItemCount(); + + return ( + <div className="flex flex-col min-h-screen"> + {/* Header */} + <header className="bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 sticky top-0 z-10"> + <div className="container mx-auto px-4 py-3 flex items-center justify-between"> + {/* Logo */} + <Link href="/" className="text-xl font-bold text-primary"> + Restaurant + </Link> + + {/* Navigation Menu */} + <nav className="hidden md:flex items-center space-x-6"> + <Link href="/" className="hover:text-primary transition-colors"> + Home + </Link> + <Link href="/menu" className="hover:text-primary transition-colors"> + Menu + </Link> + <Link href="/reservations" className="hover:text-primary transition-colors"> + Reservations + </Link> + <Link href="/about" className="hover:text-primary transition-colors"> + About + </Link> + <Link href="/contact" className="hover:text-primary transition-colors"> + Contact + </Link> + </nav> + + {/* User Actions */} + <div className="flex items-center space-x-4"> + {/* Cart Icon */} + <Link href="/cart" className="relative"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="hover:text-primary transition-colors" + > + <circle cx="8" cy="21" r="1" /> + <circle cx="19" cy="21" r="1" /> + <path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" /> + </svg> + {cartItemCount > 0 && ( + <span className="absolute -top-2 -right-2 bg-primary text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center"> + {cartItemCount} + </span> + )} + </Link> + + {/* Theme Toggle */} + <button onClick={toggleTheme} className="hover:text-primary transition-colors"> + {theme === 'light' ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="4" /> + <path d="M12 2v2" /> + <path d="M12 20v2" /> + <path d="m4.93 4.93 1.41 1.41" /> + <path d="m17.66 17.66 1.41 1.41" /> + <path d="M2 12h2" /> + <path d="M20 12h2" /> + <path d="m6.34 17.66-1.41 1.41" /> + <path d="m19.07 4.93-1.41 1.41" /> + </svg> + )} + </button> + + {/* Auth Actions */} + {isAuthenticated ? ( + <div className="relative group"> + <button className="flex items-center hover:text-primary transition-colors"> + <span className="mr-2">{user?.name || 'User'}</span> + <svg + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="m6 9 6 6 6-6" /> + </svg> + </button> + + {/* Dropdown Menu */} + <div className="absolute right-0 mt-2 w-48 bg-white dark:bg-slate-900 rounded-md shadow-lg border border-gray-200 dark:border-slate-700 hidden group-hover:block"> + <div className="py-1"> + <Link + href="/profile" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-800" + > + Profile + </Link> + <Link + href="/orders" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-800" + > + Orders + </Link> + {user?.role === 'admin' && ( + <Link + href="/admin" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-800" + > + Admin Dashboard + </Link> + )} + {user?.role === 'staff' && ( + <Link + href="/staff" + className="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-800" + > + Staff Dashboard + </Link> + )} + <button + onClick={logout} + className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-800 text-red-600" + > + Logout + </button> + </div> + </div> + </div> + ) : ( + <div className="flex items-center space-x-4"> + <Link + href="/login" + className="hover:text-primary transition-colors" + > + Login + </Link> + <Link + href="/register" + className="bg-primary text-white px-4 py-2 rounded-md hover:bg-primary/90 transition-colors" + > + Register + </Link> + </div> + )} + </div> + </div> + </header> + + {/* Main Content */} + <main className="flex-1 container mx-auto px-4 py-8">{children}</main> + + {/* Footer */} + <footer className="bg-white dark:bg-slate-900 border-t border-gray-200 dark:border-slate-700"> + <div className="container mx-auto px-4 py-8"> + <div className="grid grid-cols-1 md:grid-cols-3 gap-8"> + {/* Restaurant Info */} + <div> + <h3 className="text-lg font-bold mb-4">Restaurant</h3> + <p className="mb-2">123 Main Street</p> + <p className="mb-2">City, State 12345</p> + <p className="mb-2">Phone: (123) 456-7890</p> + <p>Email: [email protected]</p> + </div> + + {/* Opening Hours */} + <div> + <h3 className="text-lg font-bold mb-4">Opening Hours</h3> + <p className="mb-2">Monday - Friday: 9am - 10pm</p> + <p className="mb-2">Saturday: 10am - 11pm</p> + <p>Sunday: 10am - 9pm</p> + </div> + + {/* Quick Links */} + <div> + <h3 className="text-lg font-bold mb-4">Quick Links</h3> + <ul className="space-y-2"> + <li> + <Link href="/menu" className="hover:text-primary transition-colors"> + Menu + </Link> + </li> + <li> + <Link href="/reservations" className="hover:text-primary transition-colors"> + Reservations + </Link> + </li> + <li> + <Link href="/about" className="hover:text-primary transition-colors"> + About Us + </Link> + </li> + <li> + <Link href="/contact" className="hover:text-primary transition-colors"> + Contact + </Link> + </li> + <li> + <Link href="/privacy" className="hover:text-primary transition-colors"> + Privacy Policy + </Link> + </li> + </ul> + </div> + </div> + + <div className="border-t border-gray-200 dark:border-slate-700 mt-8 pt-6 text-center"> + <p>© {new Date().getFullYear()} Restaurant. All rights reserved.</p> + </div> + </div> + </footer> + </div> + ); +}; + +export default MainLayout;
\ No newline at end of file diff --git a/frontend/src/components/ui/button.jsx b/frontend/src/components/ui/button.jsx new file mode 100644 index 0000000..69ad71f --- /dev/null +++ b/frontend/src/components/ui/button.jsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}) { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} /> + ); +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/form.jsx b/frontend/src/components/ui/form.jsx new file mode 100644 index 0000000..728ea87 --- /dev/null +++ b/frontend/src/components/ui/form.jsx @@ -0,0 +1,144 @@ +"use client"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { Controller, FormProvider, useFormContext, useFormState } from "react-hook-form"; + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +const FormFieldContext = React.createContext({}) + +const FormField = ( + { + ...props + } +) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +const FormItemContext = React.createContext({}) + +function FormItem({ + className, + ...props +}) { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div data-slot="form-item" className={cn("grid gap-2", className)} {...props} /> + </FormItemContext.Provider> + ); +} + +function FormLabel({ + className, + ...props +}) { + const { error, formItemId } = useFormField() + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn("data-[error=true]:text-destructive", className)} + htmlFor={formItemId} + {...props} /> + ); +} + +function FormControl({ + ...props +}) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} /> + ); +} + +function FormDescription({ + className, + ...props +}) { + const { formDescriptionId } = useFormField() + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} /> + ); +} + +function FormMessage({ + className, + ...props +}) { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message ?? "") : props.children + + if (!body) { + return null + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn("text-destructive text-sm", className)} + {...props}> + {body} + </p> + ); +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx new file mode 100644 index 0000000..1e9bbd1 --- /dev/null +++ b/frontend/src/components/ui/input.jsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ + className, + type, + ...props +}) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + className + )} + {...props} /> + ); +} + +export { Input } diff --git a/frontend/src/components/ui/label.jsx b/frontend/src/components/ui/label.jsx new file mode 100644 index 0000000..1002a4f --- /dev/null +++ b/frontend/src/components/ui/label.jsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} /> + ); +} + +export { Label } diff --git a/frontend/src/components/ui/sonner.jsx b/frontend/src/components/ui/sonner.jsx new file mode 100644 index 0000000..8079d58 --- /dev/null +++ b/frontend/src/components/ui/sonner.jsx @@ -0,0 +1,26 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner"; + +const Toaster = ({ + ...props +}) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme} + className="toaster group" + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)" + } + } + {...props} /> + ); +} + +export { Toaster } |