Dental Care

This commit is contained in:
Iliyan Angelov
2025-11-16 14:29:51 +02:00
commit 39077550ef
194 changed files with 43197 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
export function ForgotPasswordForm({
className,
...props
}: React.ComponentProps<"div">) {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await authClient.forgetPassword({
email,
redirectTo: `${window.location.origin}/reset-password`,
});
if (error) {
toast.error("Failed to send reset link", {
description: error.message || "Please try again.",
});
} else {
toast.success("Password reset link sent!", {
description: "Please check your email inbox.",
});
setEmail(""); // Clear the form on success
}
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
} finally {
setIsLoading(false);
}
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Forgot Password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a link to reset
your password
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Reset Link"}
</Button>
<FieldDescription className="text-center">
Remember your password?{" "}
<a
href="/sign-in"
className="underline-offset-4 hover:underline"
>
Sign in
</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import Image from "next/image";
import Link from "next/link";
import { ForgotPasswordForm } from "@/app/(auth)/forgot-password/forgot-password-form";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
<Image
width={24}
height={24}
src={"/tooth.svg"}
alt="Dental U Care Logo"
priority
/>
</div>
<span>Dental U Care</span>
</Link>
<ForgotPasswordForm />
</div>
</div>
);
}

13
app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
export default async function AuthLayout({
children,
}: {
children: ReactNode;
}) {
// Removed redundant client-side redirect here.
// The sign-in form performs role-based redirects after login
// and middleware protects authenticated routes. Keeping the
// layout minimal avoids redirect race conditions.
return <>{children}</>;
}

View File

@@ -0,0 +1,32 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { ResetPasswordForm } from "./reset-password-form";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link
href="/"
className="flex items-center gap-2 self-center font-medium"
>
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
<Image
width={24}
height={24}
src={"/tooth.svg"}
alt="Dental U Care Logo"
priority
/>
</div>
<span>Dental U Care</span>
</Link>
<Suspense fallback={<div className="text-center">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { toast } from "sonner";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { authClient } from "@/lib/auth-session/auth-client";
import { useRouter, useSearchParams } from "next/navigation";
export function ResetPasswordForm({
className,
...props
}: React.ComponentProps<"div">) {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowConfirm((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
if (!token) {
toast.error("Invalid or missing reset token");
setIsLoading(false);
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
setIsLoading(false);
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
setIsLoading(false);
return;
}
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message || "Failed to reset password");
} else {
toast.success("Password reset successfully");
router.push("/sign-in");
}
setIsLoading(false);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Reset Password</CardTitle>
<CardDescription>Enter your new password</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your new password"
disabled={isLoading}
minLength={8}
required
/>
<button
aria-label={
showPassword ? "Hide password" : "Show password"
}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1">
Must be at least 8 characters
</p>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword">
Confirm Password
</FieldLabel>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
disabled={isLoading}
minLength={8}
required
/>
<button
aria-label={
showConfirm
? "Hide confirm password"
: "Show confirm password"
}
onClick={toggleConfirm}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showConfirm ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Resetting...
</>
) : (
"Reset Password"
)}
</Button>
</Field>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="underline underline-offset-4">
Sign up
</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our{" "}
<Link href="/docs/terms-of-service">Terms of Service</Link> and{" "}
<Link href="/docs/privacy-policy">Privacy Policy</Link>.
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Image from "next/image"
import { Metadata } from "next"
import { LoginForm } from "@/app/(auth)/sign-in/sign-in-form"
import Link from "next/link";
export const metadata: Metadata = {
title: "Sign in",
};
export default function LoginPage() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col p-6 md:p-10">
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
<Link href="/" className="flex items-center gap-2 font-semibold text-lg">
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
<Image src="/tooth.svg" alt="Dental U Care" width={24} height={24} />
</div>
Dental U Care
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="/dentist.jpg"
alt="Dental clinic interior"
fill
className="object-cover dark:brightness-80"
priority
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,335 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { useState } from "react";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
export function LoginForm({
className,
...props
}: React.ComponentProps<"form">) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
await authClient.signIn.email(
{
email,
password,
},
{
onSuccess: async (ctx) => {
// User data is available in ctx.data
const user = ctx.data?.user;
const role = user?.role;
setShowVerifyNotice(false);
// Determine target based on role
const target =
role === "admin"
? "/admin"
: role === "dentist"
? "/dentist"
: role === "patient"
? "/patient"
: "/";
const description =
role === "admin"
? "Redirecting to admin panel..."
: role === "dentist"
? "Redirecting to dentist portal..."
: role === "patient"
? "Redirecting to patient portal..."
: "Welcome back!";
toast.success("Login successful!", { description });
// Small delay to ensure cookie is set before redirect
// This is critical for production where cookie propagation takes time
await new Promise((resolve) => setTimeout(resolve, 500));
// Use window.location.href for full page reload
window.location.href = target;
},
onError: (ctx) => {
if (ctx.error.status === 403) {
setShowVerifyNotice(true);
toast.error("Please verify your email address", {
description: "Check your inbox for the verification link.",
});
} else {
setShowVerifyNotice(false);
toast.error("Login failed", {
description: ctx.error.message || "Invalid email or password.",
});
}
setIsLoading(false);
},
}
);
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
setIsLoading(false);
}
}
async function handleResendVerification(
e: React.MouseEvent<HTMLButtonElement>
) {
e.preventDefault();
setResendLoading(true);
setResendSuccess(false);
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) {
setResendSuccess(true);
toast.success("Verification email sent!", {
description: "Check your inbox for the verification link.",
});
} else {
toast.error("Failed to resend verification email.");
}
} catch {
toast.error("Failed to resend verification email.");
} finally {
setResendLoading(false);
}
}
async function handleGoogleSignIn() {
try {
setIsGoogleLoading(true);
// Google OAuth redirects to Google, then back to /api/auth/callback/google
// Better Auth handles the callback and creates/updates the session
// The onAfterSignUp hook in auth.ts ensures new users get the "patient" role
// After callback, users are redirected to root "/"
// The auth layout then redirects to role-specific dashboard
await authClient.signIn.social({
provider: "google",
});
// This will cause a redirect, so code after this won't execute
} catch (error) {
console.error("Google sign-in failed:", error);
toast.error("Google sign-in failed", {
description: "Please try again.",
});
setIsGoogleLoading(false);
}
}
return (
<form
className={cn("flex flex-col gap-6", className)}
{...props}
onSubmit={handleSubmit}
>
<FieldGroup>
{showVerifyNotice && (
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
<div className="mb-2">
Your email is not verified. Please check your inbox for the
verification link.
</div>
<button
type="button"
className="underline text-sm text-blue-700 disabled:opacity-60"
onClick={handleResendVerification}
disabled={resendLoading || resendSuccess}
>
{resendLoading
? "Resending..."
: resendSuccess
? "Verification Sent!"
: "Resend Verification Email"}
</button>
</div>
)}
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="e.g m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<Link
href="/forgot-password"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</Link>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
/>
<button
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
</Field>
<Field>
<Button type="submit" disabled={isLoading || isGoogleLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
{"Logging in..."}
</>
) : (
"Login"
)}
</Button>
</Field>
<FieldSeparator>Or continue with</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={handleGoogleSignIn}
disabled={isLoading || isGoogleLoading}
>
{isGoogleLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
{"Signing in..."}
</>
) : (
<>
<svg
width="800px"
height="800px"
viewBox="-3 0 262 262"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
className="size-4 mr-2"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
Login with Google
</>
)}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="underline underline-offset-4">
Sign up
</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}

View File

@@ -0,0 +1,46 @@
import { Metadata } from "next";
import { SignupForm } from "@/app/(auth)/sign-up/sign-up-form";
import Image from "next/image";
import Link from "next/link";
export const metadata: Metadata = {
title: "Sign Up",
};
export default function SignupPage() {
return (
<div className="grid h-screen lg:grid-cols-2">
<div className="flex flex-col p-4 md:p-6 h-screen overflow-hidden">
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
<Link
href="/"
className="flex items-center gap-2 font-semibold text-lg"
>
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
<Image
src="/tooth.svg"
alt="Dental U Care"
width={24}
height={24}
/>
</div>
Dental U Care
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<SignupForm />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block h-screen">
<Image
src="/doctor-image.jpg"
alt="Doctor"
className="absolute inset-0 h-full w-full object-cover dark:brightness-80"
priority
fill
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,414 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { useState } from "react";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
export function SignupForm({
className,
...props
}: React.ComponentProps<"form">) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowConfirm((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
// Validate passwords match
if (password !== confirmPassword) {
toast.error("Passwords don't match", {
description: "Please make sure both passwords are the same.",
});
setIsLoading(false);
return;
}
if (!/[^A-Za-z0-9]/.test(password)) {
toast.error("Password must contain at least one special character", {
description:
"Please include at least one special character in your password.",
});
setIsLoading(false);
return;
}
// Validate password length
if (password.length < 8) {
toast.error("Password too short", {
description: "Password must be at least 8 characters long.",
});
setIsLoading(false);
return;
}
try {
const { error } = await authClient.signUp.email({
email,
password,
name,
});
if (error) {
if (error.status === 403) {
setShowVerifyNotice(true);
toast.error("Please verify your email address", {
description: "Check your inbox for the verification link.",
});
} else {
setShowVerifyNotice(false);
toast.error("Sign up failed", {
description:
error.message || "Unable to create account. Please try again.",
});
}
} else {
setShowVerifyNotice(false);
toast.success("Account created successfully!", {
description: "Please check your email to verify your account.",
});
// Redirect to login page after successful signup using full page reload
setTimeout(() => {
window.location.href = "/sign-in";
}, 2000);
}
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
} finally {
setIsLoading(false);
}
}
async function handleResendVerification(
e: React.MouseEvent<HTMLButtonElement>
) {
e.preventDefault();
setResendLoading(true);
setResendSuccess(false);
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) {
setResendSuccess(true);
toast.success("Verification email sent!", {
description: "Check your inbox for the verification link.",
});
} else {
toast.error("Failed to resend verification email.");
}
} catch {
toast.error("Failed to resend verification email.");
} finally {
setResendLoading(false);
}
}
async function handleGoogleSignUp() {
try {
setIsGoogleLoading(true);
// Google OAuth will redirect to Google, then back to callback
// The onAfterSignUp hook in auth.ts assigns "patient" role to new users
// After callback, the auth layout redirects to the role-specific dashboard
await authClient.signIn.social({
provider: "google",
});
} catch (error) {
console.error("Google sign-up failed:", error);
toast.error("Google sign-up failed", {
description: "Please try again.",
});
setIsGoogleLoading(false);
}
}
return (
<form
className={cn("flex flex-col gap-3", className)}
{...props}
onSubmit={handleSubmit}
>
{showVerifyNotice && (
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
<div className="mb-2">
Your email is not verified. Please check your inbox for the
verification link.
</div>
<button
type="button"
className="underline text-sm text-blue-700 disabled:opacity-60"
onClick={handleResendVerification}
disabled={resendLoading || resendSuccess}
>
{resendLoading
? "Resending..."
: resendSuccess
? "Verification Sent!"
: "Resend Verification Email"}
</button>
</div>
)}
<FieldGroup className="gap-3">
<div className="flex flex-col items-center text-center mb-2">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm">
Fill in the form below to create your account
</p>
</div>
<Field className="gap-1">
<FieldLabel htmlFor="name" className="text-xs">
Full Name
</FieldLabel>
<Input
id="name"
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="email" className="text-xs">
Email
</FieldLabel>
<Input
id="email"
type="email"
placeholder="e.g m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
className="h-9"
/>
<FieldDescription className="text-xs leading-tight">
We&apos;ll use this to contact you.
</FieldDescription>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="password" className="text-xs">
Password
</FieldLabel>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
minLength={8}
required
className="h-9"
/>
<button
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<FieldDescription className="text-xs leading-tight">
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="confirm-password" className="text-xs">
Confirm Password
</FieldLabel>
<div className="relative">
<Input
id="confirm-password"
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
minLength={8}
required
className="h-9"
/>
<button
aria-label={
showConfirm ? "Hide confirm password" : "Show confirm password"
}
onClick={toggleConfirm}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showConfirm ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<FieldDescription className="text-xs leading-tight">
Please confirm your password.
</FieldDescription>
</Field>
<Field className="gap-1">
<Button
type="submit"
disabled={isLoading || isGoogleLoading}
className="h-9"
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Creating account...
</>
) : (
"Create Account"
)}
</Button>
</Field>
<FieldSeparator>Or continue with</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={handleGoogleSignUp}
disabled={isLoading || isGoogleLoading}
>
<svg
width="800px"
height="800px"
viewBox="-3 0 262 262"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
{isGoogleLoading ? "Signing up..." : "Sign up with Google"}
</Button>
<FieldDescription className="px-6 text-center">
Already have an account? <Link href="/sign-in">Sign in</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}

View File

@@ -0,0 +1,18 @@
"use server";
import { getServerSession } from "@/lib/auth-session/get-session";
import { forbidden, unauthorized } from "next/navigation";
import { setTimeout } from "node:timers/promises";
export async function deleteApplication() {
const session = await getServerSession();
const user = session?.user;
if (!user) unauthorized();
if (user.role !== "admin") forbidden();
// Delete app...
await setTimeout(800);
}

View File

@@ -0,0 +1,49 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Appointment Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function AppointmentManagementPage() {
const { user } = await requireAdmin();
// Add pagination limit to prevent loading too much data at once
// Use safe find to filter out orphaned appointments
const appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appointment Management</h1>
<p className="text-muted-foreground">
Manage all appointments in the system
</p>
</div>
<AdminAppointmentsTable appointments={appointments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,62 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminDentistsTable } from "@/components/admin/dentists-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dentist Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistManagementPage() {
const { user } = await requireAdmin();
const dentistsData = await prisma.user.findMany({
take: 50, // Limit to 50 dentists to prevent excessive data loading
where: {
role: "dentist",
},
include: {
appointmentsAsDentist: {
take: 10, // Limit appointments per dentist to avoid N+1 issue
include: {
service: true,
patient: true,
},
orderBy: {
date: "desc",
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Transform the data to match the expected Dentist type
const dentists = dentistsData.map((dentist) => ({
...dentist,
experience: dentist.experience !== null ? String(dentist.experience) : null,
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Dentist Management</h1>
<p className="text-muted-foreground">
Manage all dentists in the system
</p>
</div>
<AdminDentistsTable dentists={dentists} />
</div>
</DashboardLayout>
);
}

197
app/(main)/admin/page.tsx Normal file
View File

@@ -0,0 +1,197 @@
import { ChartAreaInteractive } from "@/components/chart/chart-area-interactive";
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
import { SectionCards } from "@/components/layout/section-cards";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
export const metadata: Metadata = {
title: "Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function Page() {
// Require admin role - will redirect to home page (/) if not admin
const { user } = await requireAdmin();
// Calculate date ranges once for reuse
const now = new Date();
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_IN_MS);
const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_IN_MS);
const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_IN_MS);
// Run all count queries in parallel for better performance
const [
totalAppointments,
previousAppointments,
newPatients,
previousPatients,
payments,
previousPayments,
completedAppointments,
previousCompleted,
appointmentsForChart,
appointments,
] = await Promise.all([
// Total appointments in last 30 days
prisma.appointment.count({
where: {
createdAt: { gte: thirtyDaysAgo },
},
}),
// Previous period appointments for comparison
prisma.appointment.count({
where: {
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// New patients in last 30 days
prisma.user.count({
where: {
role: "patient",
createdAt: { gte: thirtyDaysAgo },
},
}),
// Previous period patients
prisma.user.count({
where: {
role: "patient",
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// Revenue this month
prisma.payment.aggregate({
where: {
status: "paid",
paidAt: { gte: thirtyDaysAgo },
},
_sum: {
amount: true,
},
}),
// Previous period revenue
prisma.payment.aggregate({
where: {
status: "paid",
paidAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
_sum: {
amount: true,
},
}),
// Calculate completed appointments for satisfaction (mock calculation)
prisma.appointment.count({
where: {
status: "completed",
updatedAt: { gte: thirtyDaysAgo },
},
}),
// Previous completed
prisma.appointment.count({
where: {
status: "completed",
updatedAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// Fetch appointments for chart (last 90 days)
prisma.appointment.findMany({
where: {
createdAt: { gte: ninetyDaysAgo },
},
select: {
createdAt: true,
},
orderBy: {
createdAt: "asc",
},
}),
// Fetch recent appointments for table
// Use safe find to filter out orphaned appointments
safeFindManyAppointments({
take: 20,
orderBy: {
createdAt: "desc",
},
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
}),
]);
const revenue = payments._sum.amount || 0;
const previousRevenue = previousPayments._sum.amount || 0;
// Calculate percentage changes
const appointmentChange =
previousAppointments > 0
? ((totalAppointments - previousAppointments) / previousAppointments) *
100
: 0;
const patientChange =
previousPatients > 0
? ((newPatients - previousPatients) / previousPatients) * 100
: 0;
const revenueChange =
previousRevenue > 0
? ((revenue - previousRevenue) / previousRevenue) * 100
: 0;
const satisfactionChange =
previousCompleted > 0
? ((completedAppointments - previousCompleted) / previousCompleted) * 100
: 0;
// Mock satisfaction rate (in a real app, this would come from reviews/ratings)
const satisfactionRate = 98.5;
// Group appointments by date for chart
const chartData = appointmentsForChart.reduce(
(acc: Record<string, number>, appointment) => {
const date = appointment.createdAt.toISOString().split("T")[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
},
{}
);
// Convert to array format for chart
const chartDataArray = Object.entries(chartData).map(([date, count]) => ({
date,
appointments: count,
}));
const dashboardStats = {
totalAppointments,
appointmentChange,
newPatients,
patientChange,
revenue,
revenueChange,
satisfactionRate,
satisfactionChange,
};
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards stats={dashboardStats} />
<div className="px-4 lg:px-6">
<ChartAreaInteractive data={chartDataArray} />
</div>
<div className="px-4 lg:px-6">
<AdminAppointmentsTable appointments={appointments} />
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,62 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminPatientsTable } from "@/components/admin/patients-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PatientManagementPage() {
const { user } = await requireAdmin();
const patients = await prisma.user.findMany({
take: 50, // Limit to 50 patients to prevent excessive data loading
where: {
role: "patient",
},
include: {
appointmentsAsPatient: {
take: 10, // Limit appointments per patient to avoid N+1 issue
include: {
service: true,
dentist: true,
},
orderBy: {
date: "desc",
},
},
payments: {
take: 10, // Limit payments per patient
orderBy: {
createdAt: "desc",
},
},
},
orderBy: {
createdAt: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Patient Management</h1>
<p className="text-muted-foreground">
Manage all patients in the system
</p>
</div>
<AdminPatientsTable patients={patients} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,53 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminServicesTable } from "@/components/admin/services-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Service Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function ServiceManagementPage() {
const { user } = await requireAdmin();
const servicesData = await prisma.service.findMany({
take: 100, // Limit to 100 services
include: {
appointments: {
take: 5, // Limit appointments per service to avoid N+1 issue
orderBy: {
date: "desc",
},
},
},
orderBy: {
name: "asc",
},
});
// Transform the data to match the expected Service type
const services = servicesData.map((service) => ({
...service,
description: service.description ?? "",
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Service Management</h1>
<p className="text-muted-foreground">Manage all dental services</p>
</div>
<AdminServicesTable services={services} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,19 @@
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { AdminSettingsContent } from "@/components/admin/settings-content";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function AdminSettingsPage() {
const { user } = await requireAdmin();
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<AdminSettingsContent user={{ ...user, role: user.role || "admin" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminUsersTable } from "@/components/admin/users-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function UserManagementPage() {
const { user } = await requireAdmin();
const usersRaw = await prisma.user.findMany({
take: 100, // Limit to 100 most recent users
orderBy: {
createdAt: "desc",
},
});
const users = usersRaw.map((u) => ({ ...u, role: u.role ?? undefined }));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage all users in the system
</p>
</div>
<AdminUsersTable users={users} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,57 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { DentistAppointmentsList } from "@/components/dentist/appointments-list";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Appointments - Dentist",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistAppointmentsPage() {
const { user } = await requireDentist();
const appointmentsData = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
dentistId: user.id,
},
include: {
patient: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
const appointments = appointmentsData.map((appointment) => ({
...appointment,
service: {
...appointment.service,
price: parseFloat(appointment.service.price),
},
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appointments</h1>
<p className="text-muted-foreground">
Manage your patient appointments
</p>
</div>
<DentistAppointmentsList appointments={appointments} />
</div>
</DashboardLayout>
);
}

294
app/(main)/dentist/page.tsx Normal file
View File

@@ -0,0 +1,294 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, Users, CheckCircle } from "lucide-react";
import Link from "next/link";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dentist Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistDashboard() {
// Require dentist role - will redirect to appropriate page if not dentist
const { user } = await requireDentist();
// Calculate date ranges
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const now = new Date();
// Run all queries in parallel for better performance
const [
todayAppointments,
pendingAppointments,
totalPatients,
completedAppointments,
upcomingAppointments,
] = await Promise.all([
// Fetch today's appointments
safeFindManyAppointments({
where: {
dentistId: user.id,
date: {
gte: today,
lt: tomorrow,
},
status: {
in: ["pending", "confirmed"],
},
},
include: {
patient: true,
service: true,
},
orderBy: {
timeSlot: "asc",
},
}),
// Fetch pending appointments
safeFindManyAppointments({
where: {
dentistId: user.id,
status: "pending",
date: {
gte: now,
},
},
include: {
patient: true,
service: true,
},
orderBy: {
date: "asc",
},
take: 5,
}),
// Get total unique patients
prisma.appointment.groupBy({
by: ["patientId"],
where: {
dentistId: user.id,
},
}),
// Get completed appointments count
prisma.appointment.count({
where: {
dentistId: user.id,
status: "completed",
},
}),
// Get upcoming appointments count
prisma.appointment.count({
where: {
dentistId: user.id,
status: {
in: ["pending", "confirmed"],
},
date: {
gte: now,
},
},
}),
]);
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Welcome, Dr. {user.name}!</h1>
<p className="text-muted-foreground">
Manage your schedule and patients
</p>
</div>
{/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Today&apos;s Appointments
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{todayAppointments.length}
</div>
<p className="text-xs text-muted-foreground">
Scheduled for today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{upcomingAppointments}</div>
<p className="text-xs text-muted-foreground">
Future appointments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Patients
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPatients.length}</div>
<p className="text-xs text-muted-foreground">Unique patients</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedAppointments}</div>
<p className="text-xs text-muted-foreground">All time</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-4 md:grid-cols-3">
<Link href="/dentist/appointments">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Calendar className="h-6 w-6" />
<span>View All Appointments</span>
</div>
</Button>
</Link>
<Link href="/dentist/schedule">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Clock className="h-6 w-6" />
<span>Manage Schedule</span>
</div>
</Button>
</Link>
<Link href="/dentist/patients">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Users className="h-6 w-6" />
<span>Patient Records</span>
</div>
</Button>
</Link>
</div>
{/* Today's Schedule */}
<Card>
<CardHeader>
<CardTitle>Today&apos;s Schedule</CardTitle>
<CardDescription>{today.toLocaleDateString()}</CardDescription>
</CardHeader>
<CardContent>
{todayAppointments.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No appointments scheduled for today
</p>
) : (
<div className="space-y-4">
{todayAppointments.map((appointment) => (
<div
key={appointment.id}
className="flex items-center justify-between border-b pb-4 last:border-0"
>
<div>
<p className="font-medium">{appointment.patient.name}</p>
<p className="text-sm text-muted-foreground">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
{appointment.timeSlot}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline">
View Details
</Button>
{appointment.status === "pending" && (
<Button size="sm">Confirm</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pending Appointments */}
<Card>
<CardHeader>
<CardTitle>Pending Appointments</CardTitle>
<CardDescription>
Appointments awaiting confirmation
</CardDescription>
</CardHeader>
<CardContent>
{pendingAppointments.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No pending appointments
</p>
) : (
<div className="space-y-4">
{pendingAppointments.map((appointment) => (
<div
key={appointment.id}
className="flex items-center justify-between border-b pb-4 last:border-0"
>
<div>
<p className="font-medium">{appointment.patient.name}</p>
<p className="text-sm text-muted-foreground">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
{new Date(appointment.date).toLocaleDateString()} at{" "}
{appointment.timeSlot}
</p>
</div>
<div className="flex gap-2">
<Button size="sm">Accept</Button>
<Button size="sm" variant="destructive">
Decline
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,64 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { DentistPatientsTable } from "@/components/dentist/patients-table";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Records",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistPatientsPage() {
const { user } = await requireDentist();
// Get all unique patients who have appointments with this dentist
// Use safe find to filter out orphaned appointments
const appointments = await safeFindManyAppointments({
take: 200, // Limit to prevent excessive data loading
where: {
dentistId: user.id,
},
include: {
patient: true,
service: true,
},
orderBy: {
date: "desc",
},
});
// Group by patient
const patientsMap = new Map();
appointments.forEach((apt) => {
if (!patientsMap.has(apt.patient.id)) {
patientsMap.set(apt.patient.id, {
...apt.patient,
appointments: [],
});
}
patientsMap.get(apt.patient.id).appointments.push(apt);
});
const patients = Array.from(patientsMap.values());
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Patient Records</h1>
<p className="text-muted-foreground">
View your patients&apos; information and history
</p>
</div>
<DentistPatientsTable patients={patients} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,49 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { requireDentist } from "@/lib/auth-session/auth-server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Manage Schedule",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistSchedulePage() {
const { user } = await requireDentist();
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Manage Schedule</h1>
<p className="text-muted-foreground">
Set your working hours and availability
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Working Hours</CardTitle>
<CardDescription>Configure your weekly schedule</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8">
Schedule management feature coming soon
</p>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { requireAuth } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
import { UserSettingsContent } from "@/components/user/settings-content";
import { prisma } from "@/lib/types/prisma";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistSettingsPage() {
const session = await requireAuth();
if (session.user.role !== "dentist") {
redirect("/forbidden");
}
// Fetch full user data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
phone: true,
image: true,
dateOfBirth: true,
address: true,
role: true,
},
});
if (!user) {
redirect("/sign-in");
}
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<UserSettingsContent user={{ ...user, role: user.role || "dentist" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,78 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AppointmentsList } from "@/components/patient/appointments-list";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CheckCircle } from "lucide-react";
export const metadata: Metadata = {
title: "My Appointments",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
interface AppointmentsPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function AppointmentsPage({
searchParams,
}: AppointmentsPageProps) {
const { user } = await requirePatient();
const appointmentsData = await safeFindManyAppointments({
take: 50, // Limit to 50 most recent appointments
where: {
patientId: user.id,
},
include: {
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
const appointments = appointmentsData.map((appointment) => ({
...appointment,
service: {
...appointment.service,
price: appointment.service.price, // Keep price as is (can be string or number)
},
}));
const params = await searchParams;
const showSuccess = params.success === "true";
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">My Appointments</h1>
<p className="text-muted-foreground">
View and manage your dental appointments
</p>
</div>
{showSuccess && (
<Alert className="border-green-200 bg-green-50 dark:bg-green-950/30">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800 dark:text-green-200">
Your appointment has been successfully booked! Check your email
for confirmation.
</AlertDescription>
</Alert>
)}
<AppointmentsList appointments={appointments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,93 @@
import type React from "react";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import BookingForm from "@/components/patient/booking-form";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { XCircle } from "lucide-react";
export const metadata: Metadata = {
title: "Book Appointment",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
interface BookAppointmentPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BookAppointmentPage({
searchParams,
}: BookAppointmentPageProps) {
const { user } = await requirePatient();
// Fetch available services from database
const servicesFromDb = await prisma.service.findMany({
where: {
isActive: true,
},
orderBy: {
name: "asc",
},
});
// Transform services to match component expectations
const services = servicesFromDb.map((service) => ({
id: service.id,
name: service.name,
price: service.price,
duration: service.duration,
category: service.category,
description: service.description || undefined,
}));
// Fetch available dentists
const dentists = await prisma.user.findMany({
where: {
role: "dentist",
isAvailable: true,
},
select: {
id: true,
name: true,
specialization: true,
image: true,
},
orderBy: {
name: "asc",
},
});
// Transform dentists data to match component expectations
const transformedDentists = dentists.map((dentist) => ({
...dentist,
specialization: dentist.specialization || undefined,
image: dentist.image || undefined,
}));
const params = await searchParams;
const showCanceled = params.canceled === "true";
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
{showCanceled && (
<Alert className="m-4 md:m-8 border-red-200 bg-red-50 dark:bg-red-950/30">
<XCircle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-800 dark:text-red-200">
Payment was canceled. You can try booking again.
</AlertDescription>
</Alert>
)}
<BookingForm
services={services}
dentists={transformedDentists}
patientId={user.id}
/>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,176 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
import { FileText, Calendar, User } from "lucide-react";
export const metadata: Metadata = {
title: "Health Records",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function HealthRecordsPage() {
const { user } = await requirePatient();
const userDetails = await prisma.user.findUnique({
where: { id: user.id },
include: {
appointmentsAsPatient: {
where: {
status: "completed",
},
include: {
service: true,
dentist: true,
},
orderBy: {
date: "desc",
},
},
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Health Records</h1>
<p className="text-muted-foreground">
Your medical history and treatment records
</p>
</div>
{/* Personal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">
Full Name
</p>
<p className="text-base">{userDetails?.name}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Email</p>
<p className="text-base">{userDetails?.email}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Phone</p>
<p className="text-base">
{userDetails?.phone || "Not provided"}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Date of Birth
</p>
<p className="text-base">
{userDetails?.dateOfBirth
? new Date(userDetails.dateOfBirth).toLocaleDateString()
: "Not provided"}
</p>
</div>
<div className="md:col-span-2">
<p className="text-sm font-medium text-muted-foreground">
Address
</p>
<p className="text-base">
{userDetails?.address || "Not provided"}
</p>
</div>
</CardContent>
</Card>
{/* Medical History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Medical History
</CardTitle>
<CardDescription>
Important medical information for your dentist
</CardDescription>
</CardHeader>
<CardContent>
{userDetails?.medicalHistory ? (
<p className="text-base whitespace-pre-wrap">
{userDetails.medicalHistory}
</p>
) : (
<p className="text-muted-foreground">
No medical history recorded
</p>
)}
</CardContent>
</Card>
{/* Treatment History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Treatment History
</CardTitle>
<CardDescription>Completed dental procedures</CardDescription>
</CardHeader>
<CardContent>
{!userDetails?.appointmentsAsPatient ||
userDetails.appointmentsAsPatient.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No treatment history
</p>
) : (
<div className="space-y-4">
{userDetails.appointmentsAsPatient.map((appointment) => (
<div
key={appointment.id}
className="border-b pb-4 last:border-0"
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
Dr. {appointment.dentist.name}
</p>
<p className="text-sm text-muted-foreground">
{new Date(appointment.date).toLocaleDateString()} at{" "}
{appointment.timeSlot}
</p>
{appointment.notes && (
<p className="text-sm mt-2">
<span className="font-medium">Notes:</span>{" "}
{appointment.notes}
</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,92 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { PatientSectionCards } from "@/components/patient/section-cards";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PatientDashboard() {
// Require patient role - will redirect to appropriate page if not patient
const { user } = await requirePatient();
const now = new Date();
// Run all queries in parallel for better performance
const [
upcomingAppointmentsCount,
completedAppointmentsCount,
totalSpentResult,
pendingPaymentsResult,
] = await Promise.all([
// Fetch upcoming appointments count
prisma.appointment.count({
where: {
patientId: user.id,
date: {
gte: now,
},
status: {
in: ["pending", "confirmed"],
},
},
}),
// Fetch completed appointments count
prisma.appointment.count({
where: {
patientId: user.id,
status: "completed",
},
}),
// Calculate total spent (paid payments)
prisma.payment.aggregate({
where: {
userId: user.id,
status: "paid",
},
_sum: {
amount: true,
},
}),
// Calculate pending payments
prisma.payment.aggregate({
where: {
userId: user.id,
status: "pending",
},
_sum: {
amount: true,
},
}),
]);
const patientStats = {
upcomingAppointments: upcomingAppointmentsCount,
completedAppointments: completedAppointmentsCount,
totalSpent: totalSpentResult._sum.amount || 0,
pendingPayments: pendingPaymentsResult._sum.amount || 0,
};
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6">
<h1 className="text-3xl font-bold">Welcome back, {user.name}!</h1>
<p className="text-muted-foreground">
Manage your appointments and health records
</p>
</div>
<PatientSectionCards stats={patientStats} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,52 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { PaymentHistory } from "@/components/patient/payment-history";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Payment History",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PaymentsPage() {
const { user } = await requirePatient();
const payments = await prisma.payment.findMany({
take: 50, // Limit to 50 most recent payments
where: {
userId: user.id,
},
include: {
appointment: {
include: {
service: true,
dentist: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Payment History</h1>
<p className="text-muted-foreground">
View your payment transactions
</p>
</div>
<PaymentHistory payments={payments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { requireAuth } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
import { UserSettingsContent } from "@/components/user/settings-content";
import { prisma } from "@/lib/types/prisma";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function UserSettingsPage() {
const session = await requireAuth();
if (session.user.role !== "patient") {
redirect("/");
}
// Fetch full user data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
phone: true,
image: true,
dateOfBirth: true,
address: true,
role: true,
},
});
if (!user) {
redirect("/sign-in");
}
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<UserSettingsContent user={{ ...user, role: user.role || "patient" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,9 @@
import {Metadata} from "next"
export const metadata: Metadata = {
title: "Profile",
};
export default function Profile (){
return
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { Prisma } from "@prisma/client";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await import("next/headers").then((mod) => mod.headers()),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admin can edit appointments
if (session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden: Admin access required" },
{ status: 403 }
);
}
const { id } = await params;
const body = await request.json();
const { date, timeSlot, status, notes } = body;
// Validate that the appointment exists
const existingAppointment = await prisma.appointment.findUnique({
where: { id },
});
if (!existingAppointment) {
return NextResponse.json(
{ error: "Appointment not found" },
{ status: 404 }
);
}
// Build update data object
const updateData: Prisma.AppointmentUpdateInput = {};
if (date !== undefined) updateData.date = new Date(date);
if (timeSlot !== undefined) updateData.timeSlot = timeSlot;
if (status !== undefined) updateData.status = status;
if (notes !== undefined) updateData.notes = notes;
// Update the appointment
const updatedAppointment = await prisma.appointment.update({
where: { id },
data: updateData,
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
});
return NextResponse.json({
success: true,
appointment: updatedAppointment,
message: "Appointment updated successfully",
});
} catch (error) {
console.error("Error updating appointment:", error);
return NextResponse.json(
{ error: "Failed to update appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { status, cancelReason, date, timeSlot } = body;
const { id } = await params;
const appointment = await prisma.appointment.findUnique({
where: { id },
include: {
patient: true,
dentist: true,
service: true,
},
});
if (!appointment) {
return NextResponse.json(
{ error: "Appointment not found" },
{ status: 404 }
);
}
// Update appointment
const updatedAppointment = await prisma.appointment.update({
where: { id },
data: {
...(status && { status }),
...(cancelReason && { cancelReason }),
...(date && { date: new Date(date) }),
...(timeSlot && { timeSlot }),
},
include: {
patient: true,
dentist: true,
service: true,
},
});
// Create notifications based on action
if (status === "cancelled") {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Cancelled",
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
type: "email",
},
});
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "Appointment Cancelled",
message: `Appointment with ${appointment.patient.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
type: "email",
},
});
} else if (status === "confirmed") {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Confirmed",
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been confirmed.`,
type: "email",
},
});
} else if (date || timeSlot) {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Rescheduled",
message: `Your appointment has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
type: "email",
},
});
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "Appointment Rescheduled",
message: `Appointment with ${appointment.patient.name} has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
type: "email",
},
});
}
return NextResponse.json(updatedAppointment);
} catch (error) {
console.error("Error updating appointment:", error);
return NextResponse.json(
{ error: "Failed to update appointment" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.appointment.delete({
where: { id },
});
return NextResponse.json({ message: "Appointment deleted successfully" });
} catch (error) {
console.error("Error deleting appointment:", error);
return NextResponse.json(
{ error: "Failed to delete appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { Resend } from "resend";
import { createElement } from "react";
import DentalInvoice from "@/components/emails/email-bookings";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { appointmentData } = body;
if (!appointmentData) {
return NextResponse.json(
{ error: "Appointment data is required" },
{ status: 400 }
);
}
interface ServiceItem {
qty: number;
description: string;
unitPrice: number;
total: number;
}
const { patientId, personalInfo, appointment, services, specialRequests } =
appointmentData as {
patientId: string;
personalInfo: {
firstName: string;
lastName: string;
email: string;
address?: string;
city?: string;
contactNumber?: string;
};
appointment: {
dentistId: string;
dentistName: string;
date: string;
time: string;
};
services: ServiceItem[];
specialRequests: string;
};
// Validate required fields
if (
!patientId ||
!appointment.dentistId ||
!appointment.date ||
!appointment.time
) {
return NextResponse.json(
{ error: "Missing required appointment fields" },
{ status: 400 }
);
}
// Check if time slot is already booked
const existingAppointment = await prisma.appointment.findFirst({
where: {
dentistId: appointment.dentistId,
date: new Date(appointment.date),
timeSlot: appointment.time,
status: {
in: ["pending", "confirmed"],
},
},
});
if (existingAppointment) {
return NextResponse.json(
{ error: "This time slot is already booked" },
{ status: 409 }
);
}
// Create appointments for each service
const createdAppointments = [];
for (const service of services) {
if (service.qty > 0 && service.description) {
// Find the service in database
const dbService = await prisma.service.findFirst({
where: { name: service.description },
});
if (dbService) {
const newAppointment = await prisma.appointment.create({
data: {
patientId,
dentistId: appointment.dentistId,
serviceId: dbService.id,
date: new Date(appointment.date),
timeSlot: appointment.time,
notes: specialRequests || null,
status: "pending",
},
include: {
patient: true,
dentist: true,
service: true,
},
});
createdAppointments.push(newAppointment);
}
}
}
if (createdAppointments.length === 0) {
return NextResponse.json(
{ error: "No valid services selected" },
{ status: 400 }
);
}
// Send confirmation email to patient with professional invoice template
// Generate invoice number
const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).substring(7).toUpperCase()}`;
const invoiceDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const dueDate = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
// Format appointment date and time
const formattedAppointmentDate = new Date(
appointment.date
).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
// Calculate next appointment (6 months from now for regular checkup)
const nextApptDate = new Date(appointment.date);
nextApptDate.setMonth(nextApptDate.getMonth() + 6);
const nextAppointmentDate = nextApptDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
// Calculate total duration (assuming each service takes 60 minutes)
const totalDuration = services
.filter((s) => s.qty > 0)
.reduce((sum, s) => sum + s.qty * 60, 0);
// Calculate financial totals
const subtotal = services
.filter((s) => s.qty > 0)
.reduce((sum, s) => sum + s.total, 0);
const tax = subtotal * 0.12; // 12% tax
const totalDue = subtotal + tax;
// Filter services with qty > 0 for email
const activeServices = services.filter((s) => s.qty > 0);
try {
console.log("Attempting to send email to:", personalInfo.email);
console.log(
"From address:",
`${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`
);
const emailResult = await resend.emails.send({
from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
to: personalInfo.email,
subject: `Appointment Confirmation - Invoice #${invoiceNumber}`,
react: createElement(DentalInvoice, {
invoiceNumber,
invoiceDate,
dueDate,
patientName: `${personalInfo.firstName} ${personalInfo.lastName}`,
patientAddress: personalInfo.address || "N/A",
patientCity: personalInfo.city || "N/A",
patientPhone: personalInfo.contactNumber || "N/A",
patientEmail: personalInfo.email,
bookingId: createdAppointments[0]?.id || "PENDING",
appointmentDate: formattedAppointmentDate,
appointmentTime: appointment.time,
doctorName: appointment.dentistName,
treatmentRoom: "Room 1",
appointmentDuration: `${totalDuration} minutes`,
reasonForVisit:
specialRequests ||
services
.filter((s) => s.qty > 0)
.map((s) => s.description)
.join(", "),
pdfDownloadUrl: `${process.env.NEXT_PUBLIC_APP_URL}/patient/appointments`,
paymentStatus: "Pending Payment",
nextAppointmentDate,
nextAppointmentTime: appointment.time,
nextAppointmentPurpose: "Regular Dental Checkup & Cleaning",
services: activeServices,
subtotal,
tax,
totalDue,
}),
});
console.log("Email sent successfully:", emailResult);
} catch (emailError) {
console.error("Error sending email:", emailError);
console.error(
"Email error details:",
JSON.stringify(emailError, null, 2)
);
// Don't fail the appointment creation if email fails
}
// Create notification for patient
await prisma.notification.create({
data: {
userId: patientId,
title: "Appointment Booked",
message: `Your appointment with Dr. ${appointment.dentistName} has been booked for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
type: "email",
},
});
// Create notification for dentist
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "New Appointment",
message: `New appointment request from ${personalInfo.firstName} ${personalInfo.lastName} for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
type: "email",
},
});
return NextResponse.json(
{
success: true,
appointments: createdAppointments,
message:
"Appointment booked successfully! Check your email for confirmation.",
},
{ status: 201 }
);
} catch (error) {
console.error("Error booking appointment:", error);
return NextResponse.json(
{ error: "Failed to book appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { patientId, dentistId, serviceId, date, timeSlot, notes } = body;
// Validate required fields
if (!patientId || !dentistId || !serviceId || !date || !timeSlot) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Check if time slot is already booked
const existingAppointment = await prisma.appointment.findFirst({
where: {
dentistId,
date: new Date(date),
timeSlot,
status: {
in: ["pending", "confirmed"],
},
},
});
if (existingAppointment) {
return NextResponse.json(
{ error: "This time slot is already booked" },
{ status: 409 }
);
}
// Create appointment
const appointment = await prisma.appointment.create({
data: {
patientId,
dentistId,
serviceId,
date: new Date(date),
timeSlot,
notes: notes || null,
status: "pending",
},
include: {
patient: true,
dentist: true,
service: true,
},
});
// Create notification for patient
await prisma.notification.create({
data: {
userId: patientId,
title: "Appointment Booked",
message: `Your appointment for ${appointment.service.name} has been booked for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
type: "email",
},
});
// Create notification for dentist
await prisma.notification.create({
data: {
userId: dentistId,
title: "New Appointment",
message: `New appointment request from ${appointment.patient.name} for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
type: "email",
},
});
return NextResponse.json(appointment, { status: 201 });
} catch (error) {
console.error("Error creating appointment:", error);
return NextResponse.json(
{ error: "Failed to create appointment" },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const role = searchParams.get("role");
let appointments;
if (role === "patient") {
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
patientId: userId || session.user.id,
},
include: {
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
} else if (role === "dentist") {
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
dentistId: userId || session.user.id,
},
include: {
patient: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
} else {
// Admin - get all appointments with pagination limit
// Use safe find to filter out orphaned appointments
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
}
return NextResponse.json(appointments);
} catch (error) {
console.error("Error fetching appointments:", error);
return NextResponse.json(
{ error: "Failed to fetch appointments" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth-session/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth-session/auth";
/**
* POST /api/auth/resend-verification
* Resends the email verification link to the user
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email } = body;
if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Use Better Auth's sendVerificationEmail method
await auth.api.sendVerificationEmail({
body: { email },
});
return NextResponse.json(
{ message: "Verification email sent successfully" },
{ status: 200 }
);
} catch (error) {
console.error("Failed to resend verification email:", error);
return NextResponse.json(
{ error: "Failed to send verification email" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { auth } from "@/lib/auth-session/auth";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
export async function GET() {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "No session found" }, { status: 401 });
}
// Fetch the full user object from database to get the role
// This is necessary because session cache doesn't include additional fields
if (session.user) {
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
});
if (dbUser) {
// Merge the role from database into the session user object
session.user = {
...session.user,
role: dbUser.role || "patient", // Default to patient if no role set
};
} else {
// Fallback if user not found in database
session.user.role = "patient";
}
}
return NextResponse.json(session);
} catch (error) {
console.error("Session fetch error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth-session/auth";
/**
* Debug endpoint to check session and cookie status
* Access this at: /api/debug-session
*
* REMOVE THIS FILE AFTER DEBUGGING
*/
export async function GET(request: NextRequest) {
try {
// Get all cookies
const allCookies = request.cookies.getAll();
const sessionToken = request.cookies.get("better-auth.session_token");
// Try to get session from Better Auth
let session = null;
let sessionError = null;
try {
session = await auth.api.getSession({
headers: request.headers,
});
} catch (error) {
sessionError = error instanceof Error ? error.message : String(error);
}
// Get request details
const info = {
timestamp: new Date().toISOString(),
environment: {
nodeEnv: process.env.NODE_ENV,
hasAuthUrl: !!process.env.BETTER_AUTH_URL,
authUrl: process.env.BETTER_AUTH_URL,
hasAppUrl: !!process.env.NEXT_PUBLIC_APP_URL,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
},
request: {
url: request.url,
origin: request.headers.get("origin"),
referer: request.headers.get("referer"),
host: request.headers.get("host"),
protocol: request.headers.get("x-forwarded-proto") || "unknown",
},
cookies: {
total: allCookies.length,
names: allCookies.map((c) => c.name),
hasSessionToken: !!sessionToken,
sessionTokenValue: sessionToken?.value
? `${sessionToken.value.substring(0, 20)}...`
: null,
},
session: session
? {
userId: session.user?.id,
userEmail: session.user?.email,
sessionId: session.session?.id,
expiresAt: session.session?.expiresAt,
}
: null,
sessionError,
};
return NextResponse.json(info, {
status: 200,
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,49 @@
import DentalAppointmentReminder from "@/components/emails/email-remainder";
import { Resend } from "resend";
import React from "react";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const body = await request.json();
const {
patientName,
appointmentDate,
appointmentTime,
doctorName,
treatmentType,
duration,
clinicPhone,
clinicEmail,
clinicAddress,
to,
} = body;
try {
const { data, error } = await resend.emails.send({
from: `Dental U Care <${process.env.EMAIL_SENDER_ADDRESS || "onboarding@dentalucare.tech"}>`,
to: [to],
subject: `Dental Appointment Reminder for ${patientName}`,
react: (
<DentalAppointmentReminder
patientName={patientName}
appointmentDate={appointmentDate}
appointmentTime={appointmentTime}
doctorName={doctorName}
treatmentType={treatmentType}
duration={duration}
clinicPhone={clinicPhone}
clinicEmail={clinicEmail}
clinicAddress={clinicAddress || ""}
/>
),
});
if (error) {
return Response.json({ error }, { status: 500 });
}
return Response.json(data);
} catch (error) {
return Response.json({ error }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await import("next/headers").then((mod) => mod.headers()),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admin can change roles
if (session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden: Admin access required" },
{ status: 403 }
);
}
const { id } = await params;
const body = await request.json();
const { role } = body;
// Validate role
if (!role || !["patient", "dentist", "admin"].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be patient, dentist, or admin" },
{ status: 400 }
);
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Prevent changing own role
if (user.id === session.user.id) {
return NextResponse.json(
{ error: "You cannot change your own role" },
{ status: 400 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
});
return NextResponse.json({
success: true,
user: updatedUser,
message: `Role changed to ${role} successfully`,
});
} catch (error) {
console.error("Error changing user role:", error);
return NextResponse.json(
{ error: "Failed to change user role" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,5 @@
import PrivacyPolicy from "@/components/landing/privacy-policy";
export default function PrivacyPolicyPage() {
return <PrivacyPolicy />;
}

View File

@@ -0,0 +1,5 @@
import TermsAndConditions from "@/components/landing/terms-and-conditions";
export default function TermsAndConditionsPage() {
return <TermsAndConditions />;
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
export default async function ForbiddenPage() {
const user = await getCurrentUser();
// If not authenticated, redirect to sign-in
if (!user) {
redirect("/sign-in");
}
// Determine the appropriate dashboard based on role
const getDashboardUrl = () => {
switch (user.role) {
case "admin":
return "/admin";
case "dentist":
return "/dentist";
case "patient":
return "/patient";
default:
return "/profile";
}
};
return (
<main className="flex grow items-center justify-center px-4 text-center">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-4xl font-bold">403</h1>
<h2 className="text-2xl font-semibold">Access Denied</h2>
<p className="text-muted-foreground">
You don&apos;t have permission to access this page.
</p>
</div>
<div className="flex gap-4 justify-center">
<Button asChild variant="default">
<Link href={getDashboardUrl()}>Go to Dashboard</Link>
</Button>
<Button asChild variant="outline">
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function UnauthorizedPage() {
const pathname = usePathname();
return (
<main className="flex grow items-center justify-center px-4 text-center">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">401 - Unauthorized</h1>
<p className="text-muted-foreground">Please sign in to continue.</p>
</div>
<div>
<Button asChild>
<Link href={`/sign-in?redirect=${pathname}`}>Sign in</Link>
</Button>
</div>
</div>
</main>
);
}

5
app/get-started/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import GetStartedGuide from "@/components/landing/get-started";
export default function GetStartedPage() {
return <GetStartedGuide />;
}

235
app/globals.css Normal file
View File

@@ -0,0 +1,235 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1884 0.0128 248.5103);
--card: oklch(0.9784 0.0011 197.1387);
--card-foreground: oklch(0.1884 0.0128 248.5103);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1884 0.0128 248.5103);
--primary: oklch(0.6723 0.1606 244.9955);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.1884 0.0128 248.5103);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9222 0.0013 286.3737);
--muted-foreground: oklch(0.1884 0.0128 248.5103);
--accent: oklch(0.9392 0.0166 250.8453);
--accent-foreground: oklch(0.6723 0.1606 244.9955);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9317 0.0118 231.6594);
--input: oklch(0.9809 0.0025 228.7836);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.9784 0.0011 197.1387);
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
--sidebar-primary: oklch(0.6723 0.1606 244.9955);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9392 0.0166 250.8453);
--sidebar-accent-foreground: oklch(0.6723 0.1606 244.9955);
--sidebar-border: oklch(0.9271 0.0101 238.5177);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: rgba(29,161,242,0.15);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.9328 0.0025 228.7857);
--card: oklch(0.2097 0.0080 274.5332);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: rgba(29,161,242,0.25);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--animate-aurora: aurora 60s linear infinite;
@keyframes aurora {
from {
backgroundPosition: 50% 50%, 50% 50%;
}
to {
backgroundPosition: 350% 50%, 350% 50%;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
@layer base {
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--foreground);
border-radius: 5px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--foreground) transparent;
}
}
@utility container {
margin-inline: auto;
padding-inline: 1.5rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1440px) {
padding-inline: 2rem;
max-width: 1440px;
}
}
/** Smooth scroll **/
html {
scroll-behavior: smooth;
}

42
app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/provider/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { ReactNode } from "react";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Dental U Care",
description: "Your one-stop solution for dental care",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster position="top-right" />
</ThemeProvider>
</body>
</html>
);
}

56
app/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { Contact } from "@/components/landing/contact";
import { Footer } from "@/components/landing/footer";
import { Hero } from "@/components/landing/hero";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
import { Pricing } from "@/components/landing/pricing";
import { Features } from "@/components/landing/features";
import { Team } from "@/components/landing/team";
import { About } from "@/components/landing/about";
import { Services } from "@/components/landing/services";
// import { AuroraBackground } from "@/components/ui/shadcn-io/aurora-background";
export default function Home() {
return (
<main className=" py-4 max-w-7xl mx-auto relative z-10 justify-center px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<section id="home" className="py-4 my-4">
<Hero />
</section>
<section id="about">
<About />
</section>
<section
id="team"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(251,191,36,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(251,191,36,0.3)] p-8 my-8"
>
<Team />
</section>
<section
id="features"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(249,115,22,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(249,115,22,0.3)] p-8 my-8"
>
<Features />
</section>
<section
id="services"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(59,130,246,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(59,130,246,0.3)] p-8 my-8"
>
<Services />
</section>
<section
id="pricing"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(168,85,247,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(168,85,247,0.3)] p-8 my-8"
>
<Pricing />
</section>
<section
id="contact"
className="rounded-2xl p-8 my-8 shadow-[0_18px_30px_rgba(236,72,153,0.18)] dark:shadow-[0_18px_30px_rgba(236,72,153,0.12)]"
>
<Contact />
</section>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { Navbar } from "@/components/landing/navbar";
export default function PatientResourcesPage() {
return (
<>
<Navbar />
<main className="min-h-screen bg-background flex flex-col items-center justify-center">
<h1 className="text-4xl font-bold mb-6">Patient Resources</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-xl text-center">
Find new patient forms, insurance information, and financing options to help you prepare for your visit and manage your dental care.
</p>
{/* Add more resource links or components here as needed */}
</main>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { CosmeticDentistry } from "@/components/services/CosmeticDentistry";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function CosmeticDentistryPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<CosmeticDentistry />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { EmergencyCare } from "@/components/services/EmergencyCare";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function EmergencyCarePage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<EmergencyCare />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { Orthodontics } from "@/components/services/Orthodontics";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function OrthodonticsPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<Orthodontics />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { PediatricDentistry } from "@/components/services/PediatricDentistry";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function PediatricDentistryPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<PediatricDentistry />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import {PreventiveCare} from "@/components/services/PreventiveCare";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function PreventiveCarePage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<PreventiveCare />
</main>
);
}