Dental Care
This commit is contained in:
104
app/(auth)/forgot-password/forgot-password-form.tsx
Normal file
104
app/(auth)/forgot-password/forgot-password-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
26
app/(auth)/forgot-password/page.tsx
Normal file
26
app/(auth)/forgot-password/page.tsx
Normal 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
13
app/(auth)/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
32
app/(auth)/reset-password/page.tsx
Normal file
32
app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
app/(auth)/reset-password/reset-password-form.tsx
Normal file
254
app/(auth)/reset-password/reset-password-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
38
app/(auth)/sign-in/page.tsx
Normal file
38
app/(auth)/sign-in/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
335
app/(auth)/sign-in/sign-in-form.tsx
Normal file
335
app/(auth)/sign-in/sign-in-form.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/sign-up" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</Link>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
app/(auth)/sign-up/page.tsx
Normal file
46
app/(auth)/sign-up/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
app/(auth)/sign-up/sign-up-form.tsx
Normal file
414
app/(auth)/sign-up/sign-up-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
18
app/(main)/admin/action.ts
Normal file
18
app/(main)/admin/action.ts
Normal 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);
|
||||
}
|
||||
49
app/(main)/admin/appointment-management/page.tsx
Normal file
49
app/(main)/admin/appointment-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
app/(main)/admin/dentist-management/page.tsx
Normal file
62
app/(main)/admin/dentist-management/page.tsx
Normal 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
197
app/(main)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
app/(main)/admin/patient-management/page.tsx
Normal file
62
app/(main)/admin/patient-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/(main)/admin/service-management/page.tsx
Normal file
53
app/(main)/admin/service-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/(main)/admin/settings/page.tsx
Normal file
19
app/(main)/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
app/(main)/admin/user-management/page.tsx
Normal file
42
app/(main)/admin/user-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
app/(main)/dentist/appointments/page.tsx
Normal file
57
app/(main)/dentist/appointments/page.tsx
Normal 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
294
app/(main)/dentist/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
64
app/(main)/dentist/patients/page.tsx
Normal file
64
app/(main)/dentist/patients/page.tsx
Normal 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' information and history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DentistPatientsTable patients={patients} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
49
app/(main)/dentist/schedule/page.tsx
Normal file
49
app/(main)/dentist/schedule/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/(main)/dentist/settings/page.tsx
Normal file
44
app/(main)/dentist/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
app/(main)/patient/appointments/page.tsx
Normal file
78
app/(main)/patient/appointments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
app/(main)/patient/book-appointment/page.tsx
Normal file
93
app/(main)/patient/book-appointment/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
app/(main)/patient/health-records/page.tsx
Normal file
176
app/(main)/patient/health-records/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
app/(main)/patient/page.tsx
Normal file
92
app/(main)/patient/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/(main)/patient/payments/page.tsx
Normal file
52
app/(main)/patient/payments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/(main)/patient/settings/page.tsx
Normal file
44
app/(main)/patient/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(main)/profile/page.tsx
Normal file
9
app/(main)/profile/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Metadata} from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile",
|
||||
};
|
||||
|
||||
export default function Profile (){
|
||||
return
|
||||
}
|
||||
75
app/api/appointments/[id]/edit/route.ts
Normal file
75
app/api/appointments/[id]/edit/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
139
app/api/appointments/[id]/route.ts
Normal file
139
app/api/appointments/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
270
app/api/appointments/book/route.ts
Normal file
270
app/api/appointments/book/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
app/api/appointments/route.ts
Normal file
165
app/api/appointments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal 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);
|
||||
33
app/api/auth/resend-verification/route.ts
Normal file
33
app/api/auth/resend-verification/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/auth/session/route.ts
Normal file
50
app/api/auth/session/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/api/debug-session/route.ts
Normal file
78
app/api/debug-session/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/send-appointment-reminder/route.tsx
Normal file
49
app/api/send-appointment-reminder/route.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
73
app/api/users/[id]/role/route.ts
Normal file
73
app/api/users/[id]/role/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
5
app/docs/privacy-policy/page.tsx
Normal file
5
app/docs/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import PrivacyPolicy from "@/components/landing/privacy-policy";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return <PrivacyPolicy />;
|
||||
}
|
||||
5
app/docs/terms-and-conditions/page.tsx
Normal file
5
app/docs/terms-and-conditions/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
49
app/forbidden/forbidden.tsx
Normal file
49
app/forbidden/forbidden.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
25
app/forbidden/unauthorized.tsx
Normal file
25
app/forbidden/unauthorized.tsx
Normal 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
5
app/get-started/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import GetStartedGuide from "@/components/landing/get-started";
|
||||
|
||||
export default function GetStartedPage() {
|
||||
return <GetStartedGuide />;
|
||||
}
|
||||
235
app/globals.css
Normal file
235
app/globals.css
Normal 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
42
app/layout.tsx
Normal 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
56
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/patient-resources/page.tsx
Normal file
18
app/patient-resources/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/services/cosmetic-dentistry/page.tsx
Normal file
11
app/services/cosmetic-dentistry/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/services/emergency-care/page.tsx
Normal file
11
app/services/emergency-care/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/services/orthodontics/page.tsx
Normal file
11
app/services/orthodontics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/services/pediatric-dentistry/page.tsx
Normal file
11
app/services/pediatric-dentistry/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/services/preventive-care/page.tsx
Normal file
11
app/services/preventive-care/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user