feat/authentication #5

Merged
ATS merged 10 commits from feat/authentication into master 2025-11-07 20:02:41 +00:00
16 changed files with 927 additions and 34 deletions
Showing only changes of commit 4d7201d03e - Show all commits

View File

@ -0,0 +1,18 @@
"use client";
import SideNav from "@/components/side-nav";
export default function Booking() {
return (
<div className="min-h-screen bg-gray-50">
{/* Side Navigation */}
<SideNav />
<div className="md:ml-[250px]">
<main className="p-4 sm:p-6 lg:p-8">
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import SideNav from "@/components/side-nav";
export default function Dashboard() {
return (
<div className="min-h-screen bg-gray-50">
{/* Side Navigation */}
<SideNav />
<div className="md:ml-[250px]">
{/* Main Content */}
<main className="p-4 sm:p-6 lg:p-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
</main>
</div>
</div>
);
}

7
app/(admin)/layout.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div>
{children}
</div>
)
}

143
app/(admin)/login/page.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Heart, Eye, EyeOff, X } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
export default function Login() {
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const router = useRouter();
return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<Image
src="/doctors.png"
alt="Medical professionals"
fill
className="object-cover object-center"
priority
sizes="100vw"
/>
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/20"></div>
</div>
{/* Branding - Top Left */}
<div className="absolute top-8 left-8 flex items-center gap-3 z-30">
<Heart className="w-6 h-6 text-white" fill="white" />
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
</div>
{/* Centered White Card - Login Form */}
<div className="relative z-20 w-full max-w-md bg-white rounded-2xl shadow-2xl p-8">
{/* Close Button */}
<Button
onClick={() => router.back()}
variant="ghost"
size="icon"
className="ml-auto mb-6 w-8 h-8 rounded-full"
aria-label="Close"
>
<X className="w-5 h-5 text-black" />
</Button>
{/* Heading */}
<h1 className="text-3xl font-bold text-black mb-2">
Welcome back
</h1>
{/* Sign Up Prompt */}
<p className="text-gray-600 mb-8">
New to Attune Heart Therapy?{" "}
<Link href="/signup" className="text-blue-600 underline font-medium">
Sign up
</Link>
</p>
{/* Login Form */}
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-black">
Email address
</label>
<Input
id="email"
type="email"
placeholder="Email address"
className="h-12 bg-white border-gray-300"
required
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-black">
Your password
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
className="h-12 bg-white border-gray-300 pr-12"
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 text-gray-500 hover:text-gray-700"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full h-12 text-base font-semibold bg-[#4A90A4] hover:bg-[#3a7a8a] text-white"
>
Log in
</Button>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-[#4A90A4] focus:ring-2 focus:ring-[#4A90A4] cursor-pointer"
/>
<span className="text-black">Remember me</span>
</label>
<Link
href="/forgot-password"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Forgot password?
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import SideNav from "@/components/side-nav";
import { Notifications, Notification } from "@/components/notifications";
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([
{
id: "1",
type: "appointment",
title: "New Appointment Request",
message: "Sarah Johnson requested an appointment for tomorrow at 2:00 PM",
time: "2 minutes ago",
read: false,
},
{
id: "2",
type: "success",
title: "Appointment Confirmed",
message: "Your appointment with Michael Chen has been confirmed for today at 10:00 AM",
time: "1 hour ago",
read: false,
},
{
id: "3",
type: "warning",
title: "Appointment Reminder",
message: "You have an appointment in 30 minutes with Emily Davis",
time: "3 hours ago",
read: false,
},
{
id: "4",
type: "info",
title: "New Message",
message: "You received a new message from John Smith",
time: "5 hours ago",
read: true,
},
{
id: "5",
type: "appointment",
title: "Appointment Cancelled",
message: "Robert Wilson cancelled his appointment scheduled for tomorrow",
time: "1 day ago",
read: true,
},
]);
const handleMarkAsRead = (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
};
const handleDismiss = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
const handleMarkAllAsRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
return (
<div className="min-h-screen bg-gray-50">
{/* Side Navigation */}
<SideNav />
<div className="md:ml-[250px]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Notifications
notifications={notifications}
onMarkAsRead={handleMarkAsRead}
onDismiss={handleDismiss}
onMarkAllAsRead={handleMarkAllAsRead}
/>
</div>
</div>
</div>
);
}

View File

@ -1,26 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-poppins);
--font-mono: var(--font-poppins);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
body.menu-open {
overflow: hidden;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,20 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Poppins } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const poppins = Poppins({
variable: "--font-poppins",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Attune Heart Therapy",
description: "Attune Heart Therapy",
};
export default function RootLayout({
@ -25,7 +21,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${poppins.variable} font-sans antialiased`}
>
{children}
</body>

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Bell,
X,
CheckCircle,
AlertCircle,
Info,
Calendar,
Clock,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface Notification {
id: string;
type: "success" | "warning" | "info" | "appointment";
title: string;
message: string;
time: string;
read: boolean;
}
interface NotificationsProps {
notifications: Notification[];
onMarkAsRead?: (id: string) => void;
onDismiss?: (id: string) => void;
onMarkAllAsRead?: () => void;
}
export function Notifications({
notifications,
onMarkAsRead,
onDismiss,
onMarkAllAsRead,
}: NotificationsProps) {
const unreadCount = notifications.filter((n) => !n.read).length;
const getIcon = (type: Notification["type"]) => {
switch (type) {
case "success":
return <CheckCircle className="w-5 h-5 text-green-600" />;
case "warning":
return <AlertCircle className="w-5 h-5 text-orange-600" />;
case "info":
return <Info className="w-5 h-5 text-blue-600" />;
case "appointment":
return <Calendar className="w-5 h-5 text-rose-600" />;
}
};
const getBgColor = (type: Notification["type"]) => {
switch (type) {
case "success":
return "bg-[#4A90A4]/10 border-[#4A90A4]/30";
case "warning":
return "bg-rose-100 border-rose-300";
case "info":
return "bg-pink-50 border-pink-200";
case "appointment":
return "bg-gradient-to-br from-rose-50 to-pink-50 border-rose-300";
}
};
return (
<div className="w-full max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Bell className="w-6 h-6 text-gray-900" />
<h2 className="text-2xl font-bold text-gray-900">Notifications</h2>
{unreadCount > 0 && (
<span className="px-2.5 py-0.5 bg-gradient-to-r from-rose-500 to-pink-500 text-white text-sm font-medium rounded-full">
{unreadCount}
</span>
)}
</div>
{unreadCount > 0 && onMarkAllAsRead && (
<Button variant="outline" size="sm" onClick={onMarkAllAsRead}>
Mark all as read
</Button>
)}
</div>
{/* Notifications List */}
<div className="space-y-3">
{notifications.length === 0 ? (
<div className="text-center py-12">
<Bell className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"p-4 rounded-lg border-2 transition-all",
getBgColor(notification.type),
!notification.read && "ring-2 ring-offset-2 ring-rose-300"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">{getIcon(notification.type)}</div>
<div className="flex-1">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3
className={cn(
"font-semibold mb-1",
!notification.read ? "text-gray-900" : "text-gray-700"
)}
>
{notification.title}
</h3>
<p className="text-sm text-gray-600 mb-2">{notification.message}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock className="w-3 h-3" />
{notification.time}
</div>
</div>
<div className="flex items-center gap-2">
{!notification.read && onMarkAsRead && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onMarkAsRead(notification.id)}
className="h-7 w-7"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
{onDismiss && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onDismiss(notification.id)}
className="h-7 w-7"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
// Notification Bell Component for Header
export function NotificationBell({
count,
onClick,
}: {
count: number;
onClick: () => void;
}) {
return (
<Button variant="ghost" size="icon" className="relative" onClick={onClick}>
<Bell className="w-5 h-5" />
{count > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-medium">
{count > 9 ? "9+" : count}
</span>
)}
</Button>
);
}

156
components/side-nav.tsx Normal file
View File

@ -0,0 +1,156 @@
"use client";
import React, { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import {
LayoutGrid,
Calendar,
Settings,
LogOut,
Menu,
X,
Heart,
} from "lucide-react";
const navItems = [
{ label: "Dashboard", icon: LayoutGrid, href: "/dashboard" },
{ label: "Book Appointment", icon: Calendar, href: "/booking" },
];
export default function SideNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const getActiveIndex = () => {
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
};
// Handle body scroll when mobile menu is open
useEffect(() => {
if (open) {
document.body.classList.add("menu-open");
} else {
document.body.classList.remove("menu-open");
}
return () => {
document.body.classList.remove("menu-open");
};
}, [open]);
return (
<>
{/* Mobile Top Bar */}
<div className="flex md:hidden items-center justify-between px-4 py-3 border-b border-gray-200 bg-white z-30 fixed top-0 left-0 right-0">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-rose-100 to-pink-100">
<Heart className="w-5 h-5 text-rose-600" fill="currentColor" />
</div>
<span className="text-lg font-semibold text-gray-900">Attune Heart Therapy</span>
</div>
<button onClick={() => setOpen((v) => !v)} aria-label="Open menu">
{open ? <X size={28} /> : <Menu size={28} />}
</button>
</div>
{/* Mobile Drawer Overlay */}
<div
className={`fixed inset-0 z-40 bg-black/30 transition-opacity duration-200 md:hidden ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={() => setOpen(false)}
/>
{/* Side Navigation */}
<aside
className={`fixed top-0 left-0 z-50 h-screen bg-white border-r border-gray-200 flex flex-col transition-transform duration-200 w-[85vw] max-w-[250px] min-w-[200px] md:translate-x-0 md:w-[250px] md:min-w-[250px] md:max-w-[250px] ${
open ? "translate-x-0" : "-translate-x-full"
} md:translate-x-0`}
>
{/* Logo Section */}
<div className="flex-shrink-0 px-4 pb-4 flex flex-col gap-1 md:block mb-5 pt-16 md:pt-4">
<div className="flex items-center gap-3 mb-1 ml-4 md:ml-6">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-to-br from-rose-100 to-pink-100">
<Heart className="w-6 h-6 text-rose-600" fill="currentColor" />
</div>
<span className="text-lg font-semibold text-gray-900">Attune Heart</span>
</div>
</div>
<hr className="flex-shrink-0 -mt-10 mb-4 mx-4 border-gray-200 md:block hidden" />
{/* Navigation Items */}
<nav className="flex-1 overflow-y-auto flex flex-col gap-2 px-2 md:px-0">
{navItems.map((item, idx) => {
const Icon = item.icon;
const isActive = idx === getActiveIndex();
return (
<div className="relative flex items-center w-full" key={item.label}>
{isActive && (
<span
className="absolute left-0 top-0 h-[45px] w-[3px] bg-[#4A90A4]"
style={{ left: 0 }}
/>
)}
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`group flex items-center gap-3 py-3 pl-4 md:pl-4 pr-4 md:pr-4 transition-colors duration-200 focus:outline-none w-[90%] md:w-[90%] ml-2 md:ml-4 cursor-pointer justify-start ${
isActive
? "bg-[#4A90A4] text-white border border-[#4A90A4] rounded-[5px] shadow-sm"
: "bg-transparent text-gray-600 hover:bg-[#4A90A4]/10 hover:text-[#4A90A4] rounded-lg"
}`}
style={isActive ? { height: 45 } : {}}
>
<Icon
size={20}
strokeWidth={isActive ? 2.2 : 1.5}
className={
isActive
? "text-white"
: "text-gray-700 group-hover:text-[#4A90A4]"
}
/>
<span
className="font-light leading-none text-[13px] md:text-[13px]"
style={{ fontWeight: 300 }}
>
{item.label}
</span>
</Link>
</div>
);
})}
{/* Bottom Actions */}
<div className="mt-auto pt-4 pb-4 border-t border-gray-200">
<Link
href="/settings"
onClick={() => setOpen(false)}
className="group flex items-center gap-3 py-3 pl-4 md:pl-4 pr-4 md:pr-4 transition-colors duration-200 w-[90%] md:w-[90%] ml-2 md:ml-4 cursor-pointer justify-start text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-lg"
>
<Settings size={20} strokeWidth={1.5} className="text-gray-700 group-hover:text-gray-900" />
<span className="font-light leading-none text-[13px]" style={{ fontWeight: 300 }}>
Settings
</span>
</Link>
<button
onClick={() => {
setOpen(false);
// Handle logout
}}
className="group flex items-center gap-3 py-3 pl-4 md:pl-4 pr-4 md:pr-4 transition-colors duration-200 w-[90%] md:w-[90%] ml-2 md:ml-4 cursor-pointer justify-start text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-lg"
>
<LogOut size={20} strokeWidth={1.5} className="text-gray-700 group-hover:text-gray-900" />
<span className="font-light leading-none text-[13px]" style={{ fontWeight: 300 }}>
Logout
</span>
</button>
</div>
</nav>
</aside>
</>
);
}

60
components/ui/button.tsx Normal file
View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } 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 hover:bg-primary/90",
destructive:
"bg-destructive text-white 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 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",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
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 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 }

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -12,18 +12,25 @@
"node": ">=20.9.0"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.552.0",
"next": "16.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"next": "16.0.1"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.0.1"
}
"eslint-config-next": "16.0.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
},
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417"
}

View File

@ -8,6 +8,18 @@ importers:
.:
dependencies:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.2)(react@19.2.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^0.552.0
version: 0.552.0(react@19.2.0)
next:
specifier: 16.0.1
version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -17,6 +29,9 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@ -39,6 +54,9 @@ importers:
tailwindcss:
specifier: ^4
version: 4.1.16
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5
version: 5.9.3
@ -394,6 +412,24 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -785,9 +821,16 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1446,6 +1489,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.552.0:
resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1781,6 +1829,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss@4.1.16:
resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==}
@ -1808,6 +1859,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -2236,6 +2290,19 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)':
dependencies:
react: 19.2.0
optionalDependencies:
'@types/react': 19.2.2
'@radix-ui/react-slot@1.2.4(@types/react@19.2.2)(react@19.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
react: 19.2.0
optionalDependencies:
'@types/react': 19.2.2
'@rtsao/scc@1.1.0': {}
'@swc/helpers@0.5.15':
@ -2637,8 +2704,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3427,6 +3500,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.552.0(react@19.2.0):
dependencies:
react: 19.2.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -3828,6 +3905,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.3.1: {}
tailwindcss@4.1.16: {}
tapable@2.3.0: {}
@ -3854,6 +3933,8 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.4.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

BIN
public/doctors.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB