├── .eslintrc
├── app
├── favicon.ico
├── (auth)
│ ├── sign-in
│ │ └── [[...sign-in]]
│ │ │ └── page.jsx
│ ├── sign-up
│ │ └── [[...sign-up]]
│ │ │ └── page.jsx
│ └── layout.js
├── (main)
│ ├── layout.jsx
│ ├── video-call
│ │ ├── page.jsx
│ │ └── video-call-ui.jsx
│ ├── doctors
│ │ ├── layout.js
│ │ ├── [specialty]
│ │ │ ├── [id]
│ │ │ │ ├── page.jsx
│ │ │ │ ├── layout.js
│ │ │ │ └── _components
│ │ │ │ │ ├── appointment-form.jsx
│ │ │ │ │ ├── slot-picker.jsx
│ │ │ │ │ └── doctor-profile.jsx
│ │ │ └── page.jsx
│ │ ├── page.jsx
│ │ └── components
│ │ │ └── doctor-card.jsx
│ ├── doctor
│ │ ├── layout.js
│ │ ├── _components
│ │ │ ├── appointments-list.jsx
│ │ │ └── availability-settings.jsx
│ │ ├── page.jsx
│ │ └── verification
│ │ │ └── page.jsx
│ ├── admin
│ │ ├── page.jsx
│ │ ├── layout.js
│ │ └── components
│ │ │ ├── verified-doctors.jsx
│ │ │ └── pending-doctors.jsx
│ ├── onboarding
│ │ ├── layout.js
│ │ └── page.jsx
│ ├── pricing
│ │ └── page.jsx
│ └── appointments
│ │ └── page.jsx
├── layout.js
├── globals.css
└── page.js
├── public
├── logo.png
├── banner.png
├── banner2.png
└── logo-single.png
├── jsconfig.json
├── postcss.config.mjs
├── lib
├── utils.js
├── prisma.js
├── schema.js
├── checkUser.js
├── private.key
├── specialities.js
└── data.js
├── prisma
├── migrations
│ ├── migration_lock.toml
│ ├── 20250517054209_credits
│ │ └── migration.sql
│ ├── 20250517064915_vr
│ │ └── migration.sql
│ ├── 20250527071527_update_payout
│ │ └── migration.sql
│ ├── 20250520101140_update_appointments
│ │ └── migration.sql
│ ├── 20250527063022_payout
│ │ └── migration.sql
│ └── 20250515081608_create_modal
│ │ └── migration.sql
└── schema.prisma
├── components
├── theme-provider.jsx
├── ui
│ ├── sonner.jsx
│ ├── label.jsx
│ ├── separator.jsx
│ ├── textarea.jsx
│ ├── input.jsx
│ ├── badge.jsx
│ ├── alert.jsx
│ ├── tabs.jsx
│ ├── button.jsx
│ ├── card.jsx
│ ├── dialog.jsx
│ └── select.jsx
├── pricing.jsx
├── page-header.jsx
└── header.jsx
├── README.md
├── next.config.mjs
├── eslint.config.mjs
├── components.json
├── .gitignore
├── actions
├── doctors-listing.js
├── patient.js
├── onboarding.js
├── payout.js
├── credits.js
├── admin.js
└── doctor.js
├── hooks
└── use-fetch.js
├── middleware.js
└── package.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-unused-vars": ["warn"]
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/doctors-appointment-platform/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/doctors-appointment-platform/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/doctors-appointment-platform/HEAD/public/banner.png
--------------------------------------------------------------------------------
/public/banner2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/doctors-appointment-platform/HEAD/public/banner2.png
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/public/logo-single.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/doctors-appointment-platform/HEAD/public/logo-single.png
--------------------------------------------------------------------------------
/app/(auth)/sign-in/[[...sign-in]]/page.jsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up/[[...sign-up]]/page.jsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
4 |
--------------------------------------------------------------------------------
/app/(auth)/layout.js:
--------------------------------------------------------------------------------
1 | const AuthLayout = ({ children }) => {
2 | return
{children}
;
3 | };
4 |
5 | export default AuthLayout;
6 |
--------------------------------------------------------------------------------
/app/(main)/layout.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const MainLayout = ({ children }) => {
4 | return {children}
;
5 | };
6 |
7 | export default MainLayout;
8 |
--------------------------------------------------------------------------------
/prisma/migrations/20250517054209_credits/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `CreditPackage` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropTable
8 | DROP TABLE "CreditPackage";
9 |
--------------------------------------------------------------------------------
/app/(main)/video-call/page.jsx:
--------------------------------------------------------------------------------
1 | import VideoCall from "./video-call-ui";
2 |
3 | export default async function VideoCallPage({ searchParams }) {
4 | const { sessionId, token } = await searchParams;
5 |
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/components/theme-provider.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 |
6 | export function ThemeProvider({ children, ...props }) {
7 | return {children} ;
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Full Stack Doctors Appointment Platform with Next JS, Neon, Tailwind, Vonage, Shadcn UI Tutorial 🔥🔥
2 | ## https://www.youtube.com/watch?v=ID1PRFF1dlw
3 |
4 |
5 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | serverComponentsHmrCache: false, // defaults to true
5 | },
6 | images: {
7 | remotePatterns: [
8 | {
9 | protocol: "https",
10 | hostname: "img.clerk.com",
11 | },
12 | ],
13 | },
14 | };
15 |
16 | export default nextConfig;
17 |
--------------------------------------------------------------------------------
/prisma/migrations/20250517064915_vr/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `description` on the `CreditTransaction` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "CreditTransaction" DROP COLUMN "description";
9 |
10 | -- AlterTable
11 | ALTER TABLE "User" ALTER COLUMN "credits" SET DEFAULT 2;
12 |
--------------------------------------------------------------------------------
/app/(main)/doctors/layout.js:
--------------------------------------------------------------------------------
1 | export const metadata = {
2 | title: "Find Doctors - MediMeet",
3 | description: "Browse and book appointments with top healthcare providers",
4 | };
5 |
6 | export default async function DoctorsLayout({ children }) {
7 | return (
8 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [...compat.extends("next/core-web-vitals")];
13 |
14 | export default eslintConfig;
15 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": false,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/lib/prisma.js:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | export const db = globalThis.prisma || new PrismaClient();
4 |
5 | if (process.env.NODE_ENV !== "production") {
6 | globalThis.prisma = db;
7 | }
8 |
9 | // globalThis.prisma: This global variable ensures that the Prisma client instance is
10 | // reused across hot reloads during development. Without this, each time your application
11 | // reloads, a new instance of the Prisma client would be created, potentially leading
12 | // to connection issues.
13 |
--------------------------------------------------------------------------------
/app/(main)/doctor/layout.js:
--------------------------------------------------------------------------------
1 | import { Stethoscope } from "lucide-react";
2 | import { PageHeader } from "@/components/page-header";
3 |
4 | export const metadata = {
5 | title: "Doctor Dashboard - MediMeet",
6 | description: "Manage your appointments and availability",
7 | };
8 |
9 | export default async function DoctorDashboardLayout({ children }) {
10 | return (
11 |
12 |
} title="Doctor Dashboard" />
13 |
14 | {children}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/prisma/migrations/20250527071527_update_payout/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `month` on the `Payout` table. All the data in the column will be lost.
5 | - You are about to drop the column `year` on the `Payout` table. All the data in the column will be lost.
6 |
7 | */
8 | -- DropIndex
9 | DROP INDEX "Payout_doctorId_month_year_key";
10 |
11 | -- AlterTable
12 | ALTER TABLE "Payout" DROP COLUMN "month",
13 | DROP COLUMN "year";
14 |
15 | -- CreateIndex
16 | CREATE INDEX "Payout_doctorId_status_idx" ON "Payout"("doctorId", "status");
17 |
--------------------------------------------------------------------------------
/components/ui/sonner.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | const Toaster = ({
7 | ...props
8 | }) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 | ( )
23 | );
24 | }
25 |
26 | export { Toaster }
27 |
--------------------------------------------------------------------------------
/lib/schema.js:
--------------------------------------------------------------------------------
1 | import z from "zod";
2 |
3 | export const doctorFormSchema = z.object({
4 | specialty: z.string().min(1, "Specialty is required"),
5 | experience: z
6 | .number({ invalid_type_error: "Experience must be a number" })
7 | .int()
8 | .min(1, "Experience must be at least 1 year")
9 | .max(70, "Experience must be less than 70 years"),
10 | credentialUrl: z
11 | .string()
12 | .url("Please enter a valid URL")
13 | .min(1, "Credential URL is required"),
14 | description: z
15 | .string()
16 | .min(20, "Description must be at least 20 characters")
17 | .max(1000, "Description cannot exceed 1000 characters"),
18 | });
19 |
--------------------------------------------------------------------------------
/components/ui/label.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 | ( )
20 | );
21 | }
22 |
23 | export { Label }
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 | lib/private.key
--------------------------------------------------------------------------------
/actions/doctors-listing.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 |
5 | /**
6 | * Get doctors by specialty
7 | */
8 | export async function getDoctorsBySpecialty(specialty) {
9 | try {
10 | const doctors = await db.user.findMany({
11 | where: {
12 | role: "DOCTOR",
13 | verificationStatus: "VERIFIED",
14 | specialty: specialty.split("%20").join(" "),
15 | },
16 | orderBy: {
17 | name: "asc",
18 | },
19 | });
20 |
21 | return { doctors };
22 | } catch (error) {
23 | console.error("Failed to fetch doctors by specialty:", error);
24 | return { error: "Failed to fetch doctors" };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/hooks/use-fetch.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { toast } from "sonner";
3 |
4 | const useFetch = (cb) => {
5 | const [data, setData] = useState(undefined);
6 | const [loading, setLoading] = useState(null);
7 | const [error, setError] = useState(null);
8 |
9 | const fn = async (...args) => {
10 | setLoading(true);
11 | setError(null);
12 |
13 | try {
14 | const response = await cb(...args);
15 | setData(response);
16 | setError(null);
17 | } catch (error) {
18 | setError(error);
19 | toast.error(error.message);
20 | } finally {
21 | setLoading(false);
22 | }
23 | };
24 |
25 | return { data, loading, error, fn, setData };
26 | };
27 |
28 | export default useFetch;
29 |
--------------------------------------------------------------------------------
/components/pricing.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { Card, CardContent } from "./ui/card";
5 | import { PricingTable } from "@clerk/nextjs";
6 |
7 | const Pricing = () => {
8 | return (
9 |
10 |
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default Pricing;
28 |
--------------------------------------------------------------------------------
/components/ui/separator.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }) {
14 | return (
15 | ( )
24 | );
25 | }
26 |
27 | export { Separator }
28 |
--------------------------------------------------------------------------------
/components/ui/textarea.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Textarea({
6 | className,
7 | ...props
8 | }) {
9 | return (
10 | ()
17 | );
18 | }
19 |
20 | export { Textarea }
21 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/[id]/page.jsx:
--------------------------------------------------------------------------------
1 | import { getDoctorById, getAvailableTimeSlots } from "@/actions/appointments";
2 | import { DoctorProfile } from "./_components/doctor-profile";
3 | import { redirect } from "next/navigation";
4 |
5 | export default async function DoctorProfilePage({ params }) {
6 | const { id } = await params;
7 |
8 | try {
9 | // Fetch doctor data and available slots in parallel
10 | const [doctorData, slotsData] = await Promise.all([
11 | getDoctorById(id),
12 | getAvailableTimeSlots(id),
13 | ]);
14 |
15 | return (
16 |
20 | );
21 | } catch (error) {
22 | console.error("Error loading doctor profile:", error);
23 | redirect("/doctors");
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 | import { NextResponse } from "next/server";
3 |
4 | const isProtectedRoute = createRouteMatcher([
5 | "/doctors(.*)",
6 | "/onboarding(.*)",
7 | "/doctor(.*)",
8 | "/admin(.*)",
9 | "/video-call(.*)",
10 | "/appointments(.*)",
11 | ]);
12 |
13 | export default clerkMiddleware(async (auth, req) => {
14 | const { userId } = await auth();
15 |
16 | if (!userId && isProtectedRoute(req)) {
17 | const { redirectToSignIn } = await auth();
18 | return redirectToSignIn();
19 | }
20 |
21 | return NextResponse.next();
22 | });
23 |
24 | export const config = {
25 | matcher: [
26 | // Skip Next.js internals and all static files, unless found in search params
27 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
28 | // Always run for API routes
29 | "/(api|trpc)(.*)",
30 | ],
31 | };
32 |
--------------------------------------------------------------------------------
/components/ui/input.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({
6 | className,
7 | type,
8 | ...props
9 | }) {
10 | return (
11 | ( )
21 | );
22 | }
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/[id]/layout.js:
--------------------------------------------------------------------------------
1 | import { getDoctorById } from "@/actions/appointments";
2 | import { redirect } from "next/navigation";
3 | import { PageHeader } from "@/components/page-header";
4 |
5 | export async function generateMetadata({ params }) {
6 | const { id } = await params;
7 |
8 | const { doctor } = await getDoctorById(id);
9 | return {
10 | title: `Dr. ${doctor.name} - MediMeet`,
11 | description: `Book an appointment with Dr. ${doctor.name}, ${doctor.specialty} specialist with ${doctor.experience} years of experience.`,
12 | };
13 | }
14 |
15 | export default async function DoctorProfileLayout({ children, params }) {
16 | const { id } = await params;
17 | const { doctor } = await getDoctorById(id);
18 |
19 | if (!doctor) redirect("/doctors");
20 |
21 | return (
22 |
23 |
}
25 | title={"Dr. " + doctor.name}
26 | backLink={`/doctors/${doctor.specialty}`}
27 | backLabel={`Back to ${doctor.specialty}`}
28 | />
29 |
30 | {children}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/prisma/migrations/20250520101140_update_appointments/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `availabilityId` on the `Appointment` table. All the data in the column will be lost.
5 | - Added the required column `endTime` to the `Appointment` table without a default value. This is not possible if the table is not empty.
6 | - Added the required column `startTime` to the `Appointment` table without a default value. This is not possible if the table is not empty.
7 |
8 | */
9 | -- DropForeignKey
10 | ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_availabilityId_fkey";
11 |
12 | -- DropIndex
13 | DROP INDEX "Appointment_availabilityId_key";
14 |
15 | -- DropIndex
16 | DROP INDEX "Appointment_status_createdAt_idx";
17 |
18 | -- AlterTable
19 | ALTER TABLE "Appointment" DROP COLUMN "availabilityId",
20 | ADD COLUMN "endTime" TIMESTAMP(3) NOT NULL,
21 | ADD COLUMN "startTime" TIMESTAMP(3) NOT NULL;
22 |
23 | -- CreateIndex
24 | CREATE INDEX "Appointment_status_startTime_idx" ON "Appointment"("status", "startTime");
25 |
26 | -- CreateIndex
27 | CREATE INDEX "Appointment_doctorId_startTime_idx" ON "Appointment"("doctorId", "startTime");
28 |
--------------------------------------------------------------------------------
/prisma/migrations/20250527063022_payout/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "PayoutStatus" AS ENUM ('PROCESSING', 'PROCESSED');
3 |
4 | -- CreateTable
5 | CREATE TABLE "Payout" (
6 | "id" TEXT NOT NULL,
7 | "doctorId" TEXT NOT NULL,
8 | "amount" DOUBLE PRECISION NOT NULL,
9 | "credits" INTEGER NOT NULL,
10 | "platformFee" DOUBLE PRECISION NOT NULL,
11 | "netAmount" DOUBLE PRECISION NOT NULL,
12 | "paypalEmail" TEXT NOT NULL,
13 | "status" "PayoutStatus" NOT NULL DEFAULT 'PROCESSING',
14 | "month" TEXT NOT NULL,
15 | "year" INTEGER NOT NULL,
16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17 | "updatedAt" TIMESTAMP(3) NOT NULL,
18 | "processedAt" TIMESTAMP(3),
19 | "processedBy" TEXT,
20 |
21 | CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
22 | );
23 |
24 | -- CreateIndex
25 | CREATE INDEX "Payout_status_createdAt_idx" ON "Payout"("status", "createdAt");
26 |
27 | -- CreateIndex
28 | CREATE UNIQUE INDEX "Payout_doctorId_month_year_key" ON "Payout"("doctorId", "month", "year");
29 |
30 | -- AddForeignKey
31 | ALTER TABLE "Payout" ADD CONSTRAINT "Payout_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
32 |
--------------------------------------------------------------------------------
/app/(main)/admin/page.jsx:
--------------------------------------------------------------------------------
1 | import { TabsContent } from "@/components/ui/tabs";
2 | import { PendingDoctors } from "./components/pending-doctors";
3 | import { VerifiedDoctors } from "./components/verified-doctors";
4 | import { PendingPayouts } from "./components/pending-payouts";
5 | import {
6 | getPendingDoctors,
7 | getVerifiedDoctors,
8 | getPendingPayouts,
9 | } from "@/actions/admin";
10 |
11 | export default async function AdminPage() {
12 | // Fetch all data in parallel
13 | const [pendingDoctorsData, verifiedDoctorsData, pendingPayoutsData] =
14 | await Promise.all([
15 | getPendingDoctors(),
16 | getVerifiedDoctors(),
17 | getPendingPayouts(),
18 | ]);
19 |
20 | return (
21 | <>
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/actions/patient.js:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/prisma";
2 | import { auth } from "@clerk/nextjs/server";
3 |
4 | /**
5 | * Get all appointments for the authenticated patient
6 | */
7 | export async function getPatientAppointments() {
8 | const { userId } = await auth();
9 |
10 | if (!userId) {
11 | throw new Error("Unauthorized");
12 | }
13 |
14 | try {
15 | const user = await db.user.findUnique({
16 | where: {
17 | clerkUserId: userId,
18 | role: "PATIENT",
19 | },
20 | select: {
21 | id: true,
22 | },
23 | });
24 |
25 | if (!user) {
26 | throw new Error("Patient not found");
27 | }
28 |
29 | const appointments = await db.appointment.findMany({
30 | where: {
31 | patientId: user.id,
32 | },
33 | include: {
34 | doctor: {
35 | select: {
36 | id: true,
37 | name: true,
38 | specialty: true,
39 | imageUrl: true,
40 | },
41 | },
42 | },
43 | orderBy: {
44 | startTime: "asc",
45 | },
46 | });
47 |
48 | return { appointments };
49 | } catch (error) {
50 | console.error("Failed to get patient appointments:", error);
51 | return { error: "Failed to fetch appointments" };
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/(main)/doctors/page.jsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import { SPECIALTIES } from "@/lib/specialities";
4 |
5 | export default async function DoctorsPage() {
6 | return (
7 | <>
8 |
9 |
Find Your Doctor
10 |
11 | Browse by specialty or view all available healthcare providers
12 |
13 |
14 |
15 | {SPECIALTIES.map((specialty) => (
16 |
17 |
18 |
19 |
20 |
{specialty.icon}
21 |
22 | {specialty.name}
23 |
24 |
25 |
26 | ))}
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/(main)/onboarding/layout.js:
--------------------------------------------------------------------------------
1 | import { getCurrentUser } from "@/actions/onboarding";
2 | import { redirect } from "next/navigation";
3 |
4 | export const metadata = {
5 | title: "Onboarding - MediMeet",
6 | description: "Complete your profile to get started with MediMeet",
7 | };
8 |
9 | export default async function OnboardingLayout({ children }) {
10 | // Get complete user profile
11 | const user = await getCurrentUser();
12 |
13 | // Redirect users who have already completed onboarding
14 | if (user) {
15 | if (user.role === "PATIENT") {
16 | redirect("/doctors");
17 | } else if (user.role === "DOCTOR") {
18 | // Check verification status for doctors
19 | if (user.verificationStatus === "VERIFIED") {
20 | redirect("/doctor");
21 | } else {
22 | redirect("/doctor/verification");
23 | }
24 | } else if (user.role === "ADMIN") {
25 | redirect("/admin");
26 | }
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | Welcome to MediMeet
35 |
36 |
37 | Tell us how you want to use the platform
38 |
39 |
40 |
41 | {children}
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/components/page-header.jsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { ArrowLeft } from "lucide-react";
3 | import React from "react";
4 | import { Button } from "./ui/button";
5 |
6 | /**
7 | * Reusable page header component with back button and title
8 | *
9 | * @param {React.ReactNode} props.icon - Icon component to display next to the title
10 | * @param {string} props.title - Page title
11 | * @param {string} props.backLink - URL to navigate back to (defaults to home)
12 | * @param {string} props.backLabel - Text for the back link (defaults to "Back to Home")
13 | */
14 | export function PageHeader({
15 | icon,
16 | title,
17 | backLink = "/",
18 | backLabel = "Back to Home",
19 | }) {
20 | return (
21 |
22 |
23 |
28 |
29 | {backLabel}
30 |
31 |
32 |
33 | {icon && (
34 |
35 | {React.cloneElement(icon, {
36 | className: "h-12 md:h-14 w-12 md:w-14",
37 | })}
38 |
39 | )}
40 |
{title}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/lib/checkUser.js:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs/server";
2 | import { db } from "./prisma";
3 |
4 | export const checkUser = async () => {
5 | const user = await currentUser();
6 |
7 | if (!user) {
8 | return null;
9 | }
10 |
11 | try {
12 | const loggedInUser = await db.user.findUnique({
13 | where: {
14 | clerkUserId: user.id,
15 | },
16 | include: {
17 | transactions: {
18 | where: {
19 | type: "CREDIT_PURCHASE",
20 | // Only get transactions from current month
21 | createdAt: {
22 | gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
23 | },
24 | },
25 | orderBy: {
26 | createdAt: "desc",
27 | },
28 | take: 1,
29 | },
30 | },
31 | });
32 |
33 | if (loggedInUser) {
34 | return loggedInUser;
35 | }
36 |
37 | const name = `${user.firstName} ${user.lastName}`;
38 |
39 | const newUser = await db.user.create({
40 | data: {
41 | clerkUserId: user.id,
42 | name,
43 | imageUrl: user.imageUrl,
44 | email: user.emailAddresses[0].emailAddress,
45 | transactions: {
46 | create: {
47 | type: "CREDIT_PURCHASE",
48 | packageId: "free_user",
49 | amount: 0,
50 | },
51 | },
52 | },
53 | });
54 |
55 | return newUser;
56 | } catch (error) {
57 | console.log(error.message);
58 | }
59 | };
60 |
--------------------------------------------------------------------------------
/app/layout.js:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 | import "./globals.css";
3 | import { ClerkProvider } from "@clerk/nextjs";
4 | import { Toaster } from "sonner";
5 | import Header from "@/components/header";
6 | import { dark } from "@clerk/themes";
7 | import { ThemeProvider } from "@/components/theme-provider";
8 |
9 | const inter = Inter({ subsets: ["latin"] });
10 |
11 | export const metadata = {
12 | title: "Doctors Appointment App",
13 | description: "Connect with doctors anytime, anywhere",
14 | };
15 |
16 | export default function RootLayout({ children }) {
17 | return (
18 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
35 | {children}
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/page.jsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { getDoctorsBySpecialty } from "@/actions/doctors-listing";
3 | import { DoctorCard } from "../components/doctor-card";
4 | import { PageHeader } from "@/components/page-header";
5 |
6 | export default async function DoctorSpecialtyPage({ params }) {
7 | const { specialty } = await params;
8 |
9 | // Redirect to main doctors page if no specialty is provided
10 | if (!specialty) {
11 | redirect("/doctors");
12 | }
13 |
14 | // Fetch doctors by specialty
15 | const { doctors, error } = await getDoctorsBySpecialty(specialty);
16 |
17 | if (error) {
18 | console.error("Error fetching doctors:", error);
19 | }
20 |
21 | return (
22 |
23 |
28 |
29 | {doctors && doctors.length > 0 ? (
30 |
31 | {doctors.map((doctor) => (
32 |
33 | ))}
34 |
35 | ) : (
36 |
37 |
38 | No doctors available
39 |
40 |
41 | There are currently no verified doctors in this specialty. Please
42 | check back later or choose another specialty.
43 |
44 |
45 | )}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/components/ui/badge.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const badgeVariants = cva(
8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 | secondary:
15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 | destructive:
17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18 | outline:
19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | }
26 | )
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }) {
34 | const Comp = asChild ? Slot : "span"
35 |
36 | return (
37 | ( )
41 | );
42 | }
43 |
44 | export { Badge, badgeVariants }
45 |
--------------------------------------------------------------------------------
/components/ui/alert.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-card text-card-foreground",
12 | destructive:
13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | function Alert({
23 | className,
24 | variant,
25 | ...props
26 | }) {
27 | return (
28 | (
)
33 | );
34 | }
35 |
36 | function AlertTitle({
37 | className,
38 | ...props
39 | }) {
40 | return (
41 | (
)
45 | );
46 | }
47 |
48 | function AlertDescription({
49 | className,
50 | ...props
51 | }) {
52 | return (
53 | (
)
60 | );
61 | }
62 |
63 | export { Alert, AlertTitle, AlertDescription }
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "doctor-appointment",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "postinstall": "prisma generate"
11 | },
12 | "dependencies": {
13 | "@clerk/nextjs": "^6.19.3",
14 | "@clerk/themes": "^2.2.43",
15 | "@hookform/resolvers": "^5.0.1",
16 | "@prisma/client": "^6.7.0",
17 | "@radix-ui/react-alert-dialog": "^1.1.13",
18 | "@radix-ui/react-dialog": "^1.1.13",
19 | "@radix-ui/react-label": "^2.1.6",
20 | "@radix-ui/react-select": "^2.2.4",
21 | "@radix-ui/react-separator": "^1.1.6",
22 | "@radix-ui/react-slot": "^1.2.2",
23 | "@radix-ui/react-tabs": "^1.1.11",
24 | "@vonage/auth": "^1.12.0",
25 | "@vonage/client-sdk-video": "^2.30.0",
26 | "@vonage/server-sdk": "^3.21.0",
27 | "@vonage/video": "^1.23.0",
28 | "class-variance-authority": "^0.7.1",
29 | "clsx": "^2.1.1",
30 | "date-fns": "^4.1.0",
31 | "lucide-react": "^0.510.0",
32 | "next": "15.3.2",
33 | "next-themes": "^0.4.6",
34 | "opentok": "^2.21.2",
35 | "react": "^19.0.0",
36 | "react-dom": "^19.0.0",
37 | "react-hook-form": "^7.56.3",
38 | "react-spinners": "^0.17.0",
39 | "sonner": "^2.0.3",
40 | "tailwind-merge": "^3.3.0",
41 | "zod": "^3.24.4"
42 | },
43 | "devDependencies": {
44 | "@eslint/eslintrc": "^3",
45 | "@tailwindcss/postcss": "^4",
46 | "autoprefixer": "^10.4.21",
47 | "eslint": "^9",
48 | "eslint-config-next": "15.3.2",
49 | "postcss": "^8.5.3",
50 | "prisma": "^6.8.2",
51 | "tailwindcss": "^4.1.7",
52 | "tw-animate-css": "^1.2.9"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/lib/private.key:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDlfIlWPO4ICCtl
3 | J6I9mI0+LFjTBycpRKLkdWHlQ4DfOKBJrlhGK/AYv71yUQ9DLz34okju3T1v5S7E
4 | KYSeEmvQamuF8Um9L9yivS6nilQ6ly3t4ZkVpxDNHmLatirP2uq2nxi69R7ZqOQe
5 | OBvO/R8dwhK0XlNJV4h8qbkfp1B3lN2UM9Ykxm+lW4HIZWmltbnpmcqY8JIeWnU3
6 | vRlQ2YBpUwjxxFBUKfELUC+rdI716d2Y+iMEf55MbfN3iS3napl1DNoXW4Mvpr8z
7 | NhX5nRwf8C0l5XHQ6Pc8t9Vn1jaTlAs8rj2Sd0galfPPJ/2Iubv4u7fuKH6hKOJB
8 | gukI36V7AgMBAAECggEABC1liFSBZ4bkJKokECNPDp60gTf1JdWppUlxlbgF8uPe
9 | J+Cqm20xyrISjR674vTDBsrY7RbaoM+j0crEncyrcYwDLFCz3CgOYYuWP7lcMVk9
10 | njlZGhA7szpKrCwP/6Rpan3wK0W5dlajQ91seCo0HxlRli2rHQx+wgmkNb1UcxUJ
11 | HCn0GzIsQHZVNI7jbdp0bVQCFhBkck9Yaick/92d7y4rIJAE9cvLvX3QOJt562+V
12 | yLTOEGtuFKS2Ol0yh490K5MvbPybpCbRwA8Ws4y/KTVDkxVInhS/bTW128nA9j14
13 | eWJg5NxYsPGBMbNkkZ0J6i3z7jvaSObvuShH56qDZQKBgQD12EEW0YE7zEjNVNcW
14 | Y87UF1hcLILZ6dNcZ8J9NyROsBLJ/cp4IaSurck5FnYsSYBn5EVNHIWJMSoMxpkn
15 | A7hFxYBL26JmTHrxwmCw25hlD3G4zaOiz27XGWWgn84HKQXX6y8+M40QBOJscNZt
16 | /8/rQW5y1zTOYyKBc4akx1gfVQKBgQDu90wyAaT1Bb9ADyLGYGfr1zZVes+QTENj
17 | Xm7p/qpxdwU5funnFi64nz61K5Kj/+MJIb4TlN305u9sOq2cwwLDbJ7YhDRpLuT6
18 | 2lbzv2jBUTk8/67OcsC5b8d1WFID6awY2smNF7R1xIA7xE8zFm898jT2Mc0D4M1Q
19 | HFw/O0uRjwKBgA+eweP1Q8TM4gNJ1LCzfryzDwYsPdQiqy8/2HekPUZSoZ775RVk
20 | 7dW7bQGXj8KYmPQA6PZRTZq96PIO3ERCVD76oYAwYE0nptgdhY83JKOnK46WYkNB
21 | 8sTv9CkUfj6uOJTTeJj3JYtTBB/nu3gZvNgxvBbH3a8PVW3sLS3jDJJRAoGBAKb0
22 | qIuXkoSOC1zaNlWbLYAc0J1QPIx4e+yFIcDiaHr1yPSuswT8/o+G0u0JEF78fMb4
23 | iDBuJdThNA3NwVZw+RFIZoKne2axmNGakn2iEbJe6Tqw+JTMn9HvQs+9cS/CpraG
24 | xaKKGU7ehyk6sori9b2150LK8I3xFgEOj3SuHNIJAoGABHD/GghbSffI66SEPcfT
25 | paZiT+z5actxSrwAvFztBBmJXm7AJdRhfDT86ahe/3GIgPB3NbJDp47c8ZMoYO6G
26 | pBnGZZNp3jOOSD069+XC2qI5QrXM2mjD25ynhGeerRT5Y/zM/0c16Rv/Tu88J461
27 | YJY2V6hc5RH9QcQzYKN2YFk=
28 | -----END PRIVATE KEY-----
29 |
--------------------------------------------------------------------------------
/lib/specialities.js:
--------------------------------------------------------------------------------
1 | import {
2 | HeartPulse,
3 | Stethoscope,
4 | Bone,
5 | Eye,
6 | Baby,
7 | Brain,
8 | Flower2,
9 | Target,
10 | Milestone,
11 | Microscope,
12 | Timer,
13 | Thermometer,
14 | Activity,
15 | CircleDot,
16 | } from "lucide-react";
17 |
18 | export const SPECIALTIES = [
19 | {
20 | name: "General Medicine",
21 | icon: ,
22 | },
23 | {
24 | name: "Cardiology",
25 | icon: ,
26 | },
27 | {
28 | name: "Dermatology",
29 | icon: ,
30 | },
31 | {
32 | name: "Endocrinology",
33 | icon: ,
34 | },
35 | {
36 | name: "Gastroenterology",
37 | icon: ,
38 | },
39 | {
40 | name: "Neurology",
41 | icon: ,
42 | },
43 | {
44 | name: "Obstetrics & Gynecology",
45 | icon: ,
46 | },
47 | {
48 | name: "Oncology",
49 | icon: ,
50 | },
51 | {
52 | name: "Ophthalmology",
53 | icon: ,
54 | },
55 | {
56 | name: "Orthopedics",
57 | icon: ,
58 | },
59 | {
60 | name: "Pediatrics",
61 | icon: ,
62 | },
63 | {
64 | name: "Psychiatry",
65 | icon: ,
66 | },
67 | {
68 | name: "Pulmonology",
69 | icon: ,
70 | },
71 | {
72 | name: "Radiology",
73 | icon: ,
74 | },
75 | {
76 | name: "Urology",
77 | icon: ,
78 | },
79 | {
80 | name: "Other",
81 | icon: ,
82 | },
83 | ];
84 |
--------------------------------------------------------------------------------
/components/ui/tabs.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Tabs({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 | ( )
17 | );
18 | }
19 |
20 | function TabsList({
21 | className,
22 | ...props
23 | }) {
24 | return (
25 | ( )
32 | );
33 | }
34 |
35 | function TabsTrigger({
36 | className,
37 | ...props
38 | }) {
39 | return (
40 | ( )
47 | );
48 | }
49 |
50 | function TabsContent({
51 | className,
52 | ...props
53 | }) {
54 | return (
55 | ( )
59 | );
60 | }
61 |
62 | export { Tabs, TabsList, TabsTrigger, TabsContent }
63 |
--------------------------------------------------------------------------------
/app/(main)/pricing/page.jsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import Link from "next/link";
3 | import { ArrowLeft, CreditCard, Shield, Check } from "lucide-react";
4 | import { PricingTable } from "@clerk/nextjs";
5 | import { Badge } from "@/components/ui/badge";
6 | import { Card, CardContent } from "@/components/ui/card";
7 | import Pricing from "@/components/pricing";
8 |
9 | export default async function PricingPage() {
10 | return (
11 |
12 | {/* Header Section */}
13 |
14 |
18 |
19 | Back to Home
20 |
21 |
22 |
23 |
24 |
28 | Affordable Healthcare
29 |
30 |
31 |
32 | Simple, Transparent Pricing
33 |
34 |
35 |
36 | Choose the perfect consultation package that fits your healthcare
37 | needs with no hidden fees or long-term commitments
38 |
39 |
40 |
41 | {/* Pricing Table Section */}
42 |
43 |
44 | {/* FAQ Section - Optional */}
45 |
46 |
47 | Questions? We're Here to Help
48 |
49 |
50 | Contact our support team at support@medimeet.com
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/ui/button.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }) {
45 | const Comp = asChild ? Slot : "button"
46 |
47 | return (
48 | ( )
52 | );
53 | }
54 |
55 | export { Button, buttonVariants }
56 |
--------------------------------------------------------------------------------
/app/(main)/doctor/_components/appointments-list.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { getDoctorAppointments } from "@/actions/doctor";
5 | import { AppointmentCard } from "@/components/appointment-card";
6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7 | import { Calendar } from "lucide-react";
8 | import useFetch from "@/hooks/use-fetch";
9 |
10 | export default function DoctorAppointmentsList() {
11 | const {
12 | loading,
13 | data,
14 | fn: fetchAppointments,
15 | } = useFetch(getDoctorAppointments);
16 |
17 | useEffect(() => {
18 | fetchAppointments();
19 | }, []);
20 |
21 | const appointments = data?.appointments || [];
22 |
23 | return (
24 |
25 |
26 |
27 |
28 | Upcoming Appointments
29 |
30 |
31 |
32 | {loading ? (
33 |
34 |
Loading appointments...
35 |
36 | ) : appointments.length > 0 ? (
37 |
38 | {appointments.map((appointment) => (
39 |
45 | ))}
46 |
47 | ) : (
48 |
49 |
50 |
51 | No upcoming appointments
52 |
53 |
54 | You don't have any scheduled appointments yet. Make sure
55 | you've set your availability to allow patients to book.
56 |
57 |
58 | )}
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/app/(main)/admin/layout.js:
--------------------------------------------------------------------------------
1 | import { verifyAdmin } from "@/actions/admin";
2 | import { redirect } from "next/navigation";
3 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
4 | import { ShieldCheck, AlertCircle, Users, CreditCard } from "lucide-react";
5 | import { PageHeader } from "@/components/page-header";
6 |
7 | export const metadata = {
8 | title: "Admin Settings - MediMeet",
9 | description: "Manage doctors, patients, and platform settings",
10 | };
11 |
12 | export default async function AdminLayout({ children }) {
13 | // Verify the user has admin access
14 | const isAdmin = await verifyAdmin();
15 |
16 | // Redirect if not an admin
17 | if (!isAdmin) {
18 | redirect("/onboarding");
19 | }
20 |
21 | return (
22 |
23 |
} title="Admin Settings" />
24 |
25 | {/* Vertical tabs on larger screens / Horizontal tabs on mobile */}
26 |
30 |
31 |
35 |
36 | Pending Verification
37 |
38 |
42 |
43 | Doctors
44 |
45 |
49 |
50 | Payouts
51 |
52 |
53 | {children}
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/components/ui/card.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Card({
6 | className,
7 | ...props
8 | }) {
9 | return (
10 | (
)
17 | );
18 | }
19 |
20 | function CardHeader({
21 | className,
22 | ...props
23 | }) {
24 | return (
25 | (
)
32 | );
33 | }
34 |
35 | function CardTitle({
36 | className,
37 | ...props
38 | }) {
39 | return (
40 | (
)
44 | );
45 | }
46 |
47 | function CardDescription({
48 | className,
49 | ...props
50 | }) {
51 | return (
52 | (
)
56 | );
57 | }
58 |
59 | function CardAction({
60 | className,
61 | ...props
62 | }) {
63 | return (
64 | (
)
71 | );
72 | }
73 |
74 | function CardContent({
75 | className,
76 | ...props
77 | }) {
78 | return (
);
79 | }
80 |
81 | function CardFooter({
82 | className,
83 | ...props
84 | }) {
85 | return (
86 | (
)
90 | );
91 | }
92 |
93 | export {
94 | Card,
95 | CardHeader,
96 | CardFooter,
97 | CardTitle,
98 | CardAction,
99 | CardDescription,
100 | CardContent,
101 | }
102 |
--------------------------------------------------------------------------------
/app/(main)/doctors/components/doctor-card.jsx:
--------------------------------------------------------------------------------
1 | import { User, Star, Calendar } from "lucide-react";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import { Badge } from "@/components/ui/badge";
4 | import { Button } from "@/components/ui/button";
5 | import Link from "next/link";
6 |
7 | export function DoctorCard({ doctor }) {
8 | return (
9 |
10 |
11 |
12 |
13 | {doctor.imageUrl ? (
14 |
19 | ) : (
20 |
21 | )}
22 |
23 |
24 |
25 |
26 |
{doctor.name}
27 |
31 |
32 | Verified
33 |
34 |
35 |
36 |
37 | {doctor.specialty} • {doctor.experience} years experience
38 |
39 |
40 |
41 | {doctor.description}
42 |
43 |
44 |
48 |
49 |
50 | View Profile & Book
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/app/(main)/appointments/page.jsx:
--------------------------------------------------------------------------------
1 | import { getPatientAppointments } from "@/actions/patient";
2 | import { AppointmentCard } from "@/components/appointment-card";
3 | import { PageHeader } from "@/components/page-header";
4 | import { Calendar } from "lucide-react";
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6 | import { redirect } from "next/navigation";
7 | import { getCurrentUser } from "@/actions/onboarding";
8 |
9 | export default async function PatientAppointmentsPage() {
10 | const user = await getCurrentUser();
11 |
12 | if (!user || user.role !== "PATIENT") {
13 | redirect("/onboarding");
14 | }
15 |
16 | const { appointments, error } = await getPatientAppointments();
17 |
18 | return (
19 |
20 |
}
22 | title="My Appointments"
23 | backLink="/doctors"
24 | backLabel="Find Doctors"
25 | />
26 |
27 |
28 |
29 |
30 |
31 | Your Scheduled Appointments
32 |
33 |
34 |
35 | {error ? (
36 |
39 | ) : appointments?.length > 0 ? (
40 |
41 | {appointments.map((appointment) => (
42 |
47 | ))}
48 |
49 | ) : (
50 |
51 |
52 |
53 | No appointments scheduled
54 |
55 |
56 | You don't have any appointments scheduled yet. Browse our
57 | doctors and book your first consultation.
58 |
59 |
60 | )}
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/actions/onboarding.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 | import { revalidatePath } from "next/cache";
6 |
7 | /**
8 | * Sets the user's role and related information
9 | */
10 | export async function setUserRole(formData) {
11 | const { userId } = await auth();
12 |
13 | if (!userId) {
14 | throw new Error("Unauthorized");
15 | }
16 |
17 | // Find user in our database
18 | const user = await db.user.findUnique({
19 | where: { clerkUserId: userId },
20 | });
21 |
22 | if (!user) throw new Error("User not found in database");
23 |
24 | const role = formData.get("role");
25 |
26 | if (!role || !["PATIENT", "DOCTOR"].includes(role)) {
27 | throw new Error("Invalid role selection");
28 | }
29 |
30 | try {
31 | // For patient role - simple update
32 | if (role === "PATIENT") {
33 | await db.user.update({
34 | where: {
35 | clerkUserId: userId,
36 | },
37 | data: {
38 | role: "PATIENT",
39 | },
40 | });
41 |
42 | revalidatePath("/");
43 | return { success: true, redirect: "/doctors" };
44 | }
45 |
46 | // For doctor role - need additional information
47 | if (role === "DOCTOR") {
48 | const specialty = formData.get("specialty");
49 | const experience = parseInt(formData.get("experience"), 10);
50 | const credentialUrl = formData.get("credentialUrl");
51 | const description = formData.get("description");
52 |
53 | // Validate inputs
54 | if (!specialty || !experience || !credentialUrl || !description) {
55 | throw new Error("All fields are required");
56 | }
57 |
58 | await db.user.update({
59 | where: {
60 | clerkUserId: userId,
61 | },
62 | data: {
63 | role: "DOCTOR",
64 | specialty,
65 | experience,
66 | credentialUrl,
67 | description,
68 | verificationStatus: "PENDING",
69 | },
70 | });
71 |
72 | revalidatePath("/");
73 | return { success: true, redirect: "/doctor/verification" };
74 | }
75 | } catch (error) {
76 | console.error("Failed to set user role:", error);
77 | throw new Error(`Failed to update user profile: ${error.message}`);
78 | }
79 | }
80 |
81 | /**
82 | * Gets the current user's complete profile information
83 | */
84 | export async function getCurrentUser() {
85 | const { userId } = await auth();
86 |
87 | if (!userId) {
88 | return null;
89 | }
90 |
91 | try {
92 | const user = await db.user.findUnique({
93 | where: {
94 | clerkUserId: userId,
95 | },
96 | });
97 |
98 | return user;
99 | } catch (error) {
100 | console.error("Failed to get user information:", error);
101 | return null;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/lib/data.js:
--------------------------------------------------------------------------------
1 | import {
2 | Calendar,
3 | Video,
4 | CreditCard,
5 | User,
6 | FileText,
7 | ShieldCheck,
8 | } from "lucide-react";
9 |
10 | // JSON data for features
11 | export const features = [
12 | {
13 | icon: ,
14 | title: "Create Your Profile",
15 | description:
16 | "Sign up and complete your profile to get personalized healthcare recommendations and services.",
17 | },
18 | {
19 | icon: ,
20 | title: "Book Appointments",
21 | description:
22 | "Browse doctor profiles, check availability, and book appointments that fit your schedule.",
23 | },
24 | {
25 | icon: ,
26 | title: "Video Consultation",
27 | description:
28 | "Connect with doctors through secure, high-quality video consultations from the comfort of your home.",
29 | },
30 | {
31 | icon: ,
32 | title: "Consultation Credits",
33 | description:
34 | "Purchase credit packages that fit your healthcare needs with our simple subscription model.",
35 | },
36 | {
37 | icon: ,
38 | title: "Verified Doctors",
39 | description:
40 | "All healthcare providers are carefully vetted and verified to ensure quality care.",
41 | },
42 | {
43 | icon: ,
44 | title: "Medical Documentation",
45 | description:
46 | "Access and manage your appointment history, doctor's notes, and medical recommendations.",
47 | },
48 | ];
49 |
50 | // JSON data for testimonials
51 | export const testimonials = [
52 | {
53 | initials: "SP",
54 | name: "Sarah P.",
55 | role: "Patient",
56 | quote:
57 | "The video consultation feature saved me so much time. I was able to get medical advice without taking time off work or traveling to a clinic.",
58 | },
59 | {
60 | initials: "DR",
61 | name: "Dr. Robert M.",
62 | role: "Cardiologist",
63 | quote:
64 | "This platform has revolutionized my practice. I can now reach more patients and provide timely care without the constraints of a physical office.",
65 | },
66 | {
67 | initials: "JT",
68 | name: "James T.",
69 | role: "Patient",
70 | quote:
71 | "The credit system is so convenient. I purchased a package for my family, and we've been able to consult with specialists whenever needed.",
72 | },
73 | ];
74 |
75 | // JSON data for credit system benefits
76 | export const creditBenefits = [
77 | "Each consultation requires 2 credits regardless of duration",
78 | "Credits never expire - use them whenever you need",
79 | "Monthly subscriptions give you fresh credits every month ",
80 | "Cancel or change your subscription anytime without penalties",
81 | ];
82 |
--------------------------------------------------------------------------------
/app/(main)/doctor/page.jsx:
--------------------------------------------------------------------------------
1 | import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
2 | import { getDoctorAppointments, getDoctorAvailability } from "@/actions/doctor";
3 | import { AvailabilitySettings } from "./_components/availability-settings";
4 | import { getCurrentUser } from "@/actions/onboarding";
5 | import { redirect } from "next/navigation";
6 | import { Calendar, Clock, DollarSign } from "lucide-react";
7 | import DoctorAppointmentsList from "./_components/appointments-list";
8 | import { getDoctorEarnings, getDoctorPayouts } from "@/actions/payout";
9 | import { DoctorEarnings } from "./_components/doctor-earnings";
10 |
11 | export default async function DoctorDashboardPage() {
12 | const user = await getCurrentUser();
13 |
14 | const [appointmentsData, availabilityData, earningsData, payoutsData] =
15 | await Promise.all([
16 | getDoctorAppointments(),
17 | getDoctorAvailability(),
18 | getDoctorEarnings(),
19 | getDoctorPayouts(),
20 | ]);
21 |
22 | // // Redirect if not a doctor
23 | if (user?.role !== "DOCTOR") {
24 | redirect("/onboarding");
25 | }
26 |
27 | // If already verified, redirect to dashboard
28 | if (user?.verificationStatus !== "VERIFIED") {
29 | redirect("/doctor/verification");
30 | }
31 |
32 | return (
33 |
37 |
38 |
42 |
43 | Earnings
44 |
45 |
49 |
50 | Appointments
51 |
52 |
56 |
57 | Availability
58 |
59 |
60 |
61 |
62 |
65 |
66 |
67 |
68 |
69 |
70 |
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/components/ui/dialog.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }) {
12 | return ;
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }) {
18 | return ;
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }) {
24 | return ;
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }) {
30 | return ;
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }) {
37 | return (
38 | ( )
45 | );
46 | }
47 |
48 | function DialogContent({
49 | className,
50 | children,
51 | ...props
52 | }) {
53 | return (
54 | (
55 |
56 |
63 | {children}
64 |
66 |
67 | Close
68 |
69 |
70 | )
71 | );
72 | }
73 |
74 | function DialogHeader({
75 | className,
76 | ...props
77 | }) {
78 | return (
79 | (
)
83 | );
84 | }
85 |
86 | function DialogFooter({
87 | className,
88 | ...props
89 | }) {
90 | return (
91 | (
)
95 | );
96 | }
97 |
98 | function DialogTitle({
99 | className,
100 | ...props
101 | }) {
102 | return (
103 | ( )
107 | );
108 | }
109 |
110 | function DialogDescription({
111 | className,
112 | ...props
113 | }) {
114 | return (
115 | ( )
119 | );
120 | }
121 |
122 | export {
123 | Dialog,
124 | DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | DialogOverlay,
130 | DialogPortal,
131 | DialogTitle,
132 | DialogTrigger,
133 | }
134 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/[id]/_components/appointment-form.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Textarea } from "@/components/ui/textarea";
5 | import { Button } from "@/components/ui/button";
6 | import { Label } from "@/components/ui/label";
7 | import { format } from "date-fns";
8 | import { Loader2, Clock, ArrowLeft, Calendar, CreditCard } from "lucide-react";
9 | import { bookAppointment } from "@/actions/appointments";
10 | import { toast } from "sonner";
11 | import useFetch from "@/hooks/use-fetch";
12 |
13 | export function AppointmentForm({ doctorId, slot, onBack, onComplete }) {
14 | const [description, setDescription] = useState("");
15 |
16 | // Use the useFetch hook to handle loading, data, and error states
17 | const { loading, data, fn: submitBooking } = useFetch(bookAppointment);
18 |
19 | // Handle form submission
20 | const handleSubmit = async (e) => {
21 | e.preventDefault();
22 |
23 | // Create form data
24 | const formData = new FormData();
25 | formData.append("doctorId", doctorId);
26 | formData.append("startTime", slot.startTime);
27 | formData.append("endTime", slot.endTime);
28 | formData.append("description", description);
29 |
30 | // Submit booking using the function from useFetch
31 | await submitBooking(formData);
32 | };
33 |
34 | // Handle response after booking attempt
35 | useEffect(() => {
36 | if (data) {
37 | if (data.success) {
38 | toast.success("Appointment booked successfully!");
39 | onComplete();
40 | }
41 | }
42 | }, [data]);
43 |
44 | return (
45 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/prisma/migrations/20250515081608_create_modal/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "UserRole" AS ENUM ('UNASSIGNED', 'PATIENT', 'DOCTOR', 'ADMIN');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "VerificationStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
6 |
7 | -- CreateEnum
8 | CREATE TYPE "SlotStatus" AS ENUM ('AVAILABLE', 'BOOKED', 'BLOCKED');
9 |
10 | -- CreateEnum
11 | CREATE TYPE "AppointmentStatus" AS ENUM ('SCHEDULED', 'COMPLETED', 'CANCELLED');
12 |
13 | -- CreateEnum
14 | CREATE TYPE "TransactionType" AS ENUM ('CREDIT_PURCHASE', 'APPOINTMENT_DEDUCTION', 'ADMIN_ADJUSTMENT');
15 |
16 | -- CreateTable
17 | CREATE TABLE "User" (
18 | "id" TEXT NOT NULL,
19 | "clerkUserId" TEXT NOT NULL,
20 | "email" TEXT NOT NULL,
21 | "name" TEXT,
22 | "imageUrl" TEXT,
23 | "role" "UserRole" NOT NULL DEFAULT 'UNASSIGNED',
24 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25 | "updatedAt" TIMESTAMP(3) NOT NULL,
26 | "credits" INTEGER NOT NULL DEFAULT 0,
27 | "specialty" TEXT,
28 | "experience" INTEGER,
29 | "credentialUrl" TEXT,
30 | "description" TEXT,
31 | "verificationStatus" "VerificationStatus" DEFAULT 'PENDING',
32 |
33 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
34 | );
35 |
36 | -- CreateTable
37 | CREATE TABLE "Availability" (
38 | "id" TEXT NOT NULL,
39 | "doctorId" TEXT NOT NULL,
40 | "startTime" TIMESTAMP(3) NOT NULL,
41 | "endTime" TIMESTAMP(3) NOT NULL,
42 | "status" "SlotStatus" NOT NULL DEFAULT 'AVAILABLE',
43 |
44 | CONSTRAINT "Availability_pkey" PRIMARY KEY ("id")
45 | );
46 |
47 | -- CreateTable
48 | CREATE TABLE "Appointment" (
49 | "id" TEXT NOT NULL,
50 | "patientId" TEXT NOT NULL,
51 | "doctorId" TEXT NOT NULL,
52 | "availabilityId" TEXT,
53 | "status" "AppointmentStatus" NOT NULL DEFAULT 'SCHEDULED',
54 | "notes" TEXT,
55 | "patientDescription" TEXT,
56 | "videoSessionId" TEXT,
57 | "videoSessionToken" TEXT,
58 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
59 | "updatedAt" TIMESTAMP(3) NOT NULL,
60 |
61 | CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
62 | );
63 |
64 | -- CreateTable
65 | CREATE TABLE "CreditTransaction" (
66 | "id" TEXT NOT NULL,
67 | "userId" TEXT NOT NULL,
68 | "amount" INTEGER NOT NULL,
69 | "type" "TransactionType" NOT NULL,
70 | "packageId" TEXT,
71 | "description" TEXT NOT NULL,
72 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
73 |
74 | CONSTRAINT "CreditTransaction_pkey" PRIMARY KEY ("id")
75 | );
76 |
77 | -- CreateTable
78 | CREATE TABLE "CreditPackage" (
79 | "id" TEXT NOT NULL,
80 | "name" TEXT NOT NULL,
81 | "description" TEXT NOT NULL,
82 | "credits" INTEGER NOT NULL,
83 | "price" DOUBLE PRECISION NOT NULL,
84 | "active" BOOLEAN NOT NULL DEFAULT true,
85 |
86 | CONSTRAINT "CreditPackage_pkey" PRIMARY KEY ("id")
87 | );
88 |
89 | -- CreateIndex
90 | CREATE UNIQUE INDEX "User_clerkUserId_key" ON "User"("clerkUserId");
91 |
92 | -- CreateIndex
93 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
94 |
95 | -- CreateIndex
96 | CREATE INDEX "Availability_doctorId_startTime_idx" ON "Availability"("doctorId", "startTime");
97 |
98 | -- CreateIndex
99 | CREATE UNIQUE INDEX "Appointment_availabilityId_key" ON "Appointment"("availabilityId");
100 |
101 | -- CreateIndex
102 | CREATE INDEX "Appointment_status_createdAt_idx" ON "Appointment"("status", "createdAt");
103 |
104 | -- AddForeignKey
105 | ALTER TABLE "Availability" ADD CONSTRAINT "Availability_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
106 |
107 | -- AddForeignKey
108 | ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
109 |
110 | -- AddForeignKey
111 | ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
112 |
113 | -- AddForeignKey
114 | ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_availabilityId_fkey" FOREIGN KEY ("availabilityId") REFERENCES "Availability"("id") ON DELETE SET NULL ON UPDATE CASCADE;
115 |
116 | -- AddForeignKey
117 | ALTER TABLE "CreditTransaction" ADD CONSTRAINT "CreditTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
118 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/[id]/_components/slot-picker.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { format } from "date-fns";
5 | import { Card, CardContent } from "@/components/ui/card";
6 | import { Button } from "@/components/ui/button";
7 | import { Clock, ChevronRight } from "lucide-react";
8 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9 |
10 | export function SlotPicker({ days, onSelectSlot }) {
11 | const [selectedSlot, setSelectedSlot] = useState(null);
12 |
13 | // Find first day with slots as default tab
14 | const firstDayWithSlots =
15 | days.find((day) => day.slots.length > 0)?.date || days[0]?.date;
16 | const [activeTab, setActiveTab] = useState(firstDayWithSlots);
17 |
18 | const handleSlotSelect = (slot) => {
19 | setSelectedSlot(slot);
20 | };
21 |
22 | const confirmSelection = () => {
23 | if (selectedSlot) {
24 | onSelectSlot(selectedSlot);
25 | }
26 | };
27 |
28 | return (
29 |
30 |
35 |
36 | {days.map((day) => (
37 |
45 |
46 |
47 | {format(new Date(day.date), "MMM d")}
48 |
49 |
({format(new Date(day.date), "EEE")})
50 |
51 | {day.slots.length > 0 && (
52 |
53 | {day.slots.length}
54 |
55 | )}
56 |
57 | ))}
58 |
59 |
60 | {days.map((day) => (
61 |
62 | {day.slots.length === 0 ? (
63 |
64 | No available slots for this day.
65 |
66 | ) : (
67 |
68 |
69 | {day.displayDate}
70 |
71 |
72 | {day.slots.map((slot) => (
73 | handleSlotSelect(slot)}
81 | >
82 |
83 |
90 |
97 | {format(new Date(slot.startTime), "h:mm a")}
98 |
99 |
100 |
101 | ))}
102 |
103 |
104 | )}
105 |
106 | ))}
107 |
108 |
109 |
110 |
115 | Continue
116 |
117 |
118 |
119 |
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model User {
14 | id String @id @default(uuid())
15 | clerkUserId String @unique // Clerk's user ID
16 | email String @unique
17 | name String?
18 | imageUrl String?
19 | role UserRole @default(UNASSIGNED) // UNASSIGNED, PATIENT, DOCTOR, ADMIN
20 | createdAt DateTime @default(now())
21 | updatedAt DateTime @updatedAt
22 |
23 | // Patient-specific fields
24 | credits Int @default(2) // Accumulated credit balance
25 |
26 | // Doctor-specific fields
27 | specialty String?
28 | experience Int? // Years of experience
29 | credentialUrl String? // Document URL
30 | description String? @db.Text
31 | verificationStatus VerificationStatus? @default(PENDING)
32 |
33 | // Relations
34 | patientAppointments Appointment[] @relation("PatientAppointments")
35 | doctorAppointments Appointment[] @relation("DoctorAppointments")
36 | availabilities Availability[]
37 | transactions CreditTransaction[]
38 | payouts Payout[]
39 | }
40 |
41 | enum UserRole {
42 | UNASSIGNED
43 | PATIENT
44 | DOCTOR
45 | ADMIN
46 | }
47 |
48 | enum VerificationStatus {
49 | PENDING
50 | VERIFIED
51 | REJECTED
52 | }
53 |
54 | model Availability {
55 | id String @id @default(uuid())
56 | doctorId String
57 | doctor User @relation(fields: [doctorId], references: [id], onDelete: Cascade)
58 | startTime DateTime
59 | endTime DateTime
60 | status SlotStatus @default(AVAILABLE)
61 |
62 | @@index([doctorId, startTime])
63 | }
64 |
65 | enum SlotStatus {
66 | AVAILABLE
67 | BOOKED
68 | BLOCKED
69 | }
70 |
71 | model Appointment {
72 | id String @id @default(uuid())
73 | patientId String
74 | patient User @relation("PatientAppointments", fields: [patientId], references: [id])
75 | doctorId String
76 | doctor User @relation("DoctorAppointments", fields: [doctorId], references: [id])
77 | startTime DateTime // Start time of appointment
78 | endTime DateTime // End time of appointment
79 | status AppointmentStatus @default(SCHEDULED)
80 | notes String? @db.Text
81 | patientDescription String? @db.Text
82 |
83 | // Video session fields
84 | videoSessionId String? // Vonage Video API Session ID
85 | videoSessionToken String? // Optional: Can store tokens if needed
86 |
87 | createdAt DateTime @default(now())
88 | updatedAt DateTime @updatedAt
89 |
90 | @@index([status, startTime])
91 | @@index([doctorId, startTime])
92 | }
93 |
94 | enum AppointmentStatus {
95 | SCHEDULED
96 | COMPLETED
97 | CANCELLED
98 | }
99 |
100 | model CreditTransaction {
101 | id String @id @default(uuid())
102 | userId String
103 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
104 | amount Int // Positive for additions, negative for usage
105 | type TransactionType
106 | packageId String? // Reference to which package was purchased
107 | createdAt DateTime @default(now())
108 | }
109 |
110 | enum TransactionType {
111 | CREDIT_PURCHASE // Credits purchased through Clerk Billing
112 | APPOINTMENT_DEDUCTION // Credit used for appointment
113 | ADMIN_ADJUSTMENT // Manual adjustment by admin
114 | }
115 |
116 | model Payout {
117 | id String @id @default(uuid())
118 | doctorId String
119 | doctor User @relation(fields: [doctorId], references: [id], onDelete: Cascade)
120 | amount Float // Total payout amount in USD
121 | credits Int // Number of credits being paid out
122 | platformFee Float // Platform fee deducted (2 USD per credit)
123 | netAmount Float // Amount doctor receives (8 USD per credit)
124 | paypalEmail String // Doctor's PayPal email for payout
125 | status PayoutStatus @default(PROCESSING)
126 | createdAt DateTime @default(now())
127 | updatedAt DateTime @updatedAt
128 | processedAt DateTime? // When admin marked it as processed
129 | processedBy String? // Admin who processed it
130 |
131 | @@index([status, createdAt])
132 | @@index([doctorId, status])
133 | }
134 |
135 | enum PayoutStatus {
136 | PROCESSING
137 | PROCESSED
138 | }
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | }
45 |
46 | :root {
47 | --radius: 0.625rem;
48 | --background: oklch(1 0 0);
49 | --foreground: oklch(0.145 0 0);
50 | --card: oklch(1 0 0);
51 | --card-foreground: oklch(0.145 0 0);
52 | --popover: oklch(1 0 0);
53 | --popover-foreground: oklch(0.145 0 0);
54 | --primary: oklch(0.205 0 0);
55 | --primary-foreground: oklch(0.985 0 0);
56 | --secondary: oklch(0.97 0 0);
57 | --secondary-foreground: oklch(0.205 0 0);
58 | --muted: oklch(0.97 0 0);
59 | --muted-foreground: oklch(0.556 0 0);
60 | --accent: oklch(0.97 0 0);
61 | --accent-foreground: oklch(0.205 0 0);
62 | --destructive: oklch(0.577 0.245 27.325);
63 | --border: oklch(0.922 0 0);
64 | --input: oklch(0.922 0 0);
65 | --ring: oklch(0.708 0 0);
66 | --chart-1: oklch(0.646 0.222 41.116);
67 | --chart-2: oklch(0.6 0.118 184.704);
68 | --chart-3: oklch(0.398 0.07 227.392);
69 | --chart-4: oklch(0.828 0.189 84.429);
70 | --chart-5: oklch(0.769 0.188 70.08);
71 | --sidebar: oklch(0.985 0 0);
72 | --sidebar-foreground: oklch(0.145 0 0);
73 | --sidebar-primary: oklch(0.205 0 0);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.97 0 0);
76 | --sidebar-accent-foreground: oklch(0.205 0 0);
77 | --sidebar-border: oklch(0.922 0 0);
78 | --sidebar-ring: oklch(0.708 0 0);
79 | }
80 |
81 | .dark {
82 | --background: oklch(0.145 0 0);
83 | --foreground: oklch(0.985 0 0);
84 | --card: oklch(0.205 0 0);
85 | --card-foreground: oklch(0.985 0 0);
86 | --popover: oklch(0.205 0 0);
87 | --popover-foreground: oklch(0.985 0 0);
88 | --primary: oklch(0.922 0 0);
89 | --primary-foreground: oklch(0.205 0 0);
90 | --secondary: oklch(0.269 0 0);
91 | --secondary-foreground: oklch(0.985 0 0);
92 | --muted: oklch(0.269 0 0);
93 | --muted-foreground: oklch(0.708 0 0);
94 | --accent: oklch(0.269 0 0);
95 | --accent-foreground: oklch(0.985 0 0);
96 | --destructive: oklch(0.704 0.191 22.216);
97 | --border: oklch(1 0 0 / 10%);
98 | --input: oklch(1 0 0 / 15%);
99 | --ring: oklch(0.556 0 0);
100 | --chart-1: oklch(0.488 0.243 264.376);
101 | --chart-2: oklch(0.696 0.17 162.48);
102 | --chart-3: oklch(0.769 0.188 70.08);
103 | --chart-4: oklch(0.627 0.265 303.9);
104 | --chart-5: oklch(0.645 0.246 16.439);
105 | --sidebar: oklch(0.205 0 0);
106 | --sidebar-foreground: oklch(0.985 0 0);
107 | --sidebar-primary: oklch(0.488 0.243 264.376);
108 | --sidebar-primary-foreground: oklch(0.985 0 0);
109 | --sidebar-accent: oklch(0.269 0 0);
110 | --sidebar-accent-foreground: oklch(0.985 0 0);
111 | --sidebar-border: oklch(1 0 0 / 10%);
112 | --sidebar-ring: oklch(0.556 0 0);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
124 | @utility gradient {
125 | @apply bg-gradient-to-b from-emerald-500 to-teal-400;
126 | }
127 |
128 | @utility gradient-title {
129 | @apply gradient font-bold text-transparent bg-clip-text pb-1 pr-2;
130 | }
131 |
--------------------------------------------------------------------------------
/app/(main)/doctor/verification/page.jsx:
--------------------------------------------------------------------------------
1 | import { ClipboardCheck, AlertCircle, XCircle } from "lucide-react";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { Button } from "@/components/ui/button";
10 | import Link from "next/link";
11 | import { getCurrentUser } from "@/actions/onboarding";
12 | import { redirect } from "next/navigation";
13 |
14 | export default async function VerificationPage() {
15 | // Get complete user profile
16 | const user = await getCurrentUser();
17 |
18 | // If already verified, redirect to dashboard
19 | if (user?.verificationStatus === "VERIFIED") {
20 | redirect("/doctor");
21 | }
22 |
23 | const isRejected = user?.verificationStatus === "REJECTED";
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
35 | {isRejected ? (
36 |
37 | ) : (
38 |
39 | )}
40 |
41 |
42 | {isRejected
43 | ? "Verification Declined"
44 | : "Verification in Progress"}
45 |
46 |
47 | {isRejected
48 | ? "Unfortunately, your application needs revision"
49 | : "Thank you for submitting your information"}
50 |
51 |
52 |
53 | {isRejected ? (
54 |
55 |
56 |
57 |
58 | Our administrative team has reviewed your application and
59 | found that it doesn't meet our current requirements.
60 | Common reasons for rejection include:
61 |
62 |
63 | Insufficient or unclear credential documentation
64 | Professional experience requirements not met
65 | Incomplete or vague service description
66 |
67 |
68 | You can update your application with more information and
69 | resubmit for review.
70 |
71 |
72 |
73 | ) : (
74 |
75 |
76 |
77 | Your profile is currently under review by our administrative
78 | team. This process typically takes 1-2 business days.
79 | You'll receive an email notification once your account is
80 | verified.
81 |
82 |
83 | )}
84 |
85 |
86 | {isRejected
87 | ? "You can update your doctor profile and resubmit for verification."
88 | : "While you wait, you can familiarize yourself with our platform or reach out to our support team if you have any questions."}
89 |
90 |
91 |
92 | {isRejected ? (
93 | <>
94 |
99 | Return to Home
100 |
101 |
105 | Update Profile
106 |
107 | >
108 | ) : (
109 | <>
110 |
115 | Return to Home
116 |
117 |
121 | Contact Support
122 |
123 | >
124 | )}
125 |
126 |
127 |
128 |
129 |
130 | );
131 | }
132 |
--------------------------------------------------------------------------------
/actions/payout.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 | import { revalidatePath } from "next/cache";
6 |
7 | const CREDIT_VALUE = 10; // $10 per credit total
8 | const PLATFORM_FEE_PER_CREDIT = 2; // $2 platform fee
9 | const DOCTOR_EARNINGS_PER_CREDIT = 8; // $8 to doctor
10 |
11 | /**
12 | * Request payout for all remaining credits
13 | */
14 | export async function requestPayout(formData) {
15 | const { userId } = await auth();
16 |
17 | if (!userId) {
18 | throw new Error("Unauthorized");
19 | }
20 |
21 | try {
22 | const doctor = await db.user.findUnique({
23 | where: {
24 | clerkUserId: userId,
25 | role: "DOCTOR",
26 | },
27 | });
28 |
29 | if (!doctor) {
30 | throw new Error("Doctor not found");
31 | }
32 |
33 | const paypalEmail = formData.get("paypalEmail");
34 |
35 | if (!paypalEmail) {
36 | throw new Error("PayPal email is required");
37 | }
38 |
39 | // Check if doctor has any pending payout requests
40 | const existingPendingPayout = await db.payout.findFirst({
41 | where: {
42 | doctorId: doctor.id,
43 | status: "PROCESSING",
44 | },
45 | });
46 |
47 | if (existingPendingPayout) {
48 | throw new Error(
49 | "You already have a pending payout request. Please wait for it to be processed."
50 | );
51 | }
52 |
53 | // Get doctor's current credit balance
54 | const creditCount = doctor.credits;
55 |
56 | if (creditCount === 0) {
57 | throw new Error("No credits available for payout");
58 | }
59 |
60 | if (creditCount < 1) {
61 | throw new Error("Minimum 1 credit required for payout");
62 | }
63 |
64 | const totalAmount = creditCount * CREDIT_VALUE;
65 | const platformFee = creditCount * PLATFORM_FEE_PER_CREDIT;
66 | const netAmount = creditCount * DOCTOR_EARNINGS_PER_CREDIT;
67 |
68 | // Create payout request
69 | const payout = await db.payout.create({
70 | data: {
71 | doctorId: doctor.id,
72 | amount: totalAmount,
73 | credits: creditCount,
74 | platformFee,
75 | netAmount,
76 | paypalEmail,
77 | status: "PROCESSING",
78 | },
79 | });
80 |
81 | revalidatePath("/doctor");
82 | return { success: true, payout };
83 | } catch (error) {
84 | console.error("Failed to request payout:", error);
85 | throw new Error("Failed to request payout: " + error.message);
86 | }
87 | }
88 |
89 | /**
90 | * Get doctor's payout history
91 | */
92 | export async function getDoctorPayouts() {
93 | const { userId } = await auth();
94 |
95 | if (!userId) {
96 | throw new Error("Unauthorized");
97 | }
98 |
99 | try {
100 | const doctor = await db.user.findUnique({
101 | where: {
102 | clerkUserId: userId,
103 | role: "DOCTOR",
104 | },
105 | });
106 |
107 | if (!doctor) {
108 | throw new Error("Doctor not found");
109 | }
110 |
111 | const payouts = await db.payout.findMany({
112 | where: {
113 | doctorId: doctor.id,
114 | },
115 | orderBy: {
116 | createdAt: "desc",
117 | },
118 | });
119 |
120 | return { payouts };
121 | } catch (error) {
122 | throw new Error("Failed to fetch payouts: " + error.message);
123 | }
124 | }
125 |
126 | /**
127 | * Get doctor's earnings summary
128 | */
129 | export async function getDoctorEarnings() {
130 | const { userId } = await auth();
131 |
132 | if (!userId) {
133 | throw new Error("Unauthorized");
134 | }
135 |
136 | try {
137 | const doctor = await db.user.findUnique({
138 | where: {
139 | clerkUserId: userId,
140 | role: "DOCTOR",
141 | },
142 | });
143 |
144 | if (!doctor) {
145 | throw new Error("Doctor not found");
146 | }
147 |
148 | // Get all completed appointments for this doctor
149 | const completedAppointments = await db.appointment.findMany({
150 | where: {
151 | doctorId: doctor.id,
152 | status: "COMPLETED",
153 | },
154 | });
155 |
156 | // Calculate this month's completed appointments
157 | const currentMonth = new Date();
158 | currentMonth.setDate(1);
159 | currentMonth.setHours(0, 0, 0, 0);
160 |
161 | const thisMonthAppointments = completedAppointments.filter(
162 | (appointment) => new Date(appointment.createdAt) >= currentMonth
163 | );
164 |
165 | // Use doctor's actual credits from the user model
166 | const totalEarnings = doctor.credits * DOCTOR_EARNINGS_PER_CREDIT; // $8 per credit to doctor
167 |
168 | // Calculate this month's earnings (2 credits per appointment * $8 per credit)
169 | const thisMonthEarnings =
170 | thisMonthAppointments.length * 2 * DOCTOR_EARNINGS_PER_CREDIT;
171 |
172 | // Simple average per month calculation
173 | const averageEarningsPerMonth =
174 | totalEarnings > 0
175 | ? totalEarnings / Math.max(1, new Date().getMonth() + 1)
176 | : 0;
177 |
178 | // Get current credit balance for payout calculations
179 | const availableCredits = doctor.credits;
180 | const availablePayout = availableCredits * DOCTOR_EARNINGS_PER_CREDIT;
181 |
182 | return {
183 | earnings: {
184 | totalEarnings,
185 | thisMonthEarnings,
186 | completedAppointments: completedAppointments.length,
187 | averageEarningsPerMonth,
188 | availableCredits,
189 | availablePayout,
190 | },
191 | };
192 | } catch (error) {
193 | throw new Error("Failed to fetch doctor earnings: " + error.message);
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/components/header.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "./ui/button";
3 | import {
4 | Calendar,
5 | CreditCard,
6 | ShieldCheck,
7 | Stethoscope,
8 | User,
9 | } from "lucide-react";
10 | import Link from "next/link";
11 | import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
12 | import { checkUser } from "@/lib/checkUser";
13 | import { Badge } from "./ui/badge";
14 | import { checkAndAllocateCredits } from "@/actions/credits";
15 | import Image from "next/image";
16 |
17 | export default async function Header() {
18 | const user = await checkUser();
19 | if (user?.role === "PATIENT") {
20 | await checkAndAllocateCredits(user);
21 | }
22 |
23 | return (
24 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/actions/credits.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 | import { revalidatePath } from "next/cache";
6 | import { format } from "date-fns";
7 |
8 | // Define credit allocations per plan
9 | const PLAN_CREDITS = {
10 | free_user: 0, // Basic plan: 2 credits
11 | standard: 10, // Standard plan: 10 credits per month
12 | premium: 24, // Premium plan: 24 credits per month
13 | };
14 |
15 | // Each appointment costs 2 credits
16 | const APPOINTMENT_CREDIT_COST = 2;
17 |
18 | /**
19 | * Checks user's subscription and allocates monthly credits if needed
20 | * This should be called on app initialization (e.g., in a layout component)
21 | */
22 | export async function checkAndAllocateCredits(user) {
23 | try {
24 | if (!user) {
25 | return null;
26 | }
27 |
28 | // Only allocate credits for patients
29 | if (user.role !== "PATIENT") {
30 | return user;
31 | }
32 |
33 | // Check if user has a subscription
34 | const { has } = await auth();
35 |
36 | // Check which plan the user has
37 | const hasBasic = has({ plan: "free_user" });
38 | const hasStandard = has({ plan: "standard" });
39 | const hasPremium = has({ plan: "premium" });
40 |
41 | let currentPlan = null;
42 | let creditsToAllocate = 0;
43 |
44 | if (hasPremium) {
45 | currentPlan = "premium";
46 | creditsToAllocate = PLAN_CREDITS.premium;
47 | } else if (hasStandard) {
48 | currentPlan = "standard";
49 | creditsToAllocate = PLAN_CREDITS.standard;
50 | } else if (hasBasic) {
51 | currentPlan = "free_user";
52 | creditsToAllocate = PLAN_CREDITS.free_user;
53 | }
54 |
55 | // If user doesn't have any plan, just return the user
56 | if (!currentPlan) {
57 | return user;
58 | }
59 |
60 | // Check if we already allocated credits for this month
61 | const currentMonth = format(new Date(), "yyyy-MM");
62 |
63 | // If there's a transaction this month, check if it's for the same plan
64 | if (user.transactions.length > 0) {
65 | const latestTransaction = user.transactions[0];
66 | const transactionMonth = format(
67 | new Date(latestTransaction.createdAt),
68 | "yyyy-MM"
69 | );
70 | const transactionPlan = latestTransaction.packageId;
71 |
72 | // If we already allocated credits for this month and the plan is the same, just return
73 | if (
74 | transactionMonth === currentMonth &&
75 | transactionPlan === currentPlan
76 | ) {
77 | return user;
78 | }
79 | }
80 |
81 | // Allocate credits and create transaction record
82 | const updatedUser = await db.$transaction(async (tx) => {
83 | // Create transaction record
84 | await tx.creditTransaction.create({
85 | data: {
86 | userId: user.id,
87 | amount: creditsToAllocate,
88 | type: "CREDIT_PURCHASE",
89 | packageId: currentPlan,
90 | },
91 | });
92 |
93 | // Update user's credit balance
94 | const updatedUser = await tx.user.update({
95 | where: {
96 | id: user.id,
97 | },
98 | data: {
99 | credits: {
100 | increment: creditsToAllocate,
101 | },
102 | },
103 | });
104 |
105 | return updatedUser;
106 | });
107 |
108 | // Revalidate relevant paths to reflect updated credit balance
109 | revalidatePath("/doctors");
110 | revalidatePath("/appointments");
111 |
112 | return updatedUser;
113 | } catch (error) {
114 | console.error(
115 | "Failed to check subscription and allocate credits:",
116 | error.message
117 | );
118 | return null;
119 | }
120 | }
121 |
122 | /**
123 | * Deducts credits for booking an appointment
124 | */
125 | export async function deductCreditsForAppointment(userId, doctorId) {
126 | try {
127 | const user = await db.user.findUnique({
128 | where: { id: userId },
129 | });
130 |
131 | const doctor = await db.user.findUnique({
132 | where: { id: doctorId },
133 | });
134 |
135 | // Ensure user has sufficient credits
136 | if (user.credits < APPOINTMENT_CREDIT_COST) {
137 | throw new Error("Insufficient credits to book an appointment");
138 | }
139 |
140 | if (!doctor) {
141 | throw new Error("Doctor not found");
142 | }
143 |
144 | // Deduct credits from patient and add to doctor
145 | const result = await db.$transaction(async (tx) => {
146 | // Create transaction record for patient (deduction)
147 | await tx.creditTransaction.create({
148 | data: {
149 | userId: user.id,
150 | amount: -APPOINTMENT_CREDIT_COST,
151 | type: "APPOINTMENT_DEDUCTION",
152 | },
153 | });
154 |
155 | // Create transaction record for doctor (addition)
156 | await tx.creditTransaction.create({
157 | data: {
158 | userId: doctor.id,
159 | amount: APPOINTMENT_CREDIT_COST,
160 | type: "APPOINTMENT_DEDUCTION", // Using same type for consistency
161 | },
162 | });
163 |
164 | // Update patient's credit balance (decrement)
165 | const updatedUser = await tx.user.update({
166 | where: {
167 | id: user.id,
168 | },
169 | data: {
170 | credits: {
171 | decrement: APPOINTMENT_CREDIT_COST,
172 | },
173 | },
174 | });
175 |
176 | // Update doctor's credit balance (increment)
177 | await tx.user.update({
178 | where: {
179 | id: doctor.id,
180 | },
181 | data: {
182 | credits: {
183 | increment: APPOINTMENT_CREDIT_COST,
184 | },
185 | },
186 | });
187 |
188 | return updatedUser;
189 | });
190 |
191 | return { success: true, user: result };
192 | } catch (error) {
193 | console.error("Failed to deduct credits:", error);
194 | return { success: false, error: error.message };
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/components/ui/select.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }) {
12 | return ;
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }) {
18 | return ;
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }) {
24 | return ;
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }) {
33 | return (
34 | (
42 | {children}
43 |
44 |
45 |
46 | )
47 | );
48 | }
49 |
50 | function SelectContent({
51 | className,
52 | children,
53 | position = "popper",
54 | ...props
55 | }) {
56 | return (
57 | (
58 |
68 |
69 |
72 | {children}
73 |
74 |
75 |
76 | )
77 | );
78 | }
79 |
80 | function SelectLabel({
81 | className,
82 | ...props
83 | }) {
84 | return (
85 | ( )
89 | );
90 | }
91 |
92 | function SelectItem({
93 | className,
94 | children,
95 | ...props
96 | }) {
97 | return (
98 | (
105 |
106 |
107 |
108 |
109 |
110 | {children}
111 | )
112 | );
113 | }
114 |
115 | function SelectSeparator({
116 | className,
117 | ...props
118 | }) {
119 | return (
120 | ( )
124 | );
125 | }
126 |
127 | function SelectScrollUpButton({
128 | className,
129 | ...props
130 | }) {
131 | return (
132 | (
136 |
137 | )
138 | );
139 | }
140 |
141 | function SelectScrollDownButton({
142 | className,
143 | ...props
144 | }) {
145 | return (
146 | (
150 |
151 | )
152 | );
153 | }
154 |
155 | export {
156 | Select,
157 | SelectContent,
158 | SelectGroup,
159 | SelectItem,
160 | SelectLabel,
161 | SelectScrollDownButton,
162 | SelectScrollUpButton,
163 | SelectSeparator,
164 | SelectTrigger,
165 | SelectValue,
166 | }
167 |
--------------------------------------------------------------------------------
/actions/admin.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 | import { revalidatePath } from "next/cache";
6 |
7 | /**
8 | * Verifies if current user has admin role
9 | */
10 | export async function verifyAdmin() {
11 | const { userId } = await auth();
12 |
13 | if (!userId) {
14 | return false;
15 | }
16 |
17 | try {
18 | const user = await db.user.findUnique({
19 | where: {
20 | clerkUserId: userId,
21 | },
22 | });
23 |
24 | return user?.role === "ADMIN";
25 | } catch (error) {
26 | console.error("Failed to verify admin:", error);
27 | return false;
28 | }
29 | }
30 |
31 | /**
32 | * Gets all doctors with pending verification
33 | */
34 | export async function getPendingDoctors() {
35 | const isAdmin = await verifyAdmin();
36 | if (!isAdmin) throw new Error("Unauthorized");
37 |
38 | try {
39 | const pendingDoctors = await db.user.findMany({
40 | where: {
41 | role: "DOCTOR",
42 | verificationStatus: "PENDING",
43 | },
44 | orderBy: {
45 | createdAt: "desc",
46 | },
47 | });
48 |
49 | return { doctors: pendingDoctors };
50 | } catch (error) {
51 | throw new Error("Failed to fetch pending doctors");
52 | }
53 | }
54 |
55 | /**
56 | * Gets all verified doctors
57 | */
58 | export async function getVerifiedDoctors() {
59 | const isAdmin = await verifyAdmin();
60 | if (!isAdmin) throw new Error("Unauthorized");
61 |
62 | try {
63 | const verifiedDoctors = await db.user.findMany({
64 | where: {
65 | role: "DOCTOR",
66 | verificationStatus: "VERIFIED",
67 | },
68 | orderBy: {
69 | name: "asc",
70 | },
71 | });
72 |
73 | return { doctors: verifiedDoctors };
74 | } catch (error) {
75 | console.error("Failed to get verified doctors:", error);
76 | return { error: "Failed to fetch verified doctors" };
77 | }
78 | }
79 |
80 | /**
81 | * Updates a doctor's verification status
82 | */
83 | export async function updateDoctorStatus(formData) {
84 | const isAdmin = await verifyAdmin();
85 | if (!isAdmin) throw new Error("Unauthorized");
86 |
87 | const doctorId = formData.get("doctorId");
88 | const status = formData.get("status");
89 |
90 | if (!doctorId || !["VERIFIED", "REJECTED"].includes(status)) {
91 | throw new Error("Invalid input");
92 | }
93 |
94 | try {
95 | await db.user.update({
96 | where: {
97 | id: doctorId,
98 | },
99 | data: {
100 | verificationStatus: status,
101 | },
102 | });
103 |
104 | revalidatePath("/admin");
105 | return { success: true };
106 | } catch (error) {
107 | console.error("Failed to update doctor status:", error);
108 | throw new Error(`Failed to update doctor status: ${error.message}`);
109 | }
110 | }
111 |
112 | /**
113 | * Suspends or reinstates a doctor
114 | */
115 | export async function updateDoctorActiveStatus(formData) {
116 | const isAdmin = await verifyAdmin();
117 | if (!isAdmin) throw new Error("Unauthorized");
118 |
119 | const doctorId = formData.get("doctorId");
120 | const suspend = formData.get("suspend") === "true";
121 |
122 | if (!doctorId) {
123 | throw new Error("Doctor ID is required");
124 | }
125 |
126 | try {
127 | const status = suspend ? "PENDING" : "VERIFIED";
128 |
129 | await db.user.update({
130 | where: {
131 | id: doctorId,
132 | },
133 | data: {
134 | verificationStatus: status,
135 | },
136 | });
137 |
138 | revalidatePath("/admin");
139 | return { success: true };
140 | } catch (error) {
141 | console.error("Failed to update doctor active status:", error);
142 | throw new Error(`Failed to update doctor status: ${error.message}`);
143 | }
144 | }
145 |
146 | /**
147 | * Gets all pending payouts that need admin approval
148 | */
149 | export async function getPendingPayouts() {
150 | const isAdmin = await verifyAdmin();
151 | if (!isAdmin) throw new Error("Unauthorized");
152 |
153 | try {
154 | const pendingPayouts = await db.payout.findMany({
155 | where: {
156 | status: "PROCESSING",
157 | },
158 | include: {
159 | doctor: {
160 | select: {
161 | id: true,
162 | name: true,
163 | email: true,
164 | specialty: true,
165 | credits: true,
166 | },
167 | },
168 | },
169 | orderBy: {
170 | createdAt: "desc",
171 | },
172 | });
173 |
174 | return { payouts: pendingPayouts };
175 | } catch (error) {
176 | console.error("Failed to fetch pending payouts:", error);
177 | throw new Error("Failed to fetch pending payouts");
178 | }
179 | }
180 |
181 | /**
182 | * Approves a payout request and deducts credits from doctor's account
183 | */
184 | export async function approvePayout(formData) {
185 | const isAdmin = await verifyAdmin();
186 | if (!isAdmin) throw new Error("Unauthorized");
187 |
188 | const payoutId = formData.get("payoutId");
189 |
190 | if (!payoutId) {
191 | throw new Error("Payout ID is required");
192 | }
193 |
194 | try {
195 | // Get admin user info
196 | const { userId } = await auth();
197 | const admin = await db.user.findUnique({
198 | where: { clerkUserId: userId },
199 | });
200 |
201 | // Find the payout request
202 | const payout = await db.payout.findUnique({
203 | where: {
204 | id: payoutId,
205 | status: "PROCESSING",
206 | },
207 | include: {
208 | doctor: true,
209 | },
210 | });
211 |
212 | if (!payout) {
213 | throw new Error("Payout request not found or already processed");
214 | }
215 |
216 | // Check if doctor has enough credits
217 | if (payout.doctor.credits < payout.credits) {
218 | throw new Error("Doctor doesn't have enough credits for this payout");
219 | }
220 |
221 | // Process the payout in a transaction
222 | await db.$transaction(async (tx) => {
223 | // Update payout status to PROCESSED
224 | await tx.payout.update({
225 | where: {
226 | id: payoutId,
227 | },
228 | data: {
229 | status: "PROCESSED",
230 | processedAt: new Date(),
231 | processedBy: admin?.id || "unknown",
232 | },
233 | });
234 |
235 | // Deduct credits from doctor's account
236 | await tx.user.update({
237 | where: {
238 | id: payout.doctorId,
239 | },
240 | data: {
241 | credits: {
242 | decrement: payout.credits,
243 | },
244 | },
245 | });
246 |
247 | // Create a transaction record for the deduction
248 | await tx.creditTransaction.create({
249 | data: {
250 | userId: payout.doctorId,
251 | amount: -payout.credits,
252 | type: "ADMIN_ADJUSTMENT",
253 | },
254 | });
255 | });
256 |
257 | revalidatePath("/admin");
258 | return { success: true };
259 | } catch (error) {
260 | console.error("Failed to approve payout:", error);
261 | throw new Error(`Failed to approve payout: ${error.message}`);
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/app/(main)/admin/components/verified-doctors.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import {
5 | Card,
6 | CardContent,
7 | CardDescription,
8 | CardHeader,
9 | CardTitle,
10 | } from "@/components/ui/card";
11 | import { Button } from "@/components/ui/button";
12 | import { Check, Ban, Loader2, User, Search } from "lucide-react";
13 | import { Badge } from "@/components/ui/badge";
14 | import { Input } from "@/components/ui/input";
15 | import { updateDoctorActiveStatus } from "@/actions/admin";
16 | import useFetch from "@/hooks/use-fetch";
17 | import { toast } from "sonner";
18 |
19 | export function VerifiedDoctors({ doctors }) {
20 | const [searchTerm, setSearchTerm] = useState("");
21 | const [targetDoctor, setTargetDoctor] = useState(null);
22 | const [actionType, setActionType] = useState(null);
23 |
24 | const {
25 | loading,
26 | data,
27 | fn: submitStatusUpdate,
28 | } = useFetch(updateDoctorActiveStatus);
29 |
30 | const filteredDoctors = doctors.filter((doctor) => {
31 | const query = searchTerm.toLowerCase();
32 | return (
33 | doctor.name.toLowerCase().includes(query) ||
34 | doctor.specialty.toLowerCase().includes(query) ||
35 | doctor.email.toLowerCase().includes(query)
36 | );
37 | });
38 |
39 | const handleStatusChange = async (doctor, suspend) => {
40 | const confirmed = window.confirm(
41 | `Are you sure you want to ${suspend ? "suspend" : "reinstate"} ${
42 | doctor.name
43 | }?`
44 | );
45 | if (!confirmed || loading) return;
46 |
47 | const formData = new FormData();
48 | formData.append("doctorId", doctor.id);
49 | formData.append("suspend", suspend ? "true" : "false");
50 |
51 | setTargetDoctor(doctor);
52 | setActionType(suspend ? "SUSPEND" : "REINSTATE");
53 |
54 | await submitStatusUpdate(formData);
55 | };
56 |
57 | useEffect(() => {
58 | if (data?.success && targetDoctor && actionType) {
59 | const actionVerb = actionType === "SUSPEND" ? "Suspended" : "Reinstated";
60 | toast.success(`${actionVerb} ${targetDoctor.name} successfully!`);
61 | setTargetDoctor(null);
62 | setActionType(null);
63 | }
64 | }, [data]);
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 | Manage Doctors
74 |
75 |
76 | View and manage all verified doctors
77 |
78 |
79 |
80 |
81 | setSearchTerm(e.target.value)}
86 | />
87 |
88 |
89 |
90 |
91 |
92 | {filteredDoctors.length === 0 ? (
93 |
94 | {searchTerm
95 | ? "No doctors match your search criteria."
96 | : "No verified doctors available."}
97 |
98 | ) : (
99 |
100 | {filteredDoctors.map((doctor) => {
101 | const isSuspended = doctor.verificationStatus === "REJECTED";
102 | return (
103 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | {doctor.name}
116 |
117 |
118 | {doctor.specialty} • {doctor.experience} years
119 | experience
120 |
121 |
122 | {doctor.email}
123 |
124 |
125 |
126 |
127 | {isSuspended ? (
128 | <>
129 |
133 | Suspended
134 |
135 |
139 | handleStatusChange(doctor, false)
140 | }
141 | disabled={loading}
142 | className="border-emerald-900/30 hover:bg-muted/80"
143 | >
144 | {loading && targetDoctor?.id === doctor.id ? (
145 |
146 | ) : (
147 |
148 | )}
149 | Reinstate
150 |
151 | >
152 | ) : (
153 | <>
154 |
158 | Active
159 |
160 | handleStatusChange(doctor, true)}
164 | disabled={loading}
165 | className="border-red-900/30 hover:bg-red-900/10 text-red-400"
166 | >
167 | {loading && targetDoctor?.id === doctor.id ? (
168 |
169 | ) : (
170 |
171 | )}
172 | Suspend
173 |
174 | >
175 | )}
176 |
177 |
178 |
179 |
180 | );
181 | })}
182 |
183 | )}
184 |
185 |
186 |
187 | );
188 | }
189 |
--------------------------------------------------------------------------------
/app/(main)/doctor/_components/availability-settings.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { useForm } from "react-hook-form";
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card";
12 | import { Button } from "@/components/ui/button";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { Clock, Plus, Loader2, AlertCircle } from "lucide-react";
16 | import { format } from "date-fns";
17 | import { setAvailabilitySlots } from "@/actions/doctor";
18 | import useFetch from "@/hooks/use-fetch";
19 | import { toast } from "sonner";
20 |
21 | export function AvailabilitySettings({ slots }) {
22 | const [showForm, setShowForm] = useState(false);
23 |
24 | // Custom hook for server action
25 | const { loading, fn: submitSlots, data } = useFetch(setAvailabilitySlots);
26 |
27 | // React Hook Form
28 | const {
29 | register,
30 | handleSubmit,
31 | formState: { errors },
32 | } = useForm({
33 | defaultValues: {
34 | startTime: "",
35 | endTime: "",
36 | },
37 | });
38 |
39 | function createLocalDateFromTime(timeStr) {
40 | const [hours, minutes] = timeStr.split(":").map(Number);
41 | const now = new Date();
42 | const date = new Date(
43 | now.getFullYear(),
44 | now.getMonth(),
45 | now.getDate(),
46 | hours,
47 | minutes
48 | );
49 | return date;
50 | }
51 |
52 | // Handle slot submission
53 | const onSubmit = async (data) => {
54 | if (loading) return;
55 |
56 | const formData = new FormData();
57 |
58 | const today = new Date().toISOString().split("T")[0];
59 |
60 | // Create date objects
61 | const startDate = createLocalDateFromTime(data.startTime);
62 | const endDate = createLocalDateFromTime(data.endTime);
63 |
64 | if (startDate >= endDate) {
65 | toast.error("End time must be after start time");
66 | return;
67 | }
68 |
69 | // Add to form data
70 | formData.append("startTime", startDate.toISOString());
71 | formData.append("endTime", endDate.toISOString());
72 |
73 | await submitSlots(formData);
74 | };
75 |
76 | useEffect(() => {
77 | if (data && data?.success) {
78 | setShowForm(false);
79 | toast.success("Availability slots updated successfully");
80 | }
81 | }, [data]);
82 |
83 | // Format time string for display
84 | const formatTimeString = (dateString) => {
85 | try {
86 | return format(new Date(dateString), "h:mm a");
87 | } catch (e) {
88 | return "Invalid time";
89 | }
90 | };
91 |
92 | return (
93 |
94 |
95 |
96 |
97 | Availability Settings
98 |
99 |
100 | Set your daily availability for patient appointments
101 |
102 |
103 |
104 | {/* Current Availability Display */}
105 | {!showForm ? (
106 | <>
107 |
108 |
109 | Current Availability
110 |
111 |
112 | {slots.length === 0 ? (
113 |
114 | You haven't set any availability slots yet. Add your
115 | availability to start accepting appointments.
116 |
117 | ) : (
118 |
119 | {slots.map((slot) => (
120 |
124 |
125 |
126 |
127 |
128 |
129 | {formatTimeString(slot.startTime)} -{" "}
130 | {formatTimeString(slot.endTime)}
131 |
132 |
133 | {slot.appointment ? "Booked" : "Available"}
134 |
135 |
136 |
137 | ))}
138 |
139 | )}
140 |
141 |
142 | setShowForm(true)}
144 | className="w-full bg-emerald-600 hover:bg-emerald-700"
145 | >
146 |
147 | Set Availability Time
148 |
149 | >
150 | ) : (
151 |
155 |
156 | Set Daily Availability
157 |
158 |
159 |
160 |
161 |
Start Time
162 |
170 | {errors.startTime && (
171 |
172 | {errors.startTime.message}
173 |
174 | )}
175 |
176 |
177 |
178 |
End Time
179 |
185 | {errors.endTime && (
186 |
187 | {errors.endTime.message}
188 |
189 | )}
190 |
191 |
192 |
193 |
194 | setShowForm(false)}
198 | disabled={loading}
199 | className="border-emerald-900/30"
200 | >
201 | Cancel
202 |
203 |
208 | {loading ? (
209 | <>
210 |
211 | Saving...
212 | >
213 | ) : (
214 | "Save Availability"
215 | )}
216 |
217 |
218 |
219 | )}
220 |
221 |
222 |
223 |
224 | How Availability Works
225 |
226 |
227 | Setting your daily availability allows patients to book appointments
228 | during those hours. The same availability applies to all days. You
229 | can update your availability at any time, but existing booked
230 | appointments will not be affected.
231 |
232 |
233 |
234 |
235 | );
236 | }
237 |
--------------------------------------------------------------------------------
/app/(main)/doctors/[specialty]/[id]/_components/doctor-profile.jsx:
--------------------------------------------------------------------------------
1 | // /app/doctors/[id]/_components/doctor-profile.jsx
2 | "use client";
3 |
4 | import { useState } from "react";
5 | import Image from "next/image";
6 | import { useRouter } from "next/navigation";
7 | import {
8 | User,
9 | Calendar,
10 | Clock,
11 | Medal,
12 | FileText,
13 | ChevronDown,
14 | ChevronUp,
15 | AlertCircle,
16 | } from "lucide-react";
17 | import { Button } from "@/components/ui/button";
18 | import {
19 | Card,
20 | CardContent,
21 | CardDescription,
22 | CardHeader,
23 | CardTitle,
24 | } from "@/components/ui/card";
25 | import { Separator } from "@/components/ui/separator";
26 | import { Badge } from "@/components/ui/badge";
27 | import { SlotPicker } from "./slot-picker";
28 | import { AppointmentForm } from "./appointment-form";
29 | import { Alert, AlertDescription } from "@/components/ui/alert";
30 |
31 | export function DoctorProfile({ doctor, availableDays }) {
32 | const [showBooking, setShowBooking] = useState(false);
33 | const [selectedSlot, setSelectedSlot] = useState(null);
34 | const router = useRouter();
35 |
36 | // Calculate total available slots
37 | const totalSlots = availableDays?.reduce(
38 | (total, day) => total + day.slots.length,
39 | 0
40 | );
41 |
42 | const toggleBooking = () => {
43 | setShowBooking(!showBooking);
44 | if (!showBooking) {
45 | // Scroll to booking section when expanding
46 | setTimeout(() => {
47 | document.getElementById("booking-section")?.scrollIntoView({
48 | behavior: "smooth",
49 | });
50 | }, 100);
51 | }
52 | };
53 |
54 | const handleSlotSelect = (slot) => {
55 | setSelectedSlot(slot);
56 | };
57 |
58 | const handleBookingComplete = () => {
59 | router.push("/appointments");
60 | };
61 |
62 | return (
63 |
64 | {/* Left column - Doctor Photo and Quick Info (fixed on scroll) */}
65 |
66 |
67 |
68 |
69 |
70 |
71 | {doctor.imageUrl ? (
72 |
78 | ) : (
79 |
80 |
81 |
82 | )}
83 |
84 |
85 |
86 | Dr. {doctor.name}
87 |
88 |
89 |
93 | {doctor.specialty}
94 |
95 |
96 |
97 |
98 |
99 | {doctor.experience} years experience
100 |
101 |
102 |
103 |
107 | {showBooking ? (
108 | <>
109 | Hide Booking
110 |
111 | >
112 | ) : (
113 | <>
114 | Book Appointment
115 |
116 | >
117 | )}
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 | {/* Right column - Doctor Details and Booking Section */}
126 |
127 |
128 |
129 |
130 | About Dr. {doctor.name}
131 |
132 |
133 | Professional background and expertise
134 |
135 |
136 |
137 |
138 |
139 |
140 |
Description
141 |
142 |
143 | {doctor.description}
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
Availability
153 |
154 | {totalSlots > 0 ? (
155 |
156 |
157 |
158 | {totalSlots} time slots available for booking over the next
159 | 4 days
160 |
161 |
162 | ) : (
163 |
164 |
165 |
166 | No available slots for the next 4 days. Please check back
167 | later.
168 |
169 |
170 | )}
171 |
172 |
173 |
174 |
175 | {/* Booking Section - Conditionally rendered */}
176 | {showBooking && (
177 |
178 |
179 |
180 |
181 | Book an Appointment
182 |
183 |
184 | Select a time slot and provide details for your consultation
185 |
186 |
187 |
188 | {totalSlots > 0 ? (
189 | <>
190 | {/* Slot selection step */}
191 | {!selectedSlot && (
192 |
196 | )}
197 |
198 | {/* Appointment form step */}
199 | {selectedSlot && (
200 | setSelectedSlot(null)}
204 | onComplete={handleBookingComplete}
205 | />
206 | )}
207 | >
208 | ) : (
209 |
210 |
211 |
212 | No available slots
213 |
214 |
215 | This doctor doesn't have any available appointment
216 | slots for the next 4 days. Please check back later or try
217 | another doctor.
218 |
219 |
220 | )}
221 |
222 |
223 |
224 | )}
225 |
226 |
227 | );
228 | }
229 |
--------------------------------------------------------------------------------
/app/(main)/onboarding/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { useForm } from "react-hook-form";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import {
8 | Card,
9 | CardContent,
10 | CardDescription,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 | import { Button } from "@/components/ui/button";
14 | import { User, Stethoscope, Loader2 } from "lucide-react";
15 | import { Label } from "@/components/ui/label";
16 | import { Input } from "@/components/ui/input";
17 | import { Textarea } from "@/components/ui/textarea";
18 | import {
19 | Select,
20 | SelectContent,
21 | SelectItem,
22 | SelectTrigger,
23 | SelectValue,
24 | } from "@/components/ui/select";
25 | import { setUserRole } from "@/actions/onboarding";
26 | import { doctorFormSchema } from "@/lib/schema";
27 | import { SPECIALTIES } from "@/lib/specialities";
28 | import useFetch from "@/hooks/use-fetch";
29 | import { useEffect } from "react";
30 |
31 | export default function OnboardingPage() {
32 | const [step, setStep] = useState("choose-role");
33 | const router = useRouter();
34 |
35 | // Custom hook for user role server action
36 | const { loading, data, fn: submitUserRole } = useFetch(setUserRole);
37 |
38 | // React Hook Form setup with Zod validation
39 | const {
40 | register,
41 | handleSubmit,
42 | formState: { errors },
43 | setValue,
44 | watch,
45 | } = useForm({
46 | resolver: zodResolver(doctorFormSchema),
47 | defaultValues: {
48 | specialty: "",
49 | experience: undefined,
50 | credentialUrl: "",
51 | description: "",
52 | },
53 | });
54 |
55 | // Watch specialty value for controlled select component
56 | const specialtyValue = watch("specialty");
57 |
58 | // Handle patient role selection
59 | const handlePatientSelection = async () => {
60 | if (loading) return;
61 |
62 | const formData = new FormData();
63 | formData.append("role", "PATIENT");
64 |
65 | await submitUserRole(formData);
66 | };
67 |
68 | useEffect(() => {
69 | if (data && data?.success) {
70 | router.push(data.redirect);
71 | }
72 | }, [data]);
73 |
74 | // Added missing onDoctorSubmit function
75 | const onDoctorSubmit = async (data) => {
76 | if (loading) return;
77 |
78 | const formData = new FormData();
79 | formData.append("role", "DOCTOR");
80 | formData.append("specialty", data.specialty);
81 | formData.append("experience", data.experience.toString());
82 | formData.append("credentialUrl", data.credentialUrl);
83 | formData.append("description", data.description);
84 |
85 | await submitUserRole(formData);
86 | };
87 |
88 | // Role selection screen
89 | if (step === "choose-role") {
90 | return (
91 |
92 |
!loading && handlePatientSelection()}
95 | >
96 |
97 |
98 |
99 |
100 |
101 | Join as a Patient
102 |
103 |
104 | Book appointments, consult with doctors, and manage your
105 | healthcare journey
106 |
107 |
111 | {loading ? (
112 | <>
113 |
114 | Processing...
115 | >
116 | ) : (
117 | "Continue as Patient"
118 | )}
119 |
120 |
121 |
122 |
123 |
!loading && setStep("doctor-form")}
126 | >
127 |
128 |
129 |
130 |
131 |
132 | Join as a Doctor
133 |
134 |
135 | Create your professional profile, set your availability, and
136 | provide consultations
137 |
138 |
142 | Continue as Doctor
143 |
144 |
145 |
146 |
147 | );
148 | }
149 |
150 | // Doctor registration form
151 | if (step === "doctor-form") {
152 | return (
153 |
154 |
155 |
156 |
157 | Complete Your Doctor Profile
158 |
159 |
160 | Please provide your professional details for verification
161 |
162 |
163 |
164 |
165 |
166 |
Medical Specialty
167 |
setValue("specialty", value)}
170 | >
171 |
172 |
173 |
174 |
175 | {SPECIALTIES.map((spec) => (
176 |
181 | {spec.icon}
182 | {spec.name}
183 |
184 | ))}
185 |
186 |
187 | {errors.specialty && (
188 |
189 | {errors.specialty.message}
190 |
191 | )}
192 |
193 |
194 |
195 |
Years of Experience
196 |
202 | {errors.experience && (
203 |
204 | {errors.experience.message}
205 |
206 | )}
207 |
208 |
209 |
210 |
Link to Credential Document
211 |
217 | {errors.credentialUrl && (
218 |
219 | {errors.credentialUrl.message}
220 |
221 | )}
222 |
223 | Please provide a link to your medical degree or certification
224 |
225 |
226 |
227 |
228 |
Description of Your Services
229 |
235 | {errors.description && (
236 |
237 | {errors.description.message}
238 |
239 | )}
240 |
241 |
242 |
243 | setStep("choose-role")}
247 | className="border-emerald-900/30"
248 | disabled={loading}
249 | >
250 | Back
251 |
252 |
257 | {loading ? (
258 | <>
259 |
260 | Submitting...
261 | >
262 | ) : (
263 | "Submit for Verification"
264 | )}
265 |
266 |
267 |
268 |
269 |
270 | );
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/app/(main)/admin/components/pending-doctors.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import {
5 | Card,
6 | CardContent,
7 | CardDescription,
8 | CardHeader,
9 | CardTitle,
10 | } from "@/components/ui/card";
11 | import { Button } from "@/components/ui/button";
12 | import { Check, X, User, Medal, FileText, ExternalLink } from "lucide-react";
13 | import { Separator } from "@/components/ui/separator";
14 | import {
15 | Dialog,
16 | DialogContent,
17 | DialogDescription,
18 | DialogFooter,
19 | DialogHeader,
20 | DialogTitle,
21 | } from "@/components/ui/dialog";
22 | import { Badge } from "@/components/ui/badge";
23 | import { format } from "date-fns";
24 | import { updateDoctorStatus } from "@/actions/admin";
25 | import useFetch from "@/hooks/use-fetch";
26 | import { useEffect } from "react";
27 | import { BarLoader } from "react-spinners";
28 |
29 | export function PendingDoctors({ doctors }) {
30 | const [selectedDoctor, setSelectedDoctor] = useState(null);
31 |
32 | // Custom hook for approve/reject server action
33 | const {
34 | loading,
35 | data,
36 | fn: submitStatusUpdate,
37 | } = useFetch(updateDoctorStatus);
38 |
39 | // Open doctor details dialog
40 | const handleViewDetails = (doctor) => {
41 | setSelectedDoctor(doctor);
42 | };
43 |
44 | // Close doctor details dialog
45 | const handleCloseDialog = () => {
46 | setSelectedDoctor(null);
47 | };
48 |
49 | // Handle approve or reject doctor
50 | const handleUpdateStatus = async (doctorId, status) => {
51 | if (loading) return;
52 |
53 | const formData = new FormData();
54 | formData.append("doctorId", doctorId);
55 | formData.append("status", status);
56 |
57 | await submitStatusUpdate(formData);
58 | };
59 |
60 | useEffect(() => {
61 | if (data && data?.success) {
62 | handleCloseDialog();
63 | }
64 | }, [data]);
65 |
66 | return (
67 |
68 |
69 |
70 |
71 | Pending Doctor Verifications
72 |
73 |
74 | Review and approve doctor applications
75 |
76 |
77 |
78 | {doctors.length === 0 ? (
79 |
80 | No pending verification requests at this time.
81 |
82 | ) : (
83 |
84 | {doctors.map((doctor) => (
85 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | {doctor.name}
98 |
99 |
100 | {doctor.specialty} • {doctor.experience} years
101 | experience
102 |
103 |
104 |
105 |
106 |
110 | Pending
111 |
112 | handleViewDetails(doctor)}
116 | className="border-emerald-900/30 hover:bg-muted/80"
117 | >
118 | View Details
119 |
120 |
121 |
122 |
123 |
124 | ))}
125 |
126 | )}
127 |
128 |
129 |
130 | {/* Doctor Details Dialog */}
131 | {selectedDoctor && (
132 |
133 |
134 |
135 |
136 | Doctor Verification Details
137 |
138 |
139 | Review the doctor's information carefully before making a
140 | decision
141 |
142 |
143 |
144 |
145 | {/* Basic Info */}
146 |
147 |
148 |
149 | Full Name
150 |
151 |
152 | {selectedDoctor.name}
153 |
154 |
155 |
156 |
157 | Email
158 |
159 |
160 | {selectedDoctor.email}
161 |
162 |
163 |
164 |
165 | Application Date
166 |
167 |
168 | {format(new Date(selectedDoctor.createdAt), "PPP")}
169 |
170 |
171 |
172 |
173 |
174 |
175 | {/* Professional Details */}
176 |
177 |
178 |
179 |
180 | Professional Information
181 |
182 |
183 |
184 |
185 |
186 |
187 | Specialty
188 |
189 |
{selectedDoctor.specialty}
190 |
191 |
192 |
193 |
194 | Years of Experience
195 |
196 |
197 | {selectedDoctor.experience} years
198 |
199 |
200 |
201 |
202 |
203 | Credentials
204 |
205 |
216 |
217 |
218 |
219 |
220 |
221 |
222 | {/* Description */}
223 |
224 |
225 |
226 |
227 | Service Description
228 |
229 |
230 |
231 | {selectedDoctor.description}
232 |
233 |
234 |
235 |
236 | {loading && }
237 |
238 |
239 |
242 | handleUpdateStatus(selectedDoctor.id, "REJECTED")
243 | }
244 | disabled={loading}
245 | className="bg-red-600 hover:bg-red-700"
246 | >
247 |
248 | Reject
249 |
250 |
252 | handleUpdateStatus(selectedDoctor.id, "VERIFIED")
253 | }
254 | disabled={loading}
255 | className="bg-emerald-600 hover:bg-emerald-700"
256 | >
257 |
258 | Approve
259 |
260 |
261 |
262 |
263 | )}
264 |
265 | );
266 | }
267 |
--------------------------------------------------------------------------------
/app/page.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 | import { ArrowRight, Stethoscope } from "lucide-react";
4 | import { Button } from "@/components/ui/button";
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6 | import { Badge } from "@/components/ui/badge";
7 | import Pricing from "@/components/pricing";
8 | import { creditBenefits, features, testimonials } from "@/lib/data";
9 |
10 | export default function Home() {
11 | return (
12 |
13 | {/* Hero Section */}
14 |
15 |
16 |
17 |
18 |
22 | Healthcare made simple
23 |
24 |
25 | Connect with doctors
26 | anytime, anywhere
27 |
28 |
29 | Book appointments, consult via video, and manage your healthcare
30 | journey all in one secure platform.
31 |
32 |
33 |
38 |
39 | Get Started
40 |
41 |
42 |
48 | Find Doctors
49 |
50 |
51 |
52 |
53 |
54 |
61 |
62 |
63 |
64 |
65 |
66 | {/* Features Section */}
67 |
68 |
69 |
70 |
71 | How It Works
72 |
73 |
74 | Our platform makes healthcare accessible with just a few clicks
75 |
76 |
77 |
78 |
79 | {features.map((feature, index) => (
80 |
84 |
85 |
86 | {feature.icon}
87 |
88 |
89 | {feature.title}
90 |
91 |
92 |
93 | {feature.description}
94 |
95 |
96 | ))}
97 |
98 |
99 |
100 |
101 | {/* Pricing Section with green medical styling */}
102 |
103 |
104 |
105 |
109 | Affordable Healthcare
110 |
111 |
112 | Consultation Packages
113 |
114 |
115 | Choose the perfect consultation package that fits your healthcare
116 | needs
117 |
118 |
119 |
120 |
121 | {/* Clerk Pricing Table */}
122 |
123 |
124 | {/* Description */}
125 |
126 |
127 |
128 |
129 | How Our Credit System Works
130 |
131 |
132 |
133 |
134 | {creditBenefits.map((benefit, index) => (
135 |
136 |
152 |
156 |
157 | ))}
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | {/* Testimonials with green medical accents */}
166 |
167 |
168 |
169 |
173 | Success Stories
174 |
175 |
176 | What Our Users Say
177 |
178 |
179 | Hear from patients and doctors who use our platform
180 |
181 |
182 |
183 |
184 | {testimonials.map((testimonial, index) => (
185 |
189 |
190 |
191 |
192 |
193 | {testimonial.initials}
194 |
195 |
196 |
197 |
198 | {testimonial.name}
199 |
200 |
201 | {testimonial.role}
202 |
203 |
204 |
205 |
206 | "{testimonial.quote}"
207 |
208 |
209 |
210 | ))}
211 |
212 |
213 |
214 |
215 | {/* CTA Section with green medical styling */}
216 |
217 |
218 |
219 |
220 |
221 |
222 | Ready to take control of your healthcare?
223 |
224 |
225 | Join thousands of users who have simplified their healthcare
226 | journey with our platform. Get started today and experience
227 | healthcare the way it should be.
228 |
229 |
230 |
235 | Sign Up Now
236 |
237 |
243 | View Pricing
244 |
245 |
246 |
247 |
248 | {/* Decorative healthcare elements */}
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 | );
257 | }
258 |
--------------------------------------------------------------------------------
/app/(main)/video-call/video-call-ui.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef, useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import Script from "next/script";
6 | import { Button } from "@/components/ui/button";
7 | import { Card, CardContent } from "@/components/ui/card";
8 | import {
9 | Loader2,
10 | Video,
11 | VideoOff,
12 | Mic,
13 | MicOff,
14 | PhoneOff,
15 | User,
16 | } from "lucide-react";
17 | import { toast } from "sonner";
18 |
19 | export default function VideoCall({ sessionId, token }) {
20 | const [isLoading, setIsLoading] = useState(true);
21 | const [scriptLoaded, setScriptLoaded] = useState(false);
22 | const [isConnected, setIsConnected] = useState(false);
23 | const [isVideoEnabled, setIsVideoEnabled] = useState(true);
24 | const [isAudioEnabled, setIsAudioEnabled] = useState(true);
25 |
26 | const sessionRef = useRef(null);
27 | const publisherRef = useRef(null);
28 |
29 | const router = useRouter();
30 |
31 | const appId = process.env.NEXT_PUBLIC_VONAGE_APPLICATION_ID;
32 |
33 | // Handle script load
34 | const handleScriptLoad = () => {
35 | setScriptLoaded(true);
36 | if (!window.OT) {
37 | toast.error("Failed to load Vonage Video API");
38 | setIsLoading(false);
39 | return;
40 | }
41 | initializeSession();
42 | };
43 |
44 | // Initialize video session
45 | const initializeSession = () => {
46 | if (!appId || !sessionId || !token) {
47 | toast.error("Missing required video call parameters");
48 | router.push("/appointments");
49 | return;
50 | }
51 |
52 | console.log({ appId, sessionId, token });
53 |
54 | try {
55 | // Initialize the session
56 | sessionRef.current = window.OT.initSession(appId, sessionId);
57 |
58 | // Subscribe to new streams
59 | sessionRef.current.on("streamCreated", (event) => {
60 | console.log(event, "New stream created");
61 |
62 | sessionRef.current.subscribe(
63 | event.stream,
64 | "subscriber",
65 | {
66 | insertMode: "append",
67 | width: "100%",
68 | height: "100%",
69 | },
70 | (error) => {
71 | if (error) {
72 | toast.error("Error connecting to other participant's stream");
73 | }
74 | }
75 | );
76 | });
77 |
78 | // Handle session events
79 | sessionRef.current.on("sessionConnected", () => {
80 | setIsConnected(true);
81 | setIsLoading(false);
82 |
83 | // THIS IS THE FIX - Initialize publisher AFTER session connects
84 | publisherRef.current = window.OT.initPublisher(
85 | "publisher", // This targets the div with id="publisher"
86 | {
87 | insertMode: "replace", // Change from "append" to "replace"
88 | width: "100%",
89 | height: "100%",
90 | publishAudio: isAudioEnabled,
91 | publishVideo: isVideoEnabled,
92 | },
93 | (error) => {
94 | if (error) {
95 | console.error("Publisher error:", error);
96 | toast.error("Error initializing your camera and microphone");
97 | } else {
98 | console.log(
99 | "Publisher initialized successfully - you should see your video now"
100 | );
101 | }
102 | }
103 | );
104 | });
105 |
106 | sessionRef.current.on("sessionDisconnected", () => {
107 | setIsConnected(false);
108 | });
109 |
110 | // Connect to the session
111 | sessionRef.current.connect(token, (error) => {
112 | if (error) {
113 | toast.error("Error connecting to video session");
114 | } else {
115 | // Publish your stream AFTER connecting
116 | if (publisherRef.current) {
117 | sessionRef.current.publish(publisherRef.current, (error) => {
118 | if (error) {
119 | console.log("Error publishing stream:", error);
120 | toast.error("Error publishing your stream");
121 | } else {
122 | console.log("Stream published successfully");
123 | }
124 | });
125 | }
126 | }
127 | });
128 | } catch (error) {
129 | toast.error("Failed to initialize video call");
130 | setIsLoading(false);
131 | }
132 | };
133 |
134 | // Toggle video
135 | const toggleVideo = () => {
136 | if (publisherRef.current) {
137 | publisherRef.current.publishVideo(!isVideoEnabled);
138 | setIsVideoEnabled((prev) => !prev);
139 | }
140 | };
141 |
142 | // Toggle audio
143 | const toggleAudio = () => {
144 | if (publisherRef.current) {
145 | publisherRef.current.publishAudio(!isAudioEnabled);
146 | setIsAudioEnabled((prev) => !prev);
147 | }
148 | };
149 |
150 | // End call
151 | const endCall = () => {
152 | // Properly destroy publisher
153 | if (publisherRef.current) {
154 | publisherRef.current.destroy();
155 | publisherRef.current = null;
156 | }
157 |
158 | // Disconnect session
159 | if (sessionRef.current) {
160 | sessionRef.current.disconnect();
161 | sessionRef.current = null;
162 | }
163 |
164 | router.push("/appointments");
165 | };
166 |
167 | // Cleanup on unmount
168 | useEffect(() => {
169 | return () => {
170 | if (publisherRef.current) {
171 | publisherRef.current.destroy();
172 | }
173 | if (sessionRef.current) {
174 | sessionRef.current.disconnect();
175 | }
176 | };
177 | }, []);
178 |
179 | if (!sessionId || !token || !appId) {
180 | return (
181 |
182 |
183 | Invalid Video Call
184 |
185 |
186 | Missing required parameters for the video call.
187 |
188 |
router.push("/appointments")}
190 | className="bg-emerald-600 hover:bg-emerald-700"
191 | >
192 | Back to Appointments
193 |
194 |
195 | );
196 | }
197 |
198 | return (
199 | <>
200 |