├── public
├── googlec2f92b121acd7f08.html
├── 404.avif
├── 404.png
├── 404.webp
├── logo.png
├── dashboard.png
├── readmeTop.png
├── prescription.png
├── patientDetails.png
├── placeholderIMG.avif
├── placeholderIMG.png
├── placeholderIMG.webp
├── patientsEmptyState.avif
├── patientsEmptyState.png
├── patientsEmptyState.webp
├── placeholderIMGlight.avif
├── placeholderIMGlight.png
├── placeholderIMGlight.webp
├── templateEmptyState.avif
├── templateEmptyState.png
├── templateEmptyState.webp
├── recentSectionEmptyState.avif
├── recentSectionEmptyState.png
├── recentSectionEmptyState.webp
├── recentActivityPageEmptyState.avif
├── recentActivityPageEmptyState.png
├── recentActivityPageEmptyState.webp
└── favicon.svg
├── jsconfig.json
├── postcss.config.mjs
├── next.config.mjs
├── src
├── lib
│ └── utils.js
└── components
│ └── ui
│ └── sonner.jsx
├── components
├── SessionWrapper.js
├── TitleUpdater.js
├── ThemeScript.js
├── StoreDoctorId.js
├── icons
│ └── DocPill.js
├── TitleManager.js
├── PlaceholderImageWithLogo.js
├── ConfirmationDialog.js
├── FluidToggle.js
├── OptimizedImage.js
├── DarkModeToggle.js
├── AuthGuard.js
├── ShareModal.js
├── PillSelector.js
├── CustomDropdown.js
├── KeyGeneratorModal.js
├── SharePDFButton.js
└── CustomSelect.js
├── utils
├── api.js
├── dateUtils.js
├── auth.js
├── whatsapp.js
├── theme.js
├── imageUtils.js
└── billGenerator.js
├── app
├── page.js
├── api
│ ├── auth
│ │ ├── verify-otp
│ │ │ └── route.js
│ │ ├── login
│ │ │ └── route.js
│ │ ├── validate-key
│ │ │ └── route.js
│ │ ├── unlink-google
│ │ │ └── route.js
│ │ ├── generate-key
│ │ │ └── route.js
│ │ ├── refresh
│ │ │ └── route.js
│ │ ├── send-otp
│ │ │ └── route.js
│ │ ├── link-account
│ │ │ └── route.js
│ │ ├── forgot-password
│ │ │ └── route.js
│ │ └── register
│ │ │ └── route.js
│ ├── bills
│ │ ├── patient
│ │ │ └── [patientId]
│ │ │ │ └── route.js
│ │ └── route.js
│ ├── prescriptions
│ │ ├── patient
│ │ │ └── [patientId]
│ │ │ │ └── route.js
│ │ ├── single
│ │ │ └── route.js
│ │ └── route.js
│ ├── activities
│ │ ├── cleanup
│ │ │ └── route.js
│ │ ├── single
│ │ │ └── route.js
│ │ └── route.js
│ ├── templates
│ │ ├── [id]
│ │ │ ├── route.js
│ │ │ └── usage
│ │ │ │ └── route.js
│ │ ├── single
│ │ │ └── route.js
│ │ └── route.js
│ ├── patients
│ │ └── route.js
│ ├── hospital-logo
│ │ ├── route.js
│ │ └── upload
│ │ │ └── route.js
│ ├── doctor
│ │ └── profile
│ │ │ └── route.js
│ ├── custom-data
│ │ └── [type]
│ │ │ └── route.js
│ ├── logout
│ │ └── route.js
│ └── upload-to-drive
│ │ └── route.js
├── layout.js
└── not-found.js
├── components.json
├── .gitignore
├── hooks
├── useScrollToTop.js
└── usePageTitle.js
├── next.config.js
├── LICENSE
├── tailwind.config.js
├── lib
├── constants.js
├── mongodb.js
├── medicalData.js
└── medicationData.js
├── package.json
├── README.md
├── middleware.js
└── services
└── imageOptimizationService.js
/public/googlec2f92b121acd7f08.html:
--------------------------------------------------------------------------------
1 | google-site-verification: googlec2f92b121acd7f08.html
--------------------------------------------------------------------------------
/public/404.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/404.avif
--------------------------------------------------------------------------------
/public/404.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/404.png
--------------------------------------------------------------------------------
/public/404.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/404.webp
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/dashboard.png
--------------------------------------------------------------------------------
/public/readmeTop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/readmeTop.png
--------------------------------------------------------------------------------
/public/prescription.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/prescription.png
--------------------------------------------------------------------------------
/public/patientDetails.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/patientDetails.png
--------------------------------------------------------------------------------
/public/placeholderIMG.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMG.avif
--------------------------------------------------------------------------------
/public/placeholderIMG.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMG.png
--------------------------------------------------------------------------------
/public/placeholderIMG.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMG.webp
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/public/patientsEmptyState.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/patientsEmptyState.avif
--------------------------------------------------------------------------------
/public/patientsEmptyState.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/patientsEmptyState.png
--------------------------------------------------------------------------------
/public/patientsEmptyState.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/patientsEmptyState.webp
--------------------------------------------------------------------------------
/public/placeholderIMGlight.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMGlight.avif
--------------------------------------------------------------------------------
/public/placeholderIMGlight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMGlight.png
--------------------------------------------------------------------------------
/public/placeholderIMGlight.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/placeholderIMGlight.webp
--------------------------------------------------------------------------------
/public/templateEmptyState.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/templateEmptyState.avif
--------------------------------------------------------------------------------
/public/templateEmptyState.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/templateEmptyState.png
--------------------------------------------------------------------------------
/public/templateEmptyState.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/templateEmptyState.webp
--------------------------------------------------------------------------------
/public/recentSectionEmptyState.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentSectionEmptyState.avif
--------------------------------------------------------------------------------
/public/recentSectionEmptyState.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentSectionEmptyState.png
--------------------------------------------------------------------------------
/public/recentSectionEmptyState.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentSectionEmptyState.webp
--------------------------------------------------------------------------------
/public/recentActivityPageEmptyState.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentActivityPageEmptyState.avif
--------------------------------------------------------------------------------
/public/recentActivityPageEmptyState.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentActivityPageEmptyState.png
--------------------------------------------------------------------------------
/public/recentActivityPageEmptyState.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hiprathamesh/doc-prescrip/HEAD/public/recentActivityPageEmptyState.webp
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | serverExternalPackages: ['mongodb'],
4 | };
5 |
6 | export default nextConfig;
7 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/components/SessionWrapper.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { SessionProvider } from 'next-auth/react';
4 |
5 | export default function SessionWrapper({ children }) {
6 | return {children};
7 | }
8 |
--------------------------------------------------------------------------------
/components/TitleUpdater.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import usePageTitle from '../hooks/usePageTitle';
5 |
6 | export default function TitleUpdater({ title }) {
7 | usePageTitle(title);
8 | return null; // This component doesn't render anything
9 | }
--------------------------------------------------------------------------------
/utils/api.js:
--------------------------------------------------------------------------------
1 | /**
2 | * API endpoints configuration
3 | */
4 |
5 | export const API_ENDPOINTS = {
6 | PATIENTS: '/api/patients',
7 | PRESCRIPTIONS: '/api/prescriptions',
8 | BILLS: '/api/bills',
9 | TEMPLATES: '/api/templates',
10 | CUSTOM_DATA: '/api/custom-data',
11 | ACTIVITIES: '/api/activities'
12 | };
--------------------------------------------------------------------------------
/app/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Dashboard from '../components/Dashboard';
4 | import { useEffect } from 'react';
5 | import { initializeTheme } from '../utils/theme';
6 | import usePageTitle from '../hooks/usePageTitle';
7 |
8 |
9 | export default function Home() {
10 | usePageTitle('Dashboard');
11 |
12 | useEffect(() => {
13 | initializeTheme();
14 | }, []);
15 | return ;
16 | }
--------------------------------------------------------------------------------
/app/api/auth/verify-otp/route.js:
--------------------------------------------------------------------------------
1 | // This file is no longer needed - authentication is now handled by NextAuth
2 | // Registration is done directly without OTP verification
3 |
4 | export async function POST(request) {
5 | return new Response(JSON.stringify({
6 | success: false,
7 | error: 'This endpoint is deprecated. Please use NextAuth authentication.'
8 | }), {
9 | status: 410,
10 | headers: { 'Content-Type': 'application/json' }
11 | });
12 | }
--------------------------------------------------------------------------------
/app/api/auth/login/route.js:
--------------------------------------------------------------------------------
1 | // This file is no longer needed - authentication is now handled by NextAuth
2 | // All login functionality has been moved to NextAuth's built-in handlers
3 |
4 | export async function POST(request) {
5 | return new Response(JSON.stringify({
6 | success: false,
7 | error: 'This endpoint is deprecated. Please use NextAuth authentication.'
8 | }), {
9 | status: 410,
10 | headers: { 'Content-Type': 'application/json' }
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
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 | }
--------------------------------------------------------------------------------
/components/ThemeScript.js:
--------------------------------------------------------------------------------
1 | export default function ThemeScript() {
2 | return (
3 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/.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 | TODO.md
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
--------------------------------------------------------------------------------
/utils/dateUtils.js:
--------------------------------------------------------------------------------
1 | import { format, formatDistanceToNow, isValid } from 'date-fns';
2 |
3 | export const formatDate = (date) => {
4 | const d = typeof date === 'string' ? new Date(date) : date;
5 | return isValid(d) ? format(d, 'MMM dd, yyyy') : '-';
6 | };
7 |
8 | export const formatDateTime = (date) => {
9 | const d = typeof date === 'string' ? new Date(date) : date;
10 | return isValid(d) ? format(d, 'MMM dd, yyyy hh:mm a') : '-';
11 | };
12 |
13 | export const formatTimeAgo = (date) => {
14 | const d = typeof date === 'string' ? new Date(date) : date;
15 | return isValid(d) ? formatDistanceToNow(d, { addSuffix: true }) : '-';
16 | };
17 |
18 | export const getTodayString = () => {
19 | return format(new Date(), 'yyyy-MM-dd');
20 | };
--------------------------------------------------------------------------------
/hooks/useScrollToTop.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | /**
4 | * Custom hook that scrolls to top when component mounts or dependencies change
5 | * @param {Array} deps - Dependencies that trigger scroll to top
6 | * @param {boolean} smooth - Whether to use smooth scrolling (default: false for instant)
7 | */
8 | const useScrollToTop = (deps = [], smooth = true) => {
9 | useEffect(() => {
10 | const scrollToTop = () => {
11 | window.scrollTo({
12 | top: 0,
13 | left: 0,
14 | behavior: smooth ? 'smooth' : 'instant'
15 | });
16 | };
17 |
18 | // Small delay to ensure DOM is ready
19 | const timeoutId = setTimeout(scrollToTop, 10);
20 |
21 | return () => clearTimeout(timeoutId);
22 | }, deps);
23 | };
24 |
25 | export default useScrollToTop;
26 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | webpack(config) {
4 | config.module.rules.push({
5 | test: /\.svg$/i,
6 | issuer: /\.[jt]sx?$/,
7 | use: ['@svgr/webpack'],
8 | });
9 | return config;
10 | },
11 | experimental: {
12 | appDir: true,
13 | },
14 | images: {
15 | // Configure Next.js Image component to support modern formats
16 | formats: ['image/avif', 'image/webp'],
17 | deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
18 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
19 | },
20 | // Add headers for better caching of static assets
21 | async headers() {
22 | return [
23 | {
24 | source: '/:all*(svg|jpg|jpeg|png|webp|avif|ico)',
25 | locale: false,
26 | headers: [
27 | {
28 | key: 'Cache-Control',
29 | value: 'public, max-age=31536000, immutable'
30 | }
31 | ],
32 | },
33 | ]
34 | },
35 | }
36 |
37 | module.exports = nextConfig
--------------------------------------------------------------------------------
/app/api/bills/patient/[patientId]/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/bills/patient/[patientId] - Fetch bills for a specific patient
6 | */
7 | export async function GET(request, { params }) {
8 | try {
9 | const { patientId } = await params;
10 | const doctorId = request.headers.get('X-Doctor-ID');
11 |
12 | if (!doctorId || doctorId === 'default-doctor') {
13 | return NextResponse.json(
14 | { success: false, error: 'Invalid doctor context', data: [] },
15 | { status: 403 }
16 | );
17 | }
18 |
19 | const bills = await databaseService.getBillsByPatient(patientId, doctorId);
20 |
21 | return NextResponse.json({ success: true, data: bills || [] });
22 | } catch (error) {
23 | console.error('API Error fetching bills by patient:', error);
24 | return NextResponse.json(
25 | { success: false, error: 'Failed to fetch bills', data: [] },
26 | { status: 500 }
27 | );
28 | }
29 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Prathamesh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/api/prescriptions/patient/[patientId]/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/prescriptions/patient/[patientId] - Fetch prescriptions for a specific patient
6 | */
7 | export async function GET(request, { params }) {
8 | try {
9 | const { patientId } = await params;
10 | const doctorId = request.headers.get('X-Doctor-ID');
11 |
12 | if (!doctorId || doctorId === 'default-doctor') {
13 | return NextResponse.json(
14 | { success: false, error: 'Invalid doctor context', data: [] },
15 | { status: 403 }
16 | );
17 | }
18 |
19 | const prescriptions = await databaseService.getPrescriptionsByPatient(patientId, doctorId);
20 |
21 | return NextResponse.json({ success: true, data: prescriptions || [] });
22 | } catch (error) {
23 | console.error('API Error fetching prescriptions by patient:', error);
24 | return NextResponse.json(
25 | { success: false, error: 'Failed to fetch prescriptions', data: [] },
26 | { status: 500 }
27 | );
28 | }
29 | }
--------------------------------------------------------------------------------
/utils/auth.js:
--------------------------------------------------------------------------------
1 | // filepath: c:\Repos\doc-prescrip\utils\auth.js
2 | import { signOut as nextAuthSignOut } from 'next-auth/react';
3 |
4 | export const logout = async () => {
5 | try {
6 | // First, call our logout API to clear custom cookies
7 | const response = await fetch('/api/logout', {
8 | method: 'POST',
9 | headers: {
10 | 'Content-Type': 'application/json',
11 | },
12 | });
13 |
14 | const data = await response.json();
15 |
16 | // If user has NextAuth session (Google OAuth), use NextAuth signOut
17 | if (data.hasNextAuthSession) {
18 | // Use NextAuth signOut without redirect to prevent loops
19 | await nextAuthSignOut({ redirect: false });
20 | // Then manually redirect after a brief delay
21 | setTimeout(() => {
22 | window.location.href = '/login';
23 | }, 100);
24 | } else {
25 | // For custom JWT auth, just redirect to login
26 | window.location.href = '/login';
27 | }
28 | } catch (error) {
29 | console.error('Logout error:', error);
30 | // Fallback: force redirect to login
31 | window.location.href = '/login';
32 | }
33 | };
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import colors from 'tailwindcss/colors';
2 |
3 | export const darkMode = 'class';
4 | export const content = [
5 | './pages/**/*.{js,ts,jsx,tsx}',
6 | './components/**/*.{js,ts,jsx,tsx}',
7 | './app/**/*.{js,ts,jsx,tsx}',
8 | ];
9 | export const theme = {
10 | extend: {
11 | colors: {
12 | background: 'var(--background)',
13 | foreground: 'var(--foreground)',
14 | primary: 'var(--primary)',
15 | // Dark theme specific colors
16 | 'dark-bg': {
17 | primary: '#0f172a', // slate-900
18 | secondary: '#1e293b', // slate-800
19 | tertiary: '#334155', // slate-700
20 | },
21 | 'dark-surface': {
22 | primary: '#1e293b', // slate-800
23 | secondary: '#334155', // slate-700
24 | tertiary: '#475569', // slate-600
25 | },
26 | 'dark-border': {
27 | primary: '#334155', // slate-700
28 | secondary: '#475569', // slate-600
29 | }
30 | },
31 | },
32 | };
33 | export const plugins = [];
34 |
--------------------------------------------------------------------------------
/lib/constants.js:
--------------------------------------------------------------------------------
1 | export const MEDICAL_CONDITIONS = [
2 | 'Alcohol Consumption',
3 | 'Smoking',
4 | 'Kidney Stone',
5 | 'Diabetes',
6 | 'Hypertension',
7 | 'Heart Disease',
8 | 'Asthma',
9 | 'Thyroid',
10 | 'High Cholesterol',
11 | 'Osteoporosis'
12 | ];
13 |
14 | export const SEVERITY_OPTIONS = [
15 | { value: 'mild', label: 'Mild' },
16 | { value: 'moderate', label: 'Moderate' },
17 | { value: 'severe', label: 'Severe' }
18 | ];
19 |
20 | export const DURATION_OPTIONS = [
21 | '1-2 days',
22 | '3-7 days',
23 | '1-2 weeks',
24 | '2-4 weeks',
25 | '1-3 months',
26 | '3-6 months',
27 | '6+ months'
28 | ];
29 |
30 | export const MEDICATION_TIMING = [
31 | { value: 'before_meal', label: 'Before Meal' },
32 | { value: 'after_meal', label: 'After Meal' },
33 | { value: 'with_meal', label: 'With Meal' }
34 | ];
35 |
36 | // Add new duration options for medications
37 | export const MEDICATION_DURATION_OPTIONS = [
38 | '3 days',
39 | '5 days',
40 | '7 days',
41 | '10 days',
42 | '14 days',
43 | '21 days',
44 | '1 month',
45 | '2 months',
46 | '3 months',
47 | '6 months',
48 | 'As needed',
49 | 'Continuous'
50 | ];
--------------------------------------------------------------------------------
/app/api/activities/cleanup/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * DELETE /api/activities/cleanup - Clean up old activities
6 | */
7 | export async function DELETE(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context' },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const url = new URL(request.url);
19 | const cutoffDays = parseInt(url.searchParams.get('days')) || 30;
20 |
21 | const deletedCount = await databaseService.clearOldActivities(doctorId, cutoffDays);
22 |
23 | return NextResponse.json({
24 | success: true,
25 | deletedCount: deletedCount || 0
26 | });
27 | } catch (error) {
28 | console.error('API Error cleaning up activities:', error);
29 | return NextResponse.json(
30 | { success: false, error: error.message || 'Failed to cleanup activities' },
31 | { status: 500 }
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/api/templates/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * DELETE /api/templates/[templateId] - Delete a specific template
6 | */
7 | export async function DELETE(request, { params }) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 | if (!doctorId) {
11 | return NextResponse.json(
12 | { success: false, error: 'Doctor ID is required' },
13 | { status: 400 }
14 | );
15 | }
16 |
17 | const { id: templateId } = await params;
18 | const success = await databaseService.deleteTemplate(templateId, doctorId);
19 |
20 | if (success) {
21 | return NextResponse.json({ success: true });
22 | } else {
23 | return NextResponse.json(
24 | { success: false, error: 'Failed to delete template' },
25 | { status: 500 }
26 | );
27 | }
28 | } catch (error) {
29 | console.error('API Error deleting template:', error);
30 | return NextResponse.json(
31 | { success: false, error: 'Failed to delete template' },
32 | { status: 500 }
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/layout.js:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google';
2 | import './globals.css';
3 | import SessionWrapper from '../components/SessionWrapper';
4 | import { Toaster } from "@/components/ui/sonner"
5 | import ThemeScript from '../components/ThemeScript';
6 | import StoreDoctorId from '../components/StoreDoctorId';
7 | import AuthGuard from '../components/AuthGuard';
8 | import TitleManager from '../components/TitleManager';
9 |
10 | const inter = Inter({ subsets: ['latin'] });
11 |
12 | export const metadata = {
13 | title: 'Doc Prescrip',
14 | description: 'Comprehensive web app for medical practice management',
15 | icons: {
16 | icon: '/favicon.svg',
17 | },
18 | };
19 |
20 | export default function RootLayout({ children }) {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "doc-prescip",
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 | },
11 | "dependencies": {
12 | "@auth/mongodb-adapter": "^3.10.0",
13 | "@upstash/redis": "^1.35.0",
14 | "bcryptjs": "^3.0.2",
15 | "class-variance-authority": "^0.7.1",
16 | "clsx": "^2.1.1",
17 | "date-fns": "^4.1.0",
18 | "dompurify": "^3.2.6",
19 | "googleapis": "^150.0.1",
20 | "html2canvas": "^1.4.1",
21 | "ioredis": "^5.6.1",
22 | "jose": "^6.0.11",
23 | "jsonwebtoken": "^9.0.2",
24 | "jspdf": "^3.0.1",
25 | "lucide-react": "^0.511.0",
26 | "mongodb": "^6.18.0",
27 | "next": "^15.4.5",
28 | "next-auth": "^4.24.11",
29 | "next-themes": "^0.4.6",
30 | "react": "^19.0.0",
31 | "react-dom": "^19.0.0",
32 | "resend": "^4.6.0",
33 | "sonner": "^2.0.5",
34 | "tailwind-merge": "^3.3.1",
35 | "zod": "^4.0.5"
36 | },
37 | "devDependencies": {
38 | "@svgr/webpack": "^8.1.0",
39 | "@tailwindcss/postcss": "^4",
40 | "tailwindcss": "^4",
41 | "tw-animate-css": "^1.3.4"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/api/templates/[id]/usage/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * PATCH /api/templates/[templateId]/usage - Update template last used timestamp
6 | */
7 | export async function PATCH(request, { params }) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 | if (!doctorId) {
11 | return NextResponse.json(
12 | { success: false, error: 'Doctor ID is required' },
13 | { status: 400 }
14 | );
15 | }
16 |
17 | const { id: templateId } = await params;
18 | const { lastUsed } = await request.json();
19 |
20 | const success = await databaseService.updateTemplateUsage(templateId, lastUsed, doctorId);
21 |
22 | if (success) {
23 | return NextResponse.json({ success: true });
24 | } else {
25 | return NextResponse.json(
26 | { success: false, error: 'Failed to update template usage' },
27 | { status: 500 }
28 | );
29 | }
30 | } catch (error) {
31 | console.error('API Error updating template usage:', error);
32 | return NextResponse.json(
33 | { success: false, error: 'Failed to update template usage' },
34 | { status: 500 }
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/hooks/usePageTitle.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { useSession } from 'next-auth/react';
5 | import { storage } from '../utils/storage';
6 |
7 | export default function usePageTitle(pageTitle = '') {
8 | const { data: session } = useSession();
9 |
10 | useEffect(() => {
11 | if (!pageTitle) return; // Don't do anything if no page title is provided
12 |
13 | const updateTitle = async () => {
14 | let title = '';
15 |
16 | // Get doctor information if user is logged in
17 | let doctorName = '';
18 | if (session?.user) {
19 | try {
20 | const doctorContext = storage.getDoctorContext();
21 | if (doctorContext?.lastName) {
22 | doctorName = `Dr. ${doctorContext.lastName}`;
23 | }
24 | } catch (error) {
25 | console.error('Error fetching doctor context for title:', error);
26 | }
27 | }
28 |
29 | // Build title based on page and authentication state
30 | if (doctorName) {
31 | title = `${pageTitle} | ${doctorName} | Doc Prescrip`;
32 | } else {
33 | title = `${pageTitle} | Doc Prescrip`;
34 | }
35 |
36 | document.title = title;
37 | };
38 |
39 | updateTitle();
40 | }, [pageTitle, session]);
41 | }
--------------------------------------------------------------------------------
/app/api/auth/validate-key/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export async function POST(request) {
4 | // Redirect HTTP to HTTPS (only in production)
5 | if (process.env.NODE_ENV === 'production' && request.headers.get('x-forwarded-proto') === 'http') {
6 | return NextResponse.redirect(`https://${request.headers.get('host')}${request.url}`, 308);
7 | }
8 |
9 | try {
10 | const { accessKey } = await request.json();
11 |
12 | if (!accessKey || !accessKey.trim()) {
13 | return NextResponse.json(
14 | { success: false, error: 'Access key is required' },
15 | { status: 400 }
16 | );
17 | }
18 |
19 | const { doctorService } = await import('../../../../services/doctorService');
20 |
21 | // Validate the access key
22 | const isValid = await doctorService.validateRegistrationKey(accessKey.trim());
23 |
24 | return NextResponse.json({
25 | success: true,
26 | isValid,
27 | message: isValid ? 'Access key is valid' : 'Access key is invalid or already used'
28 | });
29 |
30 | } catch (error) {
31 | console.error('Access key validation error:', error);
32 | return NextResponse.json(
33 | { success: false, error: 'Internal server error' },
34 | { status: 500 }
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/utils/whatsapp.js:
--------------------------------------------------------------------------------
1 | export const sendWhatsApp = async (phoneNumber, message) => {
2 | // Format phone number (remove any non-digits and add country code if needed)
3 | const cleanPhone = phoneNumber.replace(/\D/g, '');
4 | const formattedPhone = cleanPhone.startsWith('91') ? cleanPhone : `91${cleanPhone}`;
5 |
6 | // Encode the message for URL
7 | const encodedMessage = encodeURIComponent(message);
8 |
9 | // Create WhatsApp URL
10 | const whatsappUrl = `https://wa.me/${formattedPhone}?text=${encodedMessage}`;
11 |
12 | // Open WhatsApp in new tab
13 | window.open(whatsappUrl, '_blank');
14 | };
15 |
16 | export const generateWhatsAppMessage = (patientName, visitDate) => {
17 | return `Hello ${patientName},
18 |
19 | Your prescription from your visit on ${visitDate} is ready. Please find the attached prescription document.
20 |
21 | If you have any questions, please feel free to contact us.
22 |
23 | Best regards,
24 | Dr. Prashant Nikam`;
25 | };
26 |
27 | export const generateBillWhatsAppMessage = (patientName, billDate, amount, isPaid) => {
28 | return `Hello ${patientName},
29 |
30 | Your bill for the consultation on ${billDate} is ready.
31 |
32 | Amount: ₹${amount}
33 | Status: ${isPaid ? 'Paid' : 'Pending'}
34 |
35 | Thank you for visiting us.
36 |
37 | Best regards,
38 | Dr. Prashant Nikam`;
39 | };
--------------------------------------------------------------------------------
/app/api/templates/single/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * POST /api/templates/single - Save or update a single template
6 | */
7 | export async function POST(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 | if (!doctorId) {
11 | return NextResponse.json(
12 | { success: false, error: 'Doctor ID is required' },
13 | { status: 400 }
14 | );
15 | }
16 |
17 | const { template } = await request.json();
18 |
19 | // Ensure template has a templateId
20 | if (!template.templateId) {
21 | template.templateId = `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
22 | }
23 |
24 | const savedTemplate = await databaseService.saveTemplate(template, doctorId);
25 |
26 | if (savedTemplate) {
27 | return NextResponse.json({ success: true, data: savedTemplate });
28 | } else {
29 | return NextResponse.json(
30 | { success: false, error: 'Failed to save template' },
31 | { status: 500 }
32 | );
33 | }
34 | } catch (error) {
35 | console.error('API Error saving single template:', error);
36 | return NextResponse.json(
37 | { success: false, error: 'Failed to save template' },
38 | { status: 500 }
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/utils/theme.js:
--------------------------------------------------------------------------------
1 | // utils/theme.js or inside a useEffect
2 | export const toggleDarkMode = () => {
3 | const html = document.documentElement;
4 |
5 | if (html.classList.contains('dark')) {
6 | html.classList.remove('dark');
7 | localStorage.setItem('theme', 'light');
8 | } else {
9 | html.classList.add('dark');
10 | localStorage.setItem('theme', 'dark');
11 | }
12 | };
13 |
14 | export const getInitialTheme = () => {
15 | if (typeof window === 'undefined') return false;
16 |
17 | const savedTheme = localStorage.getItem('theme');
18 | return savedTheme === 'dark' ||
19 | (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
20 | };
21 |
22 | export const initializeThemeSync = () => {
23 | if (typeof window === 'undefined') return false;
24 |
25 | const isDark = getInitialTheme();
26 | if (isDark) {
27 | document.documentElement.classList.add('dark');
28 | } else {
29 | document.documentElement.classList.remove('dark');
30 | }
31 | return isDark;
32 | };
33 |
34 | export const initializeTheme = () => {
35 | const savedTheme = localStorage.getItem('theme');
36 | if (
37 | savedTheme === 'dark' ||
38 | (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
39 | ) {
40 | document.documentElement.classList.add('dark');
41 | } else {
42 | document.documentElement.classList.remove('dark');
43 | }
44 | };
45 |
--------------------------------------------------------------------------------
/app/api/prescriptions/single/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * POST /api/prescriptions/single - Save a single prescription
6 | */
7 | export async function POST(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context' },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const body = await request.json();
19 |
20 | if (!body.prescription) {
21 | return NextResponse.json(
22 | { success: false, error: 'Prescription data is required' },
23 | { status: 400 }
24 | );
25 | }
26 |
27 | const savedPrescription = await databaseService.savePrescription(body.prescription, doctorId);
28 |
29 | if (savedPrescription) {
30 | return NextResponse.json({ success: true, data: savedPrescription });
31 | } else {
32 | return NextResponse.json(
33 | { success: false, error: 'Failed to save prescription' },
34 | { status: 500 }
35 | );
36 | }
37 | } catch (error) {
38 | console.error('API Error saving single prescription:', error);
39 | return NextResponse.json(
40 | { success: false, error: error.message || 'Failed to save prescription' },
41 | { status: 500 }
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/api/activities/single/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * POST /api/activities/single - Save a single activity
6 | */
7 | export async function POST(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context' },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const body = await request.json();
19 |
20 | if (!body.activity) {
21 | return NextResponse.json(
22 | { success: false, error: 'Activity data is required' },
23 | { status: 400 }
24 | );
25 | }
26 |
27 | // Ensure the activity has the correct doctorId
28 | body.activity.doctorId = doctorId;
29 |
30 | const savedActivity = await databaseService.saveActivity(body.activity, doctorId);
31 |
32 | if (savedActivity) {
33 | return NextResponse.json({ success: true, data: savedActivity });
34 | } else {
35 | return NextResponse.json(
36 | { success: false, error: 'Failed to save activity' },
37 | { status: 500 }
38 | );
39 | }
40 | } catch (error) {
41 | console.error('API Error saving activity:', error);
42 | return NextResponse.json(
43 | { success: false, error: error.message || 'Failed to save activity' },
44 | { status: 500 }
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/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 |
39 | );
40 | }
41 |
42 | export { Toaster }
43 |
--------------------------------------------------------------------------------
/app/api/auth/unlink-google/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getToken } from 'next-auth/jwt';
3 | import clientPromise from '../../../../lib/mongodb';
4 |
5 | export async function POST(request) {
6 | try {
7 | // Get NextAuth token
8 | const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
9 |
10 | if (!token || !token.email) {
11 | return NextResponse.json(
12 | { success: false, error: 'Not authenticated' },
13 | { status: 401 }
14 | );
15 | }
16 |
17 | const client = await clientPromise;
18 | const db = client.db('doc-prescrip');
19 | const doctors = db.collection('doctors');
20 |
21 | const doctor = await doctors.findOne({ email: token.email });
22 |
23 | if (!doctor) {
24 | return NextResponse.json(
25 | { success: false, error: 'Doctor not found' },
26 | { status: 404 }
27 | );
28 | }
29 |
30 | // Remove Google-specific information
31 | await doctors.updateOne(
32 | { email: token.email },
33 | {
34 | $unset: {
35 | googleId: "",
36 | isGoogleUser: ""
37 | },
38 | $set: {
39 | updatedAt: new Date()
40 | }
41 | }
42 | );
43 |
44 | return NextResponse.json({
45 | success: true,
46 | message: 'Google account disconnected successfully'
47 | });
48 |
49 | } catch (error) {
50 | console.error('Google unlinking error:', error);
51 | return NextResponse.json(
52 | { success: false, error: 'Internal server error' },
53 | { status: 500 }
54 | );
55 | }
56 | }
--------------------------------------------------------------------------------
/app/api/auth/generate-key/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export async function POST(request) {
4 | // Redirect HTTP to HTTPS (only in production)
5 | if (process.env.NODE_ENV === 'production' && request.headers.get('x-forwarded-proto') === 'http') {
6 | return NextResponse.redirect(`https://${request.headers.get('host')}${request.url}`, 308);
7 | }
8 |
9 | try {
10 | const { password } = await request.json();
11 |
12 | if (!password) {
13 | return NextResponse.json(
14 | { success: false, error: 'Password is required' },
15 | { status: 400 }
16 | );
17 | }
18 |
19 | const { doctorService } = await import('../../../../services/doctorService');
20 |
21 | // Initialize default doctor if needed
22 | await doctorService.initializeDefaultDoctor();
23 |
24 | // Verify admin password
25 | const isValidAdmin = await doctorService.validateAdminPassword(password);
26 |
27 | if (!isValidAdmin) {
28 | return NextResponse.json(
29 | { success: false, error: 'Invalid admin password' },
30 | { status: 401 }
31 | );
32 | }
33 |
34 | // Generate new key
35 | const newKey = await doctorService.generateRegistrationKey();
36 |
37 | if (newKey) {
38 | return NextResponse.json({
39 | success: true,
40 | key: newKey,
41 | message: 'Registration key generated successfully'
42 | });
43 | } else {
44 | return NextResponse.json(
45 | { success: false, error: 'Failed to generate key' },
46 | { status: 500 }
47 | );
48 | }
49 | } catch (error) {
50 | console.error('Key generation error:', error);
51 | return NextResponse.json(
52 | { success: false, error: 'Internal server error' },
53 | { status: 500 }
54 | );
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/api/activities/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/activities - Fetch all activities for a doctor
6 | */
7 | export async function GET(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context', data: [] },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const activities = await databaseService.getActivities(doctorId);
19 | return NextResponse.json({ success: true, data: activities || [] });
20 | } catch (error) {
21 | console.error('API Error fetching activities:', error);
22 | return NextResponse.json(
23 | { success: false, error: 'Failed to fetch activities', data: [] },
24 | { status: 500 }
25 | );
26 | }
27 | }
28 |
29 | /**
30 | * DELETE /api/activities - Clear all activities for a doctor
31 | */
32 | export async function DELETE(request) {
33 | try {
34 | const doctorId = request.headers.get('X-Doctor-ID');
35 |
36 | if (!doctorId || doctorId === 'default-doctor') {
37 | return NextResponse.json(
38 | { success: false, error: 'Invalid doctor context' },
39 | { status: 403 }
40 | );
41 | }
42 |
43 | const deletedCount = await databaseService.clearAllActivities(doctorId);
44 |
45 | return NextResponse.json({
46 | success: true,
47 | deletedCount: deletedCount || 0
48 | });
49 | } catch (error) {
50 | console.error('API Error clearing all activities:', error);
51 | return NextResponse.json(
52 | { success: false, error: error.message || 'Failed to clear all activities' },
53 | { status: 500 }
54 | );
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/lib/mongodb.js:
--------------------------------------------------------------------------------
1 | import { MongoClient } from 'mongodb';
2 |
3 | const uri = process.env.MONGODB_URI;
4 | const options = {
5 | maxPoolSize: 10,
6 | serverSelectionTimeoutMS: 5000,
7 | socketTimeoutMS: 45000,
8 | };
9 |
10 | let client;
11 | let clientPromise;
12 |
13 | if (!process.env.MONGODB_URI) {
14 | throw new Error('Please add your Mongo URI to .env.local');
15 | }
16 |
17 | if (process.env.NODE_ENV === 'development') {
18 | if (!global._mongoClientPromise) {
19 | client = new MongoClient(uri, options);
20 | global._mongoClientPromise = client.connect();
21 | }
22 | clientPromise = global._mongoClientPromise;
23 | } else {
24 | client = new MongoClient(uri, options);
25 | clientPromise = client.connect();
26 | }
27 |
28 | export default clientPromise;
29 |
30 | /**
31 | * Get MongoDB database instance
32 | * @returns {Promise} MongoDB database instance
33 | */
34 | export async function getDatabase() {
35 | try {
36 | const client = await clientPromise;
37 | const db = client.db(process.env.MONGODB_DB_NAME || 'doc-prescrip');
38 |
39 | // Test the connection
40 | await db.admin().ping();
41 |
42 | return db;
43 | } catch (error) {
44 | console.error('Database connection error:', error);
45 | throw new Error(`Database connection failed: ${error.message}`);
46 | }
47 | }
48 |
49 | /**
50 | * Get a specific collection from the database
51 | * @param {string} collectionName - Name of the collection
52 | * @returns {Promise} MongoDB collection instance
53 | */
54 | export async function getCollection(collectionName) {
55 | try {
56 | const db = await getDatabase();
57 | return db.collection(collectionName);
58 | } catch (error) {
59 | console.error(`Error getting collection ${collectionName}:`, error);
60 | throw new Error(`Failed to get collection ${collectionName}: ${error.message}`);
61 | }
62 | }
--------------------------------------------------------------------------------
/app/api/bills/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/bills - Fetch all bills
6 | */
7 | export async function GET(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context', data: [] },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const bills = await databaseService.getBills(doctorId);
19 | return NextResponse.json({ success: true, data: bills || [] });
20 | } catch (error) {
21 | console.error('API Error fetching bills:', error);
22 | return NextResponse.json(
23 | { success: false, error: 'Failed to fetch bills', data: [] },
24 | { status: 500 }
25 | );
26 | }
27 | }
28 |
29 | /**
30 | * POST /api/bills - Save bills
31 | */
32 | export async function POST(request) {
33 | try {
34 | const doctorId = request.headers.get('X-Doctor-ID');
35 |
36 | if (!doctorId || doctorId === 'default-doctor') {
37 | return NextResponse.json(
38 | { success: false, error: 'Invalid doctor context' },
39 | { status: 403 }
40 | );
41 | }
42 |
43 | const body = await request.json();
44 |
45 | if (!body.bills || !Array.isArray(body.bills)) {
46 | return NextResponse.json(
47 | { success: false, error: 'Invalid bills data provided' },
48 | { status: 400 }
49 | );
50 | }
51 |
52 | const { bills } = body;
53 | const success = await databaseService.saveBills(bills, doctorId);
54 |
55 | if (success) {
56 | return NextResponse.json({ success: true, message: 'Bills saved successfully' });
57 | } else {
58 | return NextResponse.json(
59 | { success: false, error: 'Failed to save bills to database' },
60 | { status: 500 }
61 | );
62 | }
63 | } catch (error) {
64 | console.error('API Error saving bills:', error);
65 | return NextResponse.json(
66 | { success: false, error: error.message || 'Failed to save bills' },
67 | { status: 500 }
68 | );
69 | }
70 | }
--------------------------------------------------------------------------------
/app/api/patients/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/patients - Fetch all patients
6 | */
7 | export async function GET(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context', data: [] },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const patients = await databaseService.getPatients(doctorId);
19 | return NextResponse.json({ success: true, data: patients });
20 | } catch (error) {
21 | console.error('API Error fetching patients:', error);
22 | return NextResponse.json(
23 | { success: false, error: 'Failed to fetch patients' },
24 | { status: 500 }
25 | );
26 | }
27 | }
28 |
29 | /**
30 | * POST /api/patients - Save patients
31 | */
32 | export async function POST(request) {
33 | try {
34 | const doctorId = request.headers.get('X-Doctor-ID');
35 |
36 | if (!doctorId || doctorId === 'default-doctor') {
37 | return NextResponse.json(
38 | { success: false, error: 'Invalid doctor context' },
39 | { status: 403 }
40 | );
41 | }
42 |
43 | const body = await request.json();
44 |
45 | if (!body.patients || !Array.isArray(body.patients)) {
46 | return NextResponse.json(
47 | { success: false, error: 'Invalid patients data provided' },
48 | { status: 400 }
49 | );
50 | }
51 |
52 | const { patients } = body;
53 | const success = await databaseService.savePatients(patients, doctorId);
54 |
55 | if (success) {
56 | return NextResponse.json({ success: true, message: 'Patients saved successfully' });
57 | } else {
58 | return NextResponse.json(
59 | { success: false, error: 'Failed to save patients to database' },
60 | { status: 500 }
61 | );
62 | }
63 | } catch (error) {
64 | console.error('API Error saving patients:', error);
65 | return NextResponse.json(
66 | { success: false, error: error.message || 'Failed to save patients' },
67 | { status: 500 }
68 | );
69 | }
70 | }
--------------------------------------------------------------------------------
/app/api/hospital-logo/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function GET(request) {
5 | try {
6 | const { searchParams } = new URL(request.url);
7 | const doctorId = searchParams.get('doctorId') || request.headers.get('X-Doctor-ID');
8 |
9 | if (!doctorId) {
10 | return NextResponse.json(
11 | { success: false, error: 'Doctor ID is required' },
12 | { status: 400 }
13 | );
14 | }
15 |
16 | const logoDocument = await databaseService.getHospitalLogo(doctorId);
17 |
18 | if (logoDocument) {
19 | return NextResponse.json({
20 | success: true,
21 | logoData: {
22 | base64: logoDocument.logoBase64,
23 | fileName: logoDocument.fileName,
24 | fileSize: logoDocument.fileSize,
25 | mimeType: logoDocument.mimeType,
26 | uploadedAt: logoDocument.uploadedAt
27 | }
28 | });
29 | } else {
30 | return NextResponse.json({
31 | success: false,
32 | error: 'No logo found for this doctor'
33 | }, { status: 404 });
34 | }
35 | } catch (error) {
36 | console.error('Error fetching hospital logo:', error);
37 | return NextResponse.json(
38 | { success: false, error: 'Failed to fetch logo' },
39 | { status: 500 }
40 | );
41 | }
42 | }
43 |
44 | export async function DELETE(request) {
45 | try {
46 | const doctorId = request.headers.get('X-Doctor-ID');
47 | if (!doctorId) {
48 | return NextResponse.json(
49 | { success: false, error: 'Doctor ID is required' },
50 | { status: 400 }
51 | );
52 | }
53 |
54 | const success = await databaseService.deleteHospitalLogo(doctorId);
55 |
56 | if (success) {
57 | return NextResponse.json({ success: true });
58 | } else {
59 | return NextResponse.json(
60 | { success: false, error: 'Failed to delete logo or logo not found' },
61 | { status: 500 }
62 | );
63 | }
64 | } catch (error) {
65 | console.error('Error deleting hospital logo:', error);
66 | return NextResponse.json(
67 | { success: false, error: 'Failed to delete logo' },
68 | { status: 500 }
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/api/templates/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/templates - Fetch all templates
6 | */
7 | export async function GET(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 | if (!doctorId) {
11 | return NextResponse.json(
12 | { success: false, error: 'Doctor ID is required' },
13 | { status: 400 }
14 | );
15 | }
16 |
17 | const templates = await databaseService.getTemplates(doctorId);
18 | // Ensure all templates have templateId
19 | const templatesWithId = templates.map((template) => ({
20 | ...template,
21 | templateId:
22 | template.templateId ||
23 | template.id ||
24 | `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
25 | }));
26 | return NextResponse.json({ success: true, data: templatesWithId });
27 | } catch (error) {
28 | console.error('API Error fetching templates:', error);
29 | return NextResponse.json(
30 | { success: false, error: 'Failed to fetch templates' },
31 | { status: 500 }
32 | );
33 | }
34 | }
35 |
36 | /**
37 | * POST /api/templates - Save templates
38 | */
39 | export async function POST(request) {
40 | try {
41 | const doctorId = request.headers.get('X-Doctor-ID');
42 | if (!doctorId) {
43 | return NextResponse.json(
44 | { success: false, error: 'Doctor ID is required' },
45 | { status: 400 }
46 | );
47 | }
48 |
49 | const { templates } = await request.json();
50 |
51 | // Ensure all templates have templateId
52 | const templatesWithId = templates.map((template) => ({
53 | ...template,
54 | templateId:
55 | template.templateId ||
56 | `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
57 | }));
58 |
59 | const success = await databaseService.saveTemplates(templatesWithId, doctorId);
60 |
61 | if (success) {
62 | return NextResponse.json({ success: true });
63 | } else {
64 | return NextResponse.json(
65 | { success: false, error: 'Failed to save templates' },
66 | { status: 500 }
67 | );
68 | }
69 | } catch (error) {
70 | console.error('API Error saving templates:', error);
71 | return NextResponse.json(
72 | { success: false, error: 'Failed to save templates' },
73 | { status: 500 }
74 | );
75 | }
76 | }
--------------------------------------------------------------------------------
/app/api/hospital-logo/upload/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
5 | const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/avif'];
6 |
7 | export async function POST(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 | if (!doctorId) {
11 | return NextResponse.json(
12 | { success: false, error: 'Doctor ID is required' },
13 | { status: 400 }
14 | );
15 | }
16 |
17 | const formData = await request.formData();
18 | const file = formData.get('logo');
19 |
20 | if (!file) {
21 | return NextResponse.json(
22 | { success: false, error: 'No logo file provided' },
23 | { status: 400 }
24 | );
25 | }
26 |
27 | // Validate file type
28 | if (!ALLOWED_TYPES.includes(file.type)) {
29 | return NextResponse.json(
30 | { success: false, error: 'Invalid file type. Only PNG, JPEG, JPG, WebP, and AVIF are allowed.' },
31 | { status: 400 }
32 | );
33 | }
34 |
35 | // Validate file size
36 | if (file.size > MAX_FILE_SIZE) {
37 | return NextResponse.json(
38 | { success: false, error: 'File size too large. Maximum size is 5MB.' },
39 | { status: 400 }
40 | );
41 | }
42 |
43 | // Convert file to base64
44 | const buffer = Buffer.from(await file.arrayBuffer());
45 | const base64 = buffer.toString('base64');
46 | const dataUrl = `data:${file.type};base64,${base64}`;
47 |
48 | const logoData = {
49 | base64: dataUrl,
50 | fileName: file.name,
51 | fileSize: file.size,
52 | mimeType: file.type
53 | };
54 |
55 | // Save to database
56 | const success = await databaseService.saveHospitalLogo(doctorId, logoData);
57 |
58 | if (success) {
59 | return NextResponse.json({
60 | success: true,
61 | logoData: {
62 | fileName: logoData.fileName,
63 | fileSize: logoData.fileSize,
64 | mimeType: logoData.mimeType
65 | }
66 | });
67 | } else {
68 | return NextResponse.json(
69 | { success: false, error: 'Failed to save logo to database' },
70 | { status: 500 }
71 | );
72 | }
73 | } catch (error) {
74 | console.error('Error uploading hospital logo:', error);
75 | return NextResponse.json(
76 | { success: false, error: 'Failed to upload logo' },
77 | { status: 500 }
78 | );
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/components/StoreDoctorId.js:
--------------------------------------------------------------------------------
1 | // components/StoreDoctorId.js
2 | 'use client'
3 |
4 | import { useEffect } from 'react'
5 | import { useSession } from 'next-auth/react'
6 | import { storage } from '../utils/storage'
7 |
8 | export default function StoreDoctorId() {
9 | const { data: session, status } = useSession()
10 |
11 | useEffect(() => {
12 | if (status === 'authenticated' && session?.user?.doctorId) {
13 | try {
14 | // Always store/update the doctor context for Google authenticated users
15 | // This ensures localStorage is immediately available for other components
16 | storage.setCurrentDoctor(session.user.doctorId, {
17 | name: session.user.doctorContext?.name || session.user.name || 'Dr. Nikam',
18 | firstName: session.user.doctorContext?.firstName || session.user.name?.split(' ')[0] || 'Dr.',
19 | lastName: session.user.doctorContext?.lastName || session.user.name?.split(' ').pop() || 'Nikam',
20 | accessType: session.user.doctorContext?.accessType || 'doctor',
21 | phone: session.user.doctorContext?.phone || '',
22 | degree: session.user.doctorContext?.degree || '',
23 | registrationNumber: session.user.doctorContext?.registrationNumber || '',
24 | hospitalName: session.user.doctorContext?.hospitalName || 'Chaitanya Hospital',
25 | hospitalAddress: session.user.doctorContext?.hospitalAddress || 'Deola, Maharashtra'
26 | });
27 |
28 | console.log('✅ Google login: Stored doctorId in localStorage:', session.user.doctorId);
29 |
30 | // Dispatch a custom event to notify other components that doctor context is ready
31 | // Use setTimeout to ensure it's dispatched after localStorage is set
32 | setTimeout(() => {
33 | window.dispatchEvent(new CustomEvent('doctorContextReady', {
34 | detail: { doctorId: session.user.doctorId }
35 | }));
36 | console.log('🔔 Dispatched doctorContextReady event');
37 | }, 100);
38 |
39 | } catch (error) {
40 | console.error('❌ Error storing doctor context from Google login:', error);
41 | }
42 | } else if (status === 'unauthenticated') {
43 | // Clear doctor context on logout
44 | try {
45 | storage.clearDoctorContext();
46 | console.log('🧹 Cleared doctor context on logout');
47 | } catch (error) {
48 | console.error('❌ Error clearing doctor context:', error);
49 | }
50 | } else if (status === 'loading') {
51 | // Don't do anything while session is loading
52 | console.log('⏳ Session loading...');
53 | }
54 | }, [status, session])
55 |
56 | return null // this component doesn't render anything
57 | }
58 |
--------------------------------------------------------------------------------
/app/api/auth/refresh/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import jwt from 'jsonwebtoken';
3 | import { Redis } from '@upstash/redis';
4 |
5 | const redis = new Redis({
6 | url: process.env.UPSTASH_REDIS_REST_URL,
7 | token: process.env.UPSTASH_REDIS_REST_TOKEN,
8 | });
9 |
10 | export async function POST(request) {
11 | try {
12 | const cookies = request.cookies;
13 | const refreshToken = cookies.get('doctor-refresh')?.value || request.headers.get('x-refresh-token');
14 |
15 | if (!refreshToken) {
16 | return NextResponse.json({ success: false, error: 'No refresh token provided' }, { status: 401 });
17 | }
18 |
19 | const jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
20 | let decoded;
21 | try {
22 | decoded = jwt.verify(refreshToken, jwtSecret, {
23 | issuer: process.env.JWT_ISSUER,
24 | audience: process.env.JWT_AUDIENCE
25 | });
26 | } catch (err) {
27 | return NextResponse.json({ success: false, error: 'Invalid refresh token' }, { status: 401 });
28 | }
29 |
30 | // Check Redis for token validity
31 | const redisKey = `refresh:${decoded.doctorId}:${refreshToken}`;
32 | const valid = await redis.get(redisKey);
33 | if (!valid) {
34 | return NextResponse.json({ success: false, error: 'Refresh token revoked or expired' }, { status: 401 });
35 | }
36 |
37 | // Rotate refresh token: delete old, issue new
38 | await redis.del(redisKey);
39 | const newRefreshToken = jwt.sign(
40 | { doctorId: decoded.doctorId, type: 'refresh', iss: process.env.JWT_ISSUER, aud: process.env.JWT_AUDIENCE },
41 | jwtSecret,
42 | { expiresIn: '30d' }
43 | );
44 | await redis.set(`refresh:${decoded.doctorId}:${newRefreshToken}`, 'valid', { ex: 30 * 24 * 60 * 60 });
45 |
46 | // Issue new access token
47 | const accessToken = jwt.sign(
48 | {
49 | doctorId: decoded.doctorId,
50 | iss: process.env.JWT_ISSUER,
51 | aud: process.env.JWT_AUDIENCE
52 | },
53 | jwtSecret,
54 | { expiresIn: '15m' }
55 | );
56 |
57 | const response = NextResponse.json({ success: true });
58 | response.cookies.set('doctor-auth', accessToken, {
59 | httpOnly: true,
60 | secure: process.env.NODE_ENV === 'production',
61 | sameSite: 'lax',
62 | maxAge: 15 * 60,
63 | path: '/'
64 | });
65 | response.cookies.set('doctor-refresh', newRefreshToken, {
66 | httpOnly: true,
67 | secure: process.env.NODE_ENV === 'production',
68 | sameSite: 'lax',
69 | maxAge: 30 * 24 * 60 * 60,
70 | path: '/'
71 | });
72 |
73 | return response;
74 | } catch (error) {
75 | console.error('Refresh error:', error);
76 | return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/not-found.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { initializeTheme } from '../utils/theme';
5 | import usePageTitle from '../hooks/usePageTitle';
6 |
7 | export default function NotFound() {
8 | usePageTitle('Page Not Found');
9 |
10 | useEffect(() => {
11 | initializeTheme();
12 | }, []);
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
28 |
29 |
30 |
31 | 404 Not Found
32 |
33 |
34 | maybe the diagnosis was wrong, doc
35 |
36 |
37 |
38 |
39 |
40 |
46 |
|
47 |
51 | Return to Dashboard
52 |
53 |
54 |
55 |
The page you're looking for doesn't exist.
56 |
Please check the URL or navigate back to continue.
57 |
58 |
59 |
60 |
61 | );
62 | }
--------------------------------------------------------------------------------
/components/icons/DocPill.js:
--------------------------------------------------------------------------------
1 | const DocPill = ({ className = "w-8 h-8", ...props }) => (
2 |
20 | );
21 |
22 | export default DocPill;
--------------------------------------------------------------------------------
/app/api/auth/send-otp/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { Redis } from '@upstash/redis';
3 |
4 | const redis = new Redis({
5 | url: process.env.UPSTASH_REDIS_REST_URL,
6 | token: process.env.UPSTASH_REDIS_REST_TOKEN,
7 | });
8 |
9 | const OTP_ATTEMPT_LIMIT = 5;
10 | const OTP_ATTEMPT_WINDOW = 15 * 60; // 15 minutes
11 | const OTP_LOCKOUT_TIME = 30 * 60; // 30 minutes
12 | const OTP_VERIFY_ATTEMPT_LIMIT = 5;
13 | const OTP_VERIFY_LOCKOUT_TIME = 30 * 60; // 30 minutes
14 |
15 | async function checkOtpRateLimit(email, ip) {
16 | const key = `otp:attempts:${email}:${ip}`;
17 | const lockKey = `otp:lockout:${email}:${ip}`;
18 | const locked = await redis.get(lockKey);
19 | if (locked) return { locked: true };
20 |
21 | let attempts = await redis.get(key);
22 | attempts = attempts ? parseInt(attempts) : 0;
23 | if (attempts >= OTP_ATTEMPT_LIMIT) {
24 | await redis.set(lockKey, '1', { ex: OTP_LOCKOUT_TIME });
25 | return { locked: true };
26 | }
27 | return { locked: false, attempts, key, lockKey };
28 | }
29 |
30 | // Add brute force protection for OTP verification
31 | export async function verifyOtpHandler(email, otp) {
32 | const verifyKey = `otp:verify:attempts:${email}`;
33 | const verifyLockKey = `otp:verify:lockout:${email}`;
34 | const locked = await redis.get(verifyLockKey);
35 | if (locked) return { locked: true };
36 |
37 | let attempts = await redis.get(verifyKey);
38 | attempts = attempts ? parseInt(attempts) : 0;
39 | if (attempts >= OTP_VERIFY_ATTEMPT_LIMIT) {
40 | await redis.set(verifyLockKey, '1', { ex: OTP_VERIFY_LOCKOUT_TIME });
41 | return { locked: true };
42 | }
43 | return { locked: false, attempts, verifyKey, verifyLockKey };
44 | }
45 |
46 | export async function incrementOtpVerifyAttempts(verifyKey) {
47 | await redis.incr(verifyKey);
48 | await redis.expire(verifyKey, OTP_ATTEMPT_WINDOW);
49 | }
50 |
51 | export async function resetOtpVerifyAttempts(verifyKey, verifyLockKey) {
52 | await redis.del(verifyKey);
53 | await redis.del(verifyLockKey);
54 | }
55 |
56 | async function incrementOtpAttempts(key) {
57 | await redis.incr(key);
58 | await redis.expire(key, OTP_ATTEMPT_WINDOW);
59 | }
60 |
61 | async function resetOtpAttempts(key, lockKey) {
62 | await redis.del(key);
63 | await redis.del(lockKey);
64 | }
65 |
66 | export async function POST(request) {
67 | // Redirect HTTP to HTTPS (only in production)
68 | if (process.env.NODE_ENV === 'production' && request.headers.get('x-forwarded-proto') === 'http') {
69 | return NextResponse.redirect(`https://${request.headers.get('host')}${request.url}`, 308);
70 | }
71 |
72 | return new Response(JSON.stringify({
73 | success: false,
74 | error: 'This endpoint is deprecated. Please use NextAuth authentication.'
75 | }), {
76 | status: 410,
77 | headers: { 'Content-Type': 'application/json' }
78 | });
79 | }
80 |
81 |
--------------------------------------------------------------------------------
/app/api/prescriptions/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | /**
5 | * GET /api/prescriptions - Fetch all prescriptions
6 | */
7 | export async function GET(request) {
8 | try {
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context', data: [] },
14 | { status: 403 }
15 | );
16 | }
17 |
18 | const url = new URL(request.url);
19 | const patientId = url.searchParams.get('patientId');
20 | let prescriptions;
21 |
22 | if (patientId) {
23 | // Fetch prescriptions for a specific patient
24 | prescriptions = await databaseService.getPrescriptionsByPatient(patientId, doctorId);
25 | } else {
26 | // Fetch all prescriptions
27 | prescriptions = await databaseService.getPrescriptions(doctorId);
28 | }
29 |
30 | return NextResponse.json({ success: true, data: prescriptions || [] });
31 | } catch (error) {
32 | console.error('API Error fetching prescriptions:', error);
33 | return NextResponse.json(
34 | { success: false, error: 'Failed to fetch prescriptions', data: [] },
35 | { status: 500 }
36 | );
37 | }
38 | }
39 |
40 | /**
41 | * POST /api/prescriptions - Save prescriptions
42 | */
43 | export async function POST(request) {
44 | try {
45 | const doctorId = request.headers.get('X-Doctor-ID');
46 |
47 | if (!doctorId || doctorId === 'default-doctor') {
48 | return NextResponse.json(
49 | { success: false, error: 'Invalid doctor context' },
50 | { status: 403 }
51 | );
52 | }
53 |
54 | const body = await request.json();
55 |
56 | if (body.prescriptions) {
57 | // Save multiple prescriptions
58 | const success = await databaseService.savePrescriptions(body.prescriptions, doctorId);
59 |
60 | if (success) {
61 | return NextResponse.json({ success: true });
62 | } else {
63 | return NextResponse.json(
64 | { success: false, error: 'Failed to save prescriptions' },
65 | { status: 500 }
66 | );
67 | }
68 | } else if (body.prescription) {
69 | // Save single prescription
70 | const savedPrescription = await databaseService.savePrescription(body.prescription, doctorId);
71 |
72 | if (savedPrescription) {
73 | return NextResponse.json({ success: true, data: savedPrescription });
74 | } else {
75 | return NextResponse.json(
76 | { success: false, error: 'Failed to save prescription' },
77 | { status: 500 }
78 | );
79 | }
80 | } else {
81 | return NextResponse.json(
82 | { success: false, error: 'Invalid request body' },
83 | { status: 400 }
84 | );
85 | }
86 | } catch (error) {
87 | console.error('API Error saving prescriptions:', error);
88 | return NextResponse.json(
89 | { success: false, error: 'Failed to save prescriptions' },
90 | { status: 500 }
91 | );
92 | }
93 | }
--------------------------------------------------------------------------------
/utils/imageUtils.js:
--------------------------------------------------------------------------------
1 | // Image format detection and browser support utilities
2 |
3 | export const detectImageFormat = (src) => {
4 | if (!src) return 'Unknown';
5 |
6 | if (src.includes('.avif')) return 'AVIF';
7 | if (src.includes('.webp')) return 'WebP';
8 | if (src.includes('.jpg') || src.includes('.jpeg')) return 'JPEG';
9 | if (src.includes('.png')) return 'PNG';
10 | if (src.includes('.svg')) return 'SVG';
11 |
12 | return 'Unknown';
13 | };
14 |
15 | export const getBrowserImageSupport = () => {
16 | if (typeof window === 'undefined') {
17 | return { avif: false, webp: false };
18 | }
19 |
20 | const supportsWebP = () => {
21 | try {
22 | const canvas = document.createElement('canvas');
23 | canvas.width = 1;
24 | canvas.height = 1;
25 | return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
26 | } catch (e) {
27 | return false;
28 | }
29 | };
30 |
31 | const supportsAVIF = () => {
32 | try {
33 | const canvas = document.createElement('canvas');
34 | canvas.width = 1;
35 | canvas.height = 1;
36 | return canvas.toDataURL('image/avif').indexOf('data:image/avif') === 0;
37 | } catch (e) {
38 | return false;
39 | }
40 | };
41 |
42 | return {
43 | avif: supportsAVIF(),
44 | webp: supportsWebP()
45 | };
46 | };
47 |
48 | export const getCompressionSavings = (format) => {
49 | switch (format?.toUpperCase()) {
50 | case 'AVIF':
51 | return '~50% vs PNG';
52 | case 'WEBP':
53 | return '~25% vs PNG';
54 | case 'JPEG':
55 | return '~10% vs PNG';
56 | default:
57 | return 'No compression';
58 | }
59 | };
60 |
61 | export const logImageLoad = (format, src, additionalInfo = {}) => {
62 | const browserSupport = getBrowserImageSupport();
63 |
64 | // console.log(`✅ Image loaded successfully:`, {
65 | // format: format?.toUpperCase(),
66 | // src,
67 | // browserSupport: {
68 | // AVIF: browserSupport.avif,
69 | // WebP: browserSupport.webp
70 | // },
71 | // compressionSavings: getCompressionSavings(format),
72 | // userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.split(' ').pop() : 'Unknown',
73 | // ...additionalInfo
74 | // });
75 | };
76 |
77 | export const logImageError = (format, src, additionalInfo = {}) => {
78 | console.warn(`❌ Image failed to load:`, {
79 | format: format?.toUpperCase(),
80 | src,
81 | ...additionalInfo
82 | });
83 | };
84 |
85 | // Get the best image format for the current browser
86 | export const getBestImageFormat = (baseName, supportedFormats = ['avif', 'webp', 'jpg', 'png']) => {
87 | const browserSupport = getBrowserImageSupport();
88 |
89 | if (supportedFormats.includes('avif') && browserSupport.avif) {
90 | return `${baseName}.avif`;
91 | }
92 |
93 | if (supportedFormats.includes('webp') && browserSupport.webp) {
94 | return `${baseName}.webp`;
95 | }
96 |
97 | if (supportedFormats.includes('jpg')) {
98 | return `${baseName}.jpg`;
99 | }
100 |
101 | return `${baseName}.png`;
102 | };
--------------------------------------------------------------------------------
/components/TitleManager.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { useSession } from 'next-auth/react';
5 | import { usePathname } from 'next/navigation';
6 | import { storage } from '../utils/storage';
7 |
8 | export default function TitleManager() {
9 | const { data: session } = useSession();
10 | const pathname = usePathname();
11 |
12 | useEffect(() => {
13 | const updateTitleFromPath = async () => {
14 | // Only update title if no specific component has set it
15 | const currentTitle = document.title;
16 |
17 | // If the title has already been set by a component, don't override it
18 | if (currentTitle !== 'Doc Prescrip' && !currentTitle.includes('Doc Prescrip')) {
19 | return;
20 | }
21 |
22 | let pageTitle = '';
23 |
24 | // Determine page title from pathname
25 | switch (pathname) {
26 | case '/':
27 | pageTitle = 'Dashboard';
28 | break;
29 | case '/login':
30 | pageTitle = 'Login';
31 | break;
32 | case '/privacy':
33 | pageTitle = 'Privacy Policy';
34 | break;
35 | case '/terms':
36 | pageTitle = 'Terms of Service';
37 | break;
38 | case '/404':
39 | case '/not-found':
40 | pageTitle = 'Page Not Found';
41 | break;
42 | default:
43 | // For dynamic routes or other pages, extract from pathname
44 | if (pathname.includes('/patient/')) {
45 | pageTitle = 'Patient Details';
46 | } else if (pathname.includes('/billing')) {
47 | pageTitle = 'Billing';
48 | } else if (pathname.includes('/templates')) {
49 | pageTitle = 'Templates';
50 | } else if (pathname.includes('/settings')) {
51 | pageTitle = 'Settings';
52 | } else pageTitle = 'Page Not Found';
53 | break;
54 | }
55 |
56 | // Get doctor information if user is logged in
57 | let doctorName = '';
58 | if (session?.user) {
59 | try {
60 | const doctorContext = storage.getDoctorContext();
61 | if (doctorContext?.lastName) {
62 | doctorName = `Dr. ${doctorContext.lastName}`;
63 | }
64 | } catch (error) {
65 | console.error('Error fetching doctor context for title:', error);
66 | }
67 | }
68 |
69 | // Build title
70 | let title = '';
71 | if (pageTitle) {
72 | if (doctorName) {
73 | title = `${pageTitle} | ${doctorName} | Doc Prescrip`;
74 | } else {
75 | title = `${pageTitle} | Doc Prescrip`;
76 | }
77 | } else {
78 | if (doctorName) {
79 | title = `${doctorName} | Doc Prescrip`;
80 | } else {
81 | title = 'Doc Prescrip';
82 | }
83 | }
84 |
85 | document.title = title;
86 | };
87 |
88 | // Small delay to allow components to set their own titles first
89 | const timeoutId = setTimeout(updateTitleFromPath, 100);
90 |
91 | return () => clearTimeout(timeoutId);
92 | }, [pathname, session]);
93 |
94 | return null; // This component doesn't render anything
95 | }
--------------------------------------------------------------------------------
/app/api/doctor/profile/route.js:
--------------------------------------------------------------------------------
1 | import { doctorService } from '../../../../services/doctorService.js';
2 |
3 | export async function GET(request) {
4 | try {
5 | const doctorId = request.headers.get('x-doctor-id') || new URL(request.url).searchParams.get('doctorId');
6 |
7 | if (!doctorId) {
8 | return Response.json({ error: 'Doctor ID is required' }, { status: 400 });
9 | }
10 |
11 | // Get doctor profile
12 | const doctor = await doctorService.getDoctorById(doctorId);
13 |
14 | if (!doctor) {
15 | return Response.json({ error: 'Doctor not found' }, { status: 404 });
16 | }
17 |
18 | // Return doctor profile data
19 | const profileData = {
20 | name: doctor.name,
21 | specialization: doctor.specialization,
22 | degree: doctor.degree,
23 | registrationNumber: doctor.registrationNumber,
24 | hospitalName: doctor.hospitalName,
25 | hospitalAddress: doctor.hospitalAddress,
26 | phone: doctor.phone,
27 | email: doctor.email
28 | };
29 |
30 | return Response.json({
31 | success: true,
32 | data: profileData
33 | });
34 |
35 | } catch (error) {
36 | console.error('Error handling doctor profile GET request:', error);
37 | return Response.json({ error: 'Internal server error' }, { status: 500 });
38 | }
39 | }
40 |
41 | export async function PUT(request) {
42 | try {
43 | const doctorId = request.headers.get('x-doctor-id');
44 |
45 | if (!doctorId) {
46 | return Response.json({ error: 'Doctor ID is required' }, { status: 400 });
47 | }
48 |
49 | const body = await request.json();
50 | const {
51 | name,
52 | specialization,
53 | degree,
54 | registrationNumber,
55 | hospitalName,
56 | hospitalAddress,
57 | phone,
58 | email
59 | } = body;
60 |
61 | // Validate required fields
62 | if (!name) {
63 | return Response.json({ error: 'Name is required' }, { status: 400 });
64 | }
65 |
66 | // Prepare update data
67 | const updateData = {
68 | name,
69 | specialization,
70 | degree,
71 | registrationNumber,
72 | hospitalName,
73 | hospitalAddress,
74 | phone,
75 | email
76 | };
77 |
78 | // Remove empty/undefined fields to avoid overwriting with blank values
79 | Object.keys(updateData).forEach(key => {
80 | if (updateData[key] === undefined || updateData[key] === null) {
81 | delete updateData[key];
82 | }
83 | });
84 |
85 | // Update doctor profile
86 | const success = await doctorService.updateDoctorProfile(doctorId, updateData);
87 |
88 | if (success) {
89 | return Response.json({
90 | success: true,
91 | message: 'Doctor profile updated successfully',
92 | data: updateData
93 | });
94 | } else {
95 | return Response.json({ error: 'Failed to update doctor profile' }, { status: 500 });
96 | }
97 |
98 | } catch (error) {
99 | console.error('Error handling doctor profile PUT request:', error);
100 | return Response.json({ error: 'Internal server error' }, { status: 500 });
101 | }
102 | }
--------------------------------------------------------------------------------
/components/PlaceholderImageWithLogo.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Stethoscope } from 'lucide-react';
4 | import { useEffect, useState } from 'react';
5 | import { detectImageFormat, logImageLoad, logImageError, getBrowserImageSupport } from '../utils/imageUtils';
6 |
7 | export default function PlaceholderImageWithLogo({ isDarkTheme, themeInitialized }) {
8 | const [loadedImageFormat, setLoadedImageFormat] = useState(null);
9 |
10 | const handleImageLoad = (event) => {
11 | const src = event.target.currentSrc || event.target.src;
12 | const format = detectImageFormat(src);
13 | setLoadedImageFormat(format);
14 |
15 | logImageLoad(format, src, {
16 | component: 'PlaceholderImageWithLogo',
17 | theme: isDarkTheme ? 'Dark' : 'Light'
18 | });
19 | };
20 |
21 | const handleImageError = (event) => {
22 | const src = event.target.src;
23 | const format = detectImageFormat(src);
24 |
25 | logImageError(format, src, {
26 | component: 'PlaceholderImageWithLogo',
27 | fallbackNote: 'Will fall back to next source format'
28 | });
29 | };
30 |
31 | useEffect(() => {
32 | if (themeInitialized) {
33 | const browserSupport = getBrowserImageSupport();
34 | // console.log('🎨 PlaceholderImage - Theme initialized:', {
35 | // theme: isDarkTheme ? 'Dark' : 'Light',
36 | // browserSupport
37 | // });
38 | }
39 | }, [themeInitialized, isDarkTheme]);
40 |
41 | return (
42 |
43 | {/* Background Image */}
44 | {themeInitialized && (
45 |
46 | {/* AVIF - Best compression, modern browsers */}
47 |
51 | {/* WebP - Good compression, wide browser support */}
52 |
56 | {/* PNG - Fallback for older browsers */}
57 |
65 |
66 | )}
67 | {!themeInitialized && (
68 |
69 | )}
70 |
71 | {/* Development indicator for loaded image format */}
72 | {process.env.NODE_ENV === 'development' && loadedImageFormat && (
73 |
77 | {loadedImageFormat}
78 |
79 | )}
80 |
81 | {/* Optional gradient overlay for better text readability */}
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/app/api/custom-data/[type]/route.js:
--------------------------------------------------------------------------------
1 | import { databaseService } from '../../../../services/databaseService';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function GET(request, { params }) {
5 | try {
6 | const { type } = await params;
7 |
8 | // Get doctor ID from headers (set by frontend apiCall function)
9 | const doctorId = request.headers.get('X-Doctor-ID');
10 |
11 | if (!doctorId || doctorId === 'default-doctor') {
12 | return NextResponse.json(
13 | { success: false, error: 'Invalid doctor context' },
14 | { status: 401 }
15 | );
16 | }
17 |
18 | let data;
19 | switch (type) {
20 | case 'symptoms':
21 | data = await databaseService.getCustomSymptoms(doctorId);
22 | break;
23 | case 'diagnoses':
24 | data = await databaseService.getCustomDiagnoses(doctorId);
25 | break;
26 | case 'lab-tests':
27 | data = await databaseService.getCustomLabTests(doctorId);
28 | break;
29 | case 'medications':
30 | data = await databaseService.getCustomMedications(doctorId);
31 | break;
32 | default:
33 | return NextResponse.json(
34 | { success: false, error: 'Invalid custom data type' },
35 | { status: 400 }
36 | );
37 | }
38 |
39 | return NextResponse.json({ success: true, data });
40 | } catch (error) {
41 | console.error(`API Error fetching custom ${type}:`, error);
42 | return NextResponse.json(
43 | { success: false, error: `Failed to fetch custom ${type}` },
44 | { status: 500 }
45 | );
46 | }
47 | }
48 |
49 | export async function POST(request, { params }) {
50 | try {
51 | const { type } = await params;
52 | const { items } = await request.json();
53 |
54 | // Get doctor ID from headers (set by frontend apiCall function)
55 | const doctorId = request.headers.get('X-Doctor-ID');
56 |
57 | if (!doctorId || doctorId === 'default-doctor') {
58 | return NextResponse.json(
59 | { success: false, error: 'Invalid doctor context' },
60 | { status: 401 }
61 | );
62 | }
63 |
64 | let success;
65 | switch (type) {
66 | case 'symptoms':
67 | success = await databaseService.saveCustomSymptoms(items, doctorId);
68 | break;
69 | case 'diagnoses':
70 | success = await databaseService.saveCustomDiagnoses(items, doctorId);
71 | break;
72 | case 'lab-tests':
73 | success = await databaseService.saveCustomLabTests(items, doctorId);
74 | break;
75 | case 'medications':
76 | success = await databaseService.saveCustomMedications(items, doctorId);
77 | break;
78 | default:
79 | return NextResponse.json(
80 | { success: false, error: 'Invalid custom data type' },
81 | { status: 400 }
82 | );
83 | }
84 |
85 | if (success) {
86 | return NextResponse.json({ success: true });
87 | } else {
88 | return NextResponse.json(
89 | { success: false, error: `Failed to save custom ${type}` },
90 | { status: 500 }
91 | );
92 | }
93 | } catch (error) {
94 | console.error(`API Error saving custom ${type}:`, error);
95 | return NextResponse.json(
96 | { success: false, error: `Failed to save custom ${type}` },
97 | { status: 500 }
98 | );
99 | }
100 | }
--------------------------------------------------------------------------------
/app/api/logout/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getToken } from 'next-auth/jwt';
3 | import { Redis } from '@upstash/redis';
4 | import { jwtVerify } from 'jose';
5 |
6 | const redis = new Redis({
7 | url: process.env.UPSTASH_REDIS_REST_URL,
8 | token: process.env.UPSTASH_REDIS_REST_TOKEN,
9 | });
10 |
11 | const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'default_jwt_secret');
12 |
13 | async function verifyJwt(token) {
14 | try {
15 | const { payload } = await jwtVerify(token, secret);
16 | return payload;
17 | } catch (err) {
18 | return null;
19 | }
20 | }
21 |
22 | export async function POST(request) {
23 | try {
24 | // Check if user has NextAuth session (Google OAuth)
25 | const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
26 |
27 | // Check for custom JWT auth
28 | let doctorId = null;
29 | const doctorAuthCookie = request.cookies.get('doctor-auth');
30 | const refreshCookie = request.cookies.get('doctor-refresh');
31 |
32 | if (doctorAuthCookie?.value) {
33 | const decoded = await verifyJwt(doctorAuthCookie.value);
34 | if (decoded?.doctorId) {
35 | doctorId = decoded.doctorId;
36 | }
37 | }
38 |
39 | const response = NextResponse.json({
40 | success: true,
41 | hasNextAuthSession: !!token
42 | });
43 |
44 | // Clear custom JWT authentication cookies
45 | response.cookies.set('pin-auth', '', {
46 | httpOnly: true,
47 | secure: process.env.NODE_ENV === 'production',
48 | sameSite: 'lax',
49 | expires: new Date(0),
50 | path: '/'
51 | });
52 |
53 | response.cookies.set('doctor-auth', '', {
54 | httpOnly: true,
55 | secure: process.env.NODE_ENV === 'production',
56 | sameSite: 'lax',
57 | expires: new Date(0),
58 | path: '/'
59 | });
60 |
61 | response.cookies.set('doctor-refresh', '', {
62 | httpOnly: true,
63 | secure: process.env.NODE_ENV === 'production',
64 | sameSite: 'lax',
65 | expires: new Date(0),
66 | path: '/'
67 | });
68 |
69 | // Clean up refresh tokens from Redis if we have a doctor ID
70 | if (doctorId && refreshCookie?.value) {
71 | try {
72 | await redis.del(`refresh:${doctorId}:${refreshCookie.value}`);
73 | } catch (error) {
74 | console.error('Error cleaning up refresh token:', error);
75 | }
76 | }
77 |
78 | // Clear NextAuth session cookies if they exist
79 | if (token) {
80 | // Clear NextAuth session cookies with proper naming
81 | const isProduction = process.env.NODE_ENV === 'production';
82 | const cookiePrefix = isProduction ? '__Secure-' : '';
83 |
84 | response.cookies.set(`${cookiePrefix}next-auth.session-token`, '', {
85 | httpOnly: true,
86 | secure: isProduction,
87 | sameSite: 'lax',
88 | expires: new Date(0),
89 | path: '/'
90 | });
91 |
92 | response.cookies.set(`${cookiePrefix}next-auth.csrf-token`, '', {
93 | httpOnly: true,
94 | secure: isProduction,
95 | sameSite: 'lax',
96 | expires: new Date(0),
97 | path: '/'
98 | });
99 |
100 | response.cookies.set(`${cookiePrefix}next-auth.callback-url`, '', {
101 | httpOnly: true,
102 | secure: isProduction,
103 | sameSite: 'lax',
104 | expires: new Date(0),
105 | path: '/'
106 | });
107 | }
108 |
109 | return response;
110 | } catch (error) {
111 | console.error('Logout error:', error);
112 | return NextResponse.json(
113 | { success: false, error: 'Server error' },
114 | { status: 500 }
115 | );
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/components/ConfirmationDialog.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { X, Loader2 } from 'lucide-react';
4 | import { useState } from 'react';
5 |
6 | export default function ConfirmationDialog({
7 | isOpen,
8 | title,
9 | message,
10 | onConfirm,
11 | onCancel,
12 | isLoading = false,
13 | requireConfirmation = false,
14 | confirmationText = '',
15 | confirmationPlaceholder = 'Type to confirm'
16 | }) {
17 | const [inputValue, setInputValue] = useState('');
18 |
19 | if (!isOpen) return null;
20 |
21 | const canConfirm = !requireConfirmation || inputValue.trim() === confirmationText.trim();
22 |
23 | const handleConfirm = () => {
24 | if (canConfirm) {
25 | onConfirm();
26 | }
27 | };
28 |
29 | const handleCancel = () => {
30 | setInputValue('');
31 | onCancel();
32 | };
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
{title}
40 | {!isLoading && (
41 |
47 | )}
48 |
49 |
50 |
{message}
51 |
52 | {requireConfirmation && (
53 |
54 |
57 | setInputValue(e.target.value)}
61 | placeholder={confirmationPlaceholder}
62 | className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-red-500 focus:dark:border-red-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
63 | disabled={isLoading}
64 | autoFocus
65 | />
66 |
67 | )}
68 |
69 |
70 |
77 |
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/lib/medicalData.js:
--------------------------------------------------------------------------------
1 | export const PREDEFINED_SYMPTOMS = [
2 | 'Fever', 'Headache', 'Cough', 'Sore Throat', 'Runny Nose', 'Body Ache', 'Fatigue', 'Nausea', 'Vomiting', 'Diarrhea',
3 | 'Stomach Pain', 'Chest Pain', 'Shortness of Breath', 'Dizziness', 'Back Pain', 'Joint Pain', 'Muscle Pain', 'Skin Rash',
4 | 'Itching', 'Swelling', 'Constipation', 'Loss of Appetite', 'Weight Loss', 'Weight Gain', 'Sleep Problems', 'Anxiety',
5 | 'Depression', 'Confusion', 'Memory Loss', 'Blurred Vision', 'Ear Pain', 'Hearing Loss', 'Nose Bleeding', 'Sneezing',
6 | 'Wheezing', 'Hiccups', 'Sweating', 'Chills', 'Weakness', 'Numbness', 'Tingling', 'Bleeding', 'Bruising', 'Pale Skin',
7 | 'Yellow Skin', 'Dark Urine', 'Frequent Urination', 'Painful Urination', 'Blood in Urine', 'Irregular Heartbeat',
8 | 'High Blood Pressure', 'Low Blood Pressure', 'Leg Cramps', 'Foot Swelling', 'Hand Swelling'
9 | ];
10 |
11 | export const PREDEFINED_DIAGNOSES = [
12 | 'Common Cold', 'Flu', 'Viral Fever', 'Bacterial Infection', 'Urinary Tract Infection', 'Pneumonia', 'Bronchitis',
13 | 'Asthma', 'Allergic Rhinitis', 'Sinusitis', 'Pharyngitis', 'Tonsillitis', 'Gastritis', 'Acid Reflux', 'IBS',
14 | 'Food Poisoning', 'Migraine', 'Tension Headache', 'Hypertension', 'Diabetes Type 2', 'Diabetes Type 1',
15 | 'Hypothyroidism', 'Hyperthyroidism', 'Anemia', 'Vitamin D Deficiency', 'Vitamin B12 Deficiency', 'Arthritis',
16 | 'Osteoporosis', 'Fibromyalgia', 'Sciatica', 'Disc Prolapse', 'Cervical Spondylosis', 'Lumbar Spondylosis',
17 | 'Frozen Shoulder', 'Tennis Elbow', 'Carpal Tunnel Syndrome', 'Varicose Veins', 'Deep Vein Thrombosis',
18 | 'Heart Disease', 'Arrhythmia', 'Angina', 'Heart Attack', 'Stroke', 'High Cholesterol', 'Kidney Stones',
19 | 'Gallstones', 'Liver Disease', 'Hepatitis', 'Pancreatitis', 'Appendicitis', 'Hernia', 'Hemorrhoids',
20 | 'Skin Infection', 'Eczema', 'Psoriasis', 'Acne', 'Fungal Infection', 'Depression', 'Anxiety Disorder',
21 | 'Insomnia', 'Sleep Apnea', 'ADHD', 'Bipolar Disorder', 'Schizophrenia', 'Dementia', 'Alzheimer Disease',
22 | 'Parkinson Disease', 'Epilepsy', 'Multiple Sclerosis', 'Glaucoma', 'Cataract', 'Macular Degeneration',
23 | 'Hearing Loss', 'Tinnitus', 'Vertigo', 'Meniere Disease', 'COPD', 'Tuberculosis', 'Cancer', 'Leukemia',
24 | 'Lymphoma', 'Osteoarthritis', 'Rheumatoid Arthritis', 'Gout', 'Lupus', 'Celiac Disease', 'Crohn Disease',
25 | 'Ulcerative Colitis', 'Endometriosis', 'PCOS', 'Menopause', 'Erectile Dysfunction', 'Prostate Enlargement'
26 | ];
27 |
28 | export const PREDEFINED_LAB_TESTS = [
29 | 'Complete Blood Count (CBC)', 'Blood Sugar Fasting', 'Blood Sugar Random', 'HbA1c', 'Lipid Profile', 'Liver Function Test',
30 | 'Kidney Function Test', 'Thyroid Function Test', 'Urine Routine', 'ECG', 'Chest X-Ray', 'Blood Pressure',
31 | 'Hemoglobin', 'ESR', 'CRP', 'Vitamin D', 'Vitamin B12', 'Iron Studies', 'Calcium', 'Phosphorus',
32 | 'Uric Acid', 'Creatinine', 'Blood Urea', 'SGPT/ALT', 'SGOT/AST', 'Bilirubin Total', 'Bilirubin Direct',
33 | 'Alkaline Phosphatase', 'Protein Total', 'Albumin', 'Globulin', 'TSH', 'T3', 'T4', 'Free T3', 'Free T4',
34 | 'Total Cholesterol', 'HDL Cholesterol', 'LDL Cholesterol', 'Triglycerides', 'VLDL', 'Glucose Tolerance Test',
35 | 'HbsAg', 'Anti HCV', 'HIV', 'VDRL', 'Widal Test', 'Dengue NS1', 'Dengue IgM', 'Dengue IgG',
36 | 'Malaria Antigen', 'Typhoid IgM', 'Stool Routine', 'Stool Culture', 'Blood Culture', 'Urine Culture',
37 | 'Sputum AFB', 'Mantoux Test', 'Prothrombin Time', 'APTT', 'INR', 'D-Dimer', 'Troponin I', 'Troponin T',
38 | 'CPK-MB', 'LDH', 'Amylase', 'Lipase', 'PSA', 'CEA', 'CA 19-9', 'CA 125', 'AFP', 'Beta HCG',
39 | 'Prolactin', 'FSH', 'LH', 'Testosterone', 'Estradiol', 'Progesterone', 'Cortisol', 'Growth Hormone',
40 | 'Insulin', 'C-Peptide', 'Ferritin', 'TIBC', 'Transferrin Saturation', 'Folate', 'Homocysteine',
41 | 'Magnesium', 'Sodium', 'Potassium', 'Chloride', 'CO2', 'Anion Gap', 'Osmolality', 'Lactate',
42 | 'Ammonia', 'Ceruloplasmin', 'Alpha 1 Antitrypsin', 'Complement C3', 'Complement C4', 'ANA', 'Anti dsDNA',
43 | 'Rheumatoid Factor', 'Anti CCP', 'ASO Titre', 'C-Reactive Protein Quantitative'
44 | ];
--------------------------------------------------------------------------------
/app/api/auth/link-account/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getToken } from 'next-auth/jwt';
3 | import clientPromise from '../../../../lib/mongodb';
4 | import bcrypt from 'bcryptjs';
5 |
6 | export async function POST(request) {
7 | try {
8 | // Get NextAuth token
9 | const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
10 |
11 | if (!token || !token.email) {
12 | return NextResponse.json(
13 | { success: false, error: 'Not authenticated' },
14 | { status: 401 }
15 | );
16 | }
17 |
18 | const { action, password } = await request.json();
19 |
20 | const client = await clientPromise;
21 | const db = client.db('doc-prescrip');
22 | const doctors = db.collection('doctors');
23 |
24 | const doctor = await doctors.findOne({ email: token.email });
25 |
26 | if (!doctor) {
27 | return NextResponse.json(
28 | { success: false, error: 'Doctor not found' },
29 | { status: 404 }
30 | );
31 | }
32 |
33 | if (action === 'link-google') {
34 | // User wants to link Google account to existing email/password account
35 | if (!doctor.passwordHash) {
36 | return NextResponse.json(
37 | { success: false, error: 'No password set for this account' },
38 | { status: 400 }
39 | );
40 | }
41 |
42 | // Check if user already has Google linked
43 | if (doctor.googleId) {
44 | return NextResponse.json(
45 | { success: false, error: 'Google account is already linked to this account' },
46 | { status: 400 }
47 | );
48 | }
49 |
50 | // Update doctor with Google information from token
51 | await doctors.updateOne(
52 | { email: token.email },
53 | {
54 | $set: {
55 | googleId: token.sub, // Google user ID from JWT token
56 | isGoogleUser: true,
57 | image: token.picture || doctor.image,
58 | updatedAt: new Date()
59 | }
60 | }
61 | );
62 |
63 | return NextResponse.json({
64 | success: true,
65 | message: 'Google account linked successfully'
66 | });
67 |
68 | } else if (action === 'set-password') {
69 | // Google user wants to set a password for email/password login
70 | if (!password || password.length < 8) {
71 | return NextResponse.json(
72 | { success: false, error: 'Password must be at least 8 characters long' },
73 | { status: 400 }
74 | );
75 | }
76 |
77 | // Validate password strength
78 | const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_\-+=\[\]{};':",.<>/?\\|`~]).{8,}$/;
79 | if (!strongPasswordRegex.test(password)) {
80 | return NextResponse.json(
81 | { success: false, error: 'Password must include uppercase, lowercase, number, and special character' },
82 | { status: 400 }
83 | );
84 | }
85 |
86 | // Hash the password
87 | const salt = await bcrypt.genSalt(10);
88 | const passwordHash = await bcrypt.hash(password, salt);
89 |
90 | // Update doctor with password
91 | await doctors.updateOne(
92 | { email: token.email },
93 | {
94 | $set: {
95 | passwordHash,
96 | updatedAt: new Date()
97 | }
98 | }
99 | );
100 |
101 | return NextResponse.json({
102 | success: true,
103 | message: 'Password set successfully. You can now login with email and password.'
104 | });
105 |
106 | } else {
107 | return NextResponse.json(
108 | { success: false, error: 'Invalid action' },
109 | { status: 400 }
110 | );
111 | }
112 |
113 | } catch (error) {
114 | console.error('Account linking error:', error);
115 | return NextResponse.json(
116 | { success: false, error: 'Internal server error' },
117 | { status: 500 }
118 | );
119 | }
120 | }
--------------------------------------------------------------------------------
/components/FluidToggle.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from 'react';
2 |
3 | export default function FluidToggle({
4 | checked = false,
5 | onChange,
6 | disabled = false,
7 | size = 'default',
8 | className = ''
9 | }) {
10 | const [isPressed, setIsPressed] = useState(false);
11 | const [isHovered, setIsHovered] = useState(false);
12 | const timeoutRef = useRef(null);
13 |
14 | // Memoized size configurations with smoother scaling
15 | const sizeConfig = {
16 | small: {
17 | track: 'w-10 h-5',
18 | thumb: 'w-3.5 h-3.5',
19 | thumbPressed: 'w-5 h-3.5',
20 | thumbHovered: 'w-4 h-4',
21 | translateUnchecked: 'translate-x-0.5',
22 | translateChecked: 'translate-x-[1.25rem]',
23 | translateCheckedPressed: 'translate-x-[0.75rem]'
24 | },
25 | default: {
26 | track: 'w-10 h-6',
27 | thumb: 'w-4 h-4',
28 | thumbPressed: 'w-6 h-4',
29 | thumbHovered: 'w-4 h-4',
30 | translateUnchecked: 'translate-x-1',
31 | translateChecked: 'translate-x-[1.3rem]',
32 | translateCheckedPressed: 'translate-x-[0.85rem]'
33 | },
34 | large: {
35 | track: 'w-14 h-7',
36 | thumb: 'w-5 h-5',
37 | thumbPressed: 'w-7 h-5',
38 | thumbHovered: 'w-6 h-6',
39 | translateUnchecked: 'translate-x-1',
40 | translateChecked: 'translate-x-[2.25rem]',
41 | translateCheckedPressed: 'translate-x-[1.75rem]'
42 | }
43 | };
44 |
45 | const config = sizeConfig[size];
46 |
47 | const handleMouseDown = () => {
48 | if (disabled) return;
49 | setIsPressed(true);
50 |
51 | if (timeoutRef.current) {
52 | clearTimeout(timeoutRef.current);
53 | timeoutRef.current = null;
54 | }
55 | };
56 |
57 | const handleMouseUp = () => {
58 | if (disabled || !isPressed) return;
59 |
60 | onChange && onChange(!checked);
61 |
62 | timeoutRef.current = setTimeout(() => {
63 | setIsPressed(false);
64 | timeoutRef.current = null;
65 | }, 80);
66 | };
67 |
68 | const handleMouseEnter = () => {
69 | if (!disabled) setIsHovered(true);
70 | };
71 |
72 | const handleMouseLeave = () => {
73 | setIsHovered(false);
74 | if (isPressed) {
75 | handleMouseUp();
76 | }
77 | };
78 |
79 | useEffect(() => {
80 | return () => {
81 | if (timeoutRef.current) {
82 | clearTimeout(timeoutRef.current);
83 | }
84 | };
85 | }, []);
86 |
87 | // Pre-calculate thumb classes with smooth transitions
88 | let thumbClasses = 'inline-block rounded-full bg-white dark:bg-gray-900 shadow-lg transition-all duration-150 ease-out transform ';
89 |
90 | if (isPressed) {
91 | thumbClasses += config.thumbPressed + ' ';
92 | } else if (isHovered) {
93 | thumbClasses += config.thumbHovered + ' ';
94 | } else {
95 | thumbClasses += config.thumb + ' ';
96 | }
97 |
98 | if (checked) {
99 | if (isPressed) {
100 | thumbClasses += config.translateCheckedPressed;
101 | } else {
102 | thumbClasses += config.translateChecked;
103 | }
104 | } else {
105 | thumbClasses += config.translateUnchecked;
106 | }
107 |
108 | return (
109 |
135 | );
136 | }
--------------------------------------------------------------------------------
/components/OptimizedImage.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState, useRef } from 'react';
4 |
5 | export default function OptimizedImage({
6 | baseName,
7 | alt,
8 | className = '',
9 | style = {},
10 | onLoad,
11 | onError,
12 | draggable = false,
13 | ...props
14 | }) {
15 | const [loadedFormat, setLoadedFormat] = useState(null);
16 | const [browserSupport, setBrowserSupport] = useState({
17 | avif: false,
18 | webp: false
19 | });
20 | const imgRef = useRef(null);
21 |
22 | // Check browser support for modern image formats
23 | useEffect(() => {
24 | const checkSupport = async () => {
25 | const supportsWebP = () => {
26 | const canvas = document.createElement('canvas');
27 | canvas.width = 1;
28 | canvas.height = 1;
29 | return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
30 | };
31 |
32 | const supportsAVIF = () => {
33 | const canvas = document.createElement('canvas');
34 | canvas.width = 1;
35 | canvas.height = 1;
36 | return canvas.toDataURL('image/avif').indexOf('data:image/avif') === 0;
37 | };
38 |
39 | const support = {
40 | avif: supportsAVIF(),
41 | webp: supportsWebP()
42 | };
43 |
44 | setBrowserSupport(support);
45 |
46 | console.log('🌐 Browser Format Support:', {
47 | AVIF: support.avif,
48 | WebP: support.webp,
49 | userAgent: navigator.userAgent.split(' ').pop()
50 | });
51 | };
52 |
53 | checkSupport();
54 | }, []);
55 |
56 | const handleImageLoad = (event) => {
57 | const src = event.target.currentSrc || event.target.src;
58 | let format = 'PNG';
59 |
60 | if (src.includes('.avif')) format = 'AVIF';
61 | else if (src.includes('.webp')) format = 'WebP';
62 | else if (src.includes('.jpg') || src.includes('.jpeg')) format = 'JPEG';
63 |
64 | setLoadedFormat(format);
65 |
66 | console.log(`✅ OptimizedImage loaded:`, {
67 | format,
68 | src,
69 | baseName,
70 | browserSupport,
71 | savings: format === 'AVIF' ? '~50% vs PNG' : format === 'WebP' ? '~25% vs PNG' : 'No compression'
72 | });
73 |
74 | if (onLoad) onLoad(event);
75 | };
76 |
77 | const handleImageError = (event) => {
78 | const src = event.target.src;
79 | let format = 'Unknown';
80 |
81 | if (src.includes('.avif')) format = 'AVIF';
82 | else if (src.includes('.webp')) format = 'WebP';
83 | else if (src.includes('.png')) format = 'PNG';
84 |
85 | console.warn(`❌ OptimizedImage failed to load:`, {
86 | format,
87 | src,
88 | baseName,
89 | fallbackAvailable: true
90 | });
91 |
92 | if (onError) onError(event);
93 | };
94 |
95 | return (
96 |
97 | {/* AVIF - Most efficient format, ~50% smaller than PNG */}
98 |
102 |
103 | {/* WebP - Good compression, ~25% smaller than PNG */}
104 |
108 |
109 | {/* JPEG - Good for photos */}
110 |
114 |
115 | {/* PNG - Fallback for older browsers */}
116 |
127 |
128 | {/* Development indicator */}
129 | {process.env.NODE_ENV === 'development' && loadedFormat && (
130 |
134 | {loadedFormat}
135 |
136 | )}
137 |
138 | );
139 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Doc Prescrip
2 |
3 | 
4 |
5 | A comprehensive web application for medical practice management, designed to streamline patient care, prescription creation, billing, and more for healthcare professionals.
6 |
7 | ---
8 |
9 | ## Table of Contents
10 | - [Overview](#overview)
11 | - [Features](#features)
12 | - [Screenshots](#screenshots)
13 | - [Technology Stack](#technology-stack)
14 | - [Getting Started](#getting-started)
15 | - [Usage](#usage)
16 | - [Support & Contact](#support--contact)
17 | - [License](#license)
18 |
19 | ---
20 |
21 | ## Overview
22 | Doc Prescrip is a modern, secure, and user-friendly platform for doctors and clinics to manage their medical practice digitally. It offers tools for patient record management, digital prescriptions, billing, appointment scheduling, and more—all with a focus on privacy and compliance.
23 |
24 | ---
25 |
26 | ## Features
27 | | Category | Features |
28 | |------------------|--------------------------------------------------------------------------|
29 | | Patient Records | Add, edit, and view patient details, medical history, and visit timeline |
30 | | Prescriptions | Create, edit, and share digital prescriptions with PDF export |
31 | | Billing & Fees | Generate bills, track payments, and manage billing history |
32 | | Appointments | Schedule, manage, and track patient appointments |
33 | | Medical Docs | Generate medical certificates and other documents |
34 | | Templates | Use and manage prescription templates for faster workflow |
35 | | Activity Log | View recent activities and export history |
36 | | Customization | Personalize settings, upload hospital logo, and manage appearance |
37 | | Security | Secure authentication, privacy controls, and data protection |
38 | | Integrations | Google account linking, PDF sharing, WhatsApp integration |
39 |
40 | ---
41 |
42 | ## Screenshots
43 |
44 | 
45 |
46 | 
47 |
48 | 
49 |
50 | ---
51 |
52 | ## Technology Stack
53 | | Layer | Technology |
54 | |---------------|--------------------------|
55 | | Frontend | Next.js, React, Tailwind |
56 | | UI Components | Lucide Icons, Sonner |
57 | | Auth | NextAuth.js, Google OAuth|
58 | | Storage | Local Storage, Custom DB |
59 | | PDF | Custom PDF Generator |
60 | | Other | WhatsApp Integration |
61 |
62 | ---
63 |
64 | ## Getting Started
65 | 1. **Visit the application:**
66 | - Go to [https://doc-prescrip.com](https://doc-prescrip.com) (or your deployed domain)
67 |
68 | 2. **Create your account:**
69 | - Click "Sign Up" or "Get Started"
70 | - Choose **Google Sign-In** (recommended for quick setup)
71 | - Or register with email and password
72 |
73 | 3. **Complete your profile:**
74 | - Add your medical practice details
75 | - Upload your hospital/clinic logo
76 | - Set your preferences
77 |
78 | 4. **Start managing your practice:**
79 | - Add your first patient
80 | - Create digital prescriptions
81 | - Manage appointments and billing
82 | - Enjoy effortless practice management!
83 |
84 | ---
85 |
86 | ## Usage
87 | - **Login/Register:** Doctors can sign up or log in using email or Google.
88 | - **Patient Management:** Add new patients, view details, and track medical history.
89 | - **Prescription:** Create, edit, and export prescriptions as PDFs.
90 | - **Billing:** Generate bills, mark payments, and export billing documents.
91 | - **Settings:** Customize your profile, upload hospital logo, and manage preferences.
92 | - **Activity Log:** Track recent activities and export history for compliance.
93 |
94 | ---
95 |
96 | ## Support & Contact
97 | For help, feedback, or feature requests:
98 | - Email: [prathameshnikam21119@gmail.com](mailto:prathameshnikam21119@gmail.com)
99 | - [Privacy Policy](https://doc-prescrip.vercel.app/privacy) | [Terms of Service](https://doc-prescrip.vercel.app/terms)
100 |
101 | ---
102 |
103 | ## License
104 | This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details.
105 |
106 | ---
107 |
108 | 
--------------------------------------------------------------------------------
/app/api/upload-to-drive/route.js:
--------------------------------------------------------------------------------
1 | import { google } from "googleapis";
2 | import { getToken } from "next-auth/jwt";
3 | import { NextResponse } from "next/server";
4 | import { Readable } from "stream";
5 |
6 | // --- Utility: Get or create a Drive folder ---
7 | async function getOrCreateFolder(drive, name, parentId = null) {
8 | const query = `name='${name}' and mimeType='application/vnd.google-apps.folder' ${
9 | parentId ? `and '${parentId}' in parents` : ""
10 | } and trashed=false`;
11 |
12 | const res = await drive.files.list({ q: query, fields: "files(id, name)" });
13 | if (res.data.files.length > 0) return res.data.files[0].id;
14 |
15 | const folder = await drive.files.create({
16 | requestBody: {
17 | name,
18 | mimeType: "application/vnd.google-apps.folder",
19 | parents: parentId ? [parentId] : [],
20 | },
21 | fields: "id",
22 | });
23 |
24 | return folder.data.id;
25 | }
26 |
27 | export async function POST(request) {
28 | try {
29 | const token = await getToken({ req: request });
30 |
31 | if (!token?.serverAccessToken) {
32 | return NextResponse.json(
33 | { error: "Unauthorized - No Google access token found. Please sign out and sign in again." },
34 | { status: 401 }
35 | );
36 | }
37 |
38 | // Setup OAuth2 client with stored token
39 | const oauth2Client = new google.auth.OAuth2(
40 | process.env.GOOGLE_CLIENT_ID,
41 | process.env.GOOGLE_CLIENT_SECRET
42 | );
43 |
44 | oauth2Client.setCredentials({
45 | access_token: token.serverAccessToken,
46 | refresh_token: token.serverRefreshToken,
47 | });
48 |
49 | // Handle automatic token refresh
50 | oauth2Client.on('tokens', (tokens) => {
51 | console.log('Tokens refreshed automatically');
52 | if (tokens.refresh_token) {
53 | // Store the new refresh token if provided
54 | oauth2Client.setCredentials({
55 | access_token: tokens.access_token,
56 | refresh_token: tokens.refresh_token,
57 | });
58 | }
59 | });
60 |
61 | const drive = google.drive({ version: "v3", auth: oauth2Client });
62 |
63 | // --- Get form data ---
64 | const formData = await request.formData();
65 | const file = formData.get("file"); // PDF blob
66 | const filename = formData.get("filename") || "document.pdf";
67 | const patientName = formData.get("patientName") || "UnknownPatient";
68 | const visitDate = formData.get("visitDate") || new Date().toISOString().split("T")[0];
69 |
70 | if (!file) {
71 | return NextResponse.json({ error: "No file provided" }, { status: 400 });
72 | }
73 |
74 | // Convert file to buffer/stream
75 | const buffer = Buffer.from(await file.arrayBuffer());
76 | const stream = Readable.from(buffer);
77 |
78 | // --- Create folder hierarchy ---
79 | const rootId = await getOrCreateFolder(drive, "doc-prescrip");
80 | const patientId = await getOrCreateFolder(drive, patientName, rootId);
81 | const visitId = await getOrCreateFolder(drive, visitDate, patientId);
82 |
83 | // --- Upload file ---
84 | const uploadResponse = await drive.files.create({
85 | requestBody: {
86 | name: filename,
87 | parents: [visitId],
88 | },
89 | media: {
90 | mimeType: file.type || "application/pdf",
91 | body: stream,
92 | },
93 | fields: "id, webViewLink",
94 | });
95 |
96 | const fileId = uploadResponse.data.id;
97 |
98 | // Make file shareable
99 | await drive.permissions.create({
100 | fileId: fileId,
101 | requestBody: { role: "reader", type: "anyone" },
102 | });
103 |
104 | return NextResponse.json({
105 | success: true,
106 | fileId,
107 | link: uploadResponse.data.webViewLink,
108 | });
109 | } catch (error) {
110 | console.error("Google Drive upload error:", error);
111 |
112 | // If it's an authentication error, suggest re-login
113 | if (error.code === 401 || error.message?.includes('invalid_grant') || error.message?.includes('unauthorized')) {
114 | return NextResponse.json(
115 | { error: "Google authentication expired. Please sign out and sign in again to refresh your access." },
116 | { status: 401 }
117 | );
118 | }
119 |
120 | return NextResponse.json(
121 | { error: "Upload failed: " + error.message },
122 | { status: 500 }
123 | );
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/lib/medicationData.js:
--------------------------------------------------------------------------------
1 | export const COMMON_MEDICATIONS = [
2 | // Pain Relief & Anti-inflammatory
3 | 'Acetaminophen', 'Ibuprofen', 'Aspirin', 'Naproxen', 'Diclofenac', 'Tramadol', 'Morphine', 'Codeine',
4 | 'Paracetamol', 'Combiflam', 'Voveran', 'Zerodol', 'Dolo 650', 'Crocin', 'Brufen', 'Flexon',
5 |
6 | // Antibiotics
7 | 'Amoxicillin', 'Azithromycin', 'Ciprofloxacin', 'Doxycycline', 'Penicillin', 'Cephalexin', 'Levofloxacin',
8 | 'Metronidazole', 'Clarithromycin', 'Ampicillin', 'Erythromycin', 'Tetracycline', 'Augmentin', 'Azee',
9 | 'Norflox', 'Ofloxacin', 'Cefixime', 'Clindamycin', 'Roxithromycin', 'Sparfloxacin',
10 |
11 | // Cardiovascular
12 | 'Amlodipine', 'Lisinopril', 'Metoprolol', 'Atorvastatin', 'Losartan', 'Carvedilol', 'Simvastatin',
13 | 'Enalapril', 'Propranolol', 'Diltiazem', 'Verapamil', 'Digoxin', 'Warfarin', 'Clopidogrel',
14 | 'Amlokind', 'Telma', 'Ecosprin', 'Rosuvastatin', 'Olmesartan', 'Telmisartan',
15 |
16 | // Diabetes Management
17 | 'Metformin', 'Insulin', 'Glipizide', 'Glyburide', 'Pioglitazone', 'Sitagliptin', 'Glimepiride',
18 | 'Januvia', 'Glimpiride', 'Gliclazide', 'Repaglinide', 'Voglibose',
19 |
20 | // Respiratory
21 | 'Albuterol', 'Prednisone', 'Montelukast', 'Fluticasone', 'Budesonide', 'Theophylline',
22 | 'Salbutamol', 'Asthalin', 'Levolin', 'Budecort', 'Foracort', 'Deriphyllin',
23 |
24 | // Gastrointestinal
25 | 'Omeprazole', 'Pantoprazole', 'Ranitidine', 'Ondansetron', 'Loperamide', 'Simethicone',
26 | 'Pan D', 'Rablet', 'Rantac', 'Gelusil', 'ENO', 'Cyclopam', 'Aristozyme', 'Digene',
27 | 'Esomeprazole', 'Lansoprazole', 'Domperidone', 'Famotidine',
28 |
29 | // Mental Health & Neurological
30 | 'Sertraline', 'Fluoxetine', 'Escitalopram', 'Paroxetine', 'Lorazepam', 'Alprazolam', 'Diazepam',
31 | 'Clonazepam', 'Gabapentin', 'Pregabalin', 'Phenytoin', 'Carbamazepine',
32 |
33 | // Vitamins & Supplements
34 | 'Becosules', 'Shelcal', 'Evion', 'Limcee', 'Neurobion', 'Folic Acid', 'Iron Tablets',
35 | 'Vitamin D3', 'Calcium Carbonate', 'Multivitamin', 'Omega 3', 'Zinc Tablets',
36 |
37 | // Liver Support
38 | 'Liv 52', 'Udiliv', 'Hepamerz', 'Silymarin',
39 |
40 | // Antihistamines & Allergies
41 | 'Cetirizine', 'Loratadine', 'Fexofenadine', 'Chlorpheniramine', 'Allegra', 'Zyrtec',
42 |
43 | // Antifungals
44 | 'Fluconazole', 'Ketoconazole', 'Itraconazole', 'Terbinafine',
45 |
46 | // Cough & Cold
47 | 'Dextromethorphan', 'Bromhexine', 'Ambroxol', 'Alex', 'Benadryl', 'Chericof',
48 |
49 | // Topical Applications
50 | 'Betnovate', 'Soframycin', 'Neosporin', 'Thrombophob', 'Diclofenac Gel', 'Volini'
51 | ];
52 |
53 | export const MEDICATION_CATEGORIES = {
54 | 'Pain Relief': [
55 | 'Acetaminophen', 'Ibuprofen', 'Aspirin', 'Naproxen', 'Diclofenac', 'Tramadol',
56 | 'Paracetamol', 'Combiflam', 'Voveran', 'Zerodol', 'Dolo 650', 'Crocin', 'Brufen', 'Flexon'
57 | ],
58 | 'Antibiotics': [
59 | 'Amoxicillin', 'Azithromycin', 'Ciprofloxacin', 'Doxycycline', 'Penicillin', 'Cephalexin',
60 | 'Augmentin', 'Azee', 'Norflox', 'Ofloxacin', 'Cefixime', 'Clindamycin', 'Roxithromycin'
61 | ],
62 | 'Cardiovascular': [
63 | 'Amlodipine', 'Lisinopril', 'Metoprolol', 'Atorvastatin', 'Losartan', 'Carvedilol',
64 | 'Amlokind', 'Telma', 'Ecosprin', 'Rosuvastatin', 'Olmesartan', 'Telmisartan'
65 | ],
66 | 'Diabetes': [
67 | 'Metformin', 'Insulin', 'Glipizide', 'Glyburide', 'Pioglitazone', 'Sitagliptin',
68 | 'Januvia', 'Glimpiride', 'Gliclazide', 'Repaglinide', 'Voglibose'
69 | ],
70 | 'Respiratory': [
71 | 'Albuterol', 'Prednisone', 'Montelukast', 'Fluticasone', 'Budesonide',
72 | 'Salbutamol', 'Asthalin', 'Levolin', 'Budecort', 'Foracort', 'Deriphyllin'
73 | ],
74 | 'Gastrointestinal': [
75 | 'Omeprazole', 'Pantoprazole', 'Ranitidine', 'Ondansetron', 'Loperamide',
76 | 'Pan D', 'Rablet', 'Rantac', 'Gelusil', 'ENO', 'Cyclopam', 'Aristozyme'
77 | ],
78 | 'Mental Health': [
79 | 'Sertraline', 'Fluoxetine', 'Escitalopram', 'Paroxetine', 'Lorazepam',
80 | 'Alprazolam', 'Diazepam', 'Clonazepam', 'Gabapentin', 'Pregabalin'
81 | ],
82 | 'Vitamins & Supplements': [
83 | 'Becosules', 'Shelcal', 'Evion', 'Limcee', 'Neurobion', 'Folic Acid',
84 | 'Vitamin D3', 'Calcium Carbonate', 'Multivitamin', 'Omega 3', 'Zinc Tablets'
85 | ],
86 | 'Liver Support': ['Liv 52', 'Udiliv', 'Hepamerz', 'Silymarin'],
87 | 'Antihistamines': ['Cetirizine', 'Loratadine', 'Fexofenadine', 'Allegra', 'Zyrtec'],
88 | 'Antifungals': ['Fluconazole', 'Ketoconazole', 'Itraconazole', 'Terbinafine'],
89 | 'Cough & Cold': ['Dextromethorphan', 'Bromhexine', 'Ambroxol', 'Alex', 'Benadryl', 'Chericof'],
90 | 'Topical': ['Betnovate', 'Soframycin', 'Neosporin', 'Thrombophob', 'Diclofenac Gel', 'Volini']
91 | };
--------------------------------------------------------------------------------
/app/api/auth/forgot-password/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { Redis } from '@upstash/redis';
3 |
4 | const redis = new Redis({
5 | url: process.env.UPSTASH_REDIS_REST_URL,
6 | token: process.env.UPSTASH_REDIS_REST_TOKEN,
7 | });
8 |
9 | const FORGOT_PASSWORD_LIMIT = 3;
10 | const FORGOT_PASSWORD_WINDOW = 30 * 60; // 30 minutes
11 | const FORGOT_PASSWORD_LOCKOUT = 60 * 60; // 1 hour
12 |
13 | async function checkForgotPasswordRateLimit(email, ip) {
14 | const key = `forgot-password:attempts:${email}:${ip}`;
15 | const lockKey = `forgot-password:lockout:${email}:${ip}`;
16 | const locked = await redis.get(lockKey);
17 | if (locked) return { locked: true };
18 |
19 | let attempts = await redis.get(key);
20 | attempts = attempts ? parseInt(attempts) : 0;
21 | if (attempts >= FORGOT_PASSWORD_LIMIT) {
22 | await redis.set(lockKey, '1', { ex: FORGOT_PASSWORD_LOCKOUT });
23 | return { locked: true };
24 | }
25 | return { locked: false, attempts, key, lockKey };
26 | }
27 |
28 | async function incrementForgotPasswordAttempts(key) {
29 | await redis.incr(key);
30 | await redis.expire(key, FORGOT_PASSWORD_WINDOW);
31 | }
32 |
33 | async function resetForgotPasswordAttempts(key, lockKey) {
34 | await redis.del(key);
35 | await redis.del(lockKey);
36 | }
37 |
38 | export async function POST(request) {
39 | // Redirect HTTP to HTTPS (only in production)
40 | if (process.env.NODE_ENV === 'production' && request.headers.get('x-forwarded-proto') === 'http') {
41 | return NextResponse.redirect(`https://${request.headers.get('host')}${request.url}`, 308);
42 | }
43 |
44 | try {
45 | const { email } = await request.json();
46 | const ip = request.headers.get('x-forwarded-for') || 'unknown';
47 |
48 | // Validate email
49 | if (!email) {
50 | return NextResponse.json(
51 | { success: false, error: 'Email is required' },
52 | { status: 400 }
53 | );
54 | }
55 |
56 | // Validate email format
57 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
58 | if (!emailRegex.test(email)) {
59 | return NextResponse.json(
60 | { success: false, error: 'Invalid email format' },
61 | { status: 400 }
62 | );
63 | }
64 |
65 | // Rate limiting
66 | const rate = await checkForgotPasswordRateLimit(email, ip);
67 | if (rate.locked) {
68 | return NextResponse.json(
69 | { success: false, error: 'Too many password reset attempts. Please try again later.' },
70 | { status: 429 }
71 | );
72 | }
73 |
74 | const { doctorService } = await import('../../../../services/doctorService');
75 | const { emailService } = await import('../../../../services/emailService');
76 |
77 | // Check if doctor exists with this email
78 | const doctor = await doctorService.getDoctorByEmail(email);
79 | if (!doctor) {
80 | await incrementForgotPasswordAttempts(rate.key);
81 | return NextResponse.json(
82 | { success: false, error: 'No account found with this email address' },
83 | { status: 404 }
84 | );
85 | }
86 |
87 | // Generate new password (12 characters with uppercase, lowercase, numbers, and symbols)
88 | const newPassword = doctorService.generateRandomPassword();
89 |
90 | // Update doctor's password
91 | const passwordUpdated = await doctorService.updatePassword(doctor.doctorId, newPassword);
92 | if (!passwordUpdated) {
93 | await incrementForgotPasswordAttempts(rate.key);
94 | return NextResponse.json(
95 | { success: false, error: 'Failed to update password. Please try again.' },
96 | { status: 500 }
97 | );
98 | }
99 |
100 | // Send password reset email
101 | const emailSent = await emailService.sendPasswordResetEmail(email, newPassword, doctor.firstName || doctor.name?.split(' ')[0] || 'Doctor');
102 |
103 | if (!emailSent) {
104 | // Rollback password change if email fails
105 | console.error('Failed to send password reset email, but password was already changed');
106 | await incrementForgotPasswordAttempts(rate.key);
107 | return NextResponse.json(
108 | { success: false, error: 'Failed to send password reset email. Please try again.' },
109 | { status: 500 }
110 | );
111 | }
112 |
113 | await resetForgotPasswordAttempts(rate.key, rate.lockKey);
114 |
115 | return NextResponse.json({
116 | success: true,
117 | message: 'New password has been sent to your email'
118 | });
119 |
120 | } catch (error) {
121 | console.error('Forgot password error:', error);
122 | return NextResponse.json(
123 | { success: false, error: 'Internal server error' },
124 | { status: 500 }
125 | );
126 | }
127 | }
--------------------------------------------------------------------------------
/components/DarkModeToggle.js:
--------------------------------------------------------------------------------
1 | // components/DarkModeToggle.js
2 | import { useState, useEffect, useRef } from 'react';
3 | import { Sun, Moon } from 'lucide-react';
4 | import { toggleDarkMode } from '../utils/theme';
5 | import { flushSync } from 'react-dom';
6 | import { storage } from '../utils/storage';
7 |
8 | export default function DarkModeToggle() {
9 | const [isDark, setIsDark] = useState(false);
10 | const [showAnimations, setShowAnimations] = useState(true);
11 | const ref = useRef(null);
12 |
13 | useEffect(() => {
14 | // Check current theme
15 | setIsDark(document.documentElement.classList.contains('dark'));
16 |
17 | // Load animation settings
18 | const loadSettings = async () => {
19 | try {
20 | const settings = await storage.getSettings();
21 | // Default to true if not set
22 | setShowAnimations(settings?.appearance?.showAnimations !== false);
23 | } catch (error) {
24 | console.error('Error loading animation settings:', error);
25 | }
26 | };
27 |
28 | loadSettings();
29 |
30 | // Listen for theme changes from other components (like settings modal)
31 | const handleThemeChange = (event) => {
32 | const newTheme = event.detail.theme;
33 | setIsDark(newTheme === 'dark');
34 | };
35 |
36 | // Listen for settings changes to update animation preferences
37 | const handleSettingsChange = async (event) => {
38 | try {
39 | const settings = event.detail.settings || await storage.getSettings();
40 | setShowAnimations(settings?.appearance?.showAnimations !== false);
41 | } catch (error) {
42 | console.error('Error loading updated animation settings:', error);
43 | }
44 | };
45 |
46 | window.addEventListener('themeChanged', handleThemeChange);
47 | window.addEventListener('settingsChanged', handleSettingsChange);
48 |
49 | return () => {
50 | window.removeEventListener('themeChanged', handleThemeChange);
51 | window.removeEventListener('settingsChanged', handleSettingsChange);
52 | };
53 |
54 | }, []);
55 |
56 | const handleToggle = async () => {
57 | // If the button is disabled, do nothing
58 | if (!ref.current) return;
59 |
60 | // Save the new theme in settings
61 | try {
62 | const settings = await storage.getSettings() || {};
63 | if (!settings.appearance) settings.appearance = {};
64 | settings.appearance.theme = !isDark ? 'dark' : 'light';
65 | await storage.saveSettings(settings);
66 | } catch (error) {
67 | console.error('Error saving theme setting:', error);
68 | }
69 |
70 | // Check if animations are enabled and View Transitions API is supported
71 | if (showAnimations && document.startViewTransition) {
72 | // Add class to prevent layout shifts during transition
73 | document.body.classList.add('dark-mode-transitioning');
74 |
75 | const transition = document.startViewTransition(() => {
76 | flushSync(() => {
77 | toggleDarkMode();
78 | setIsDark(!isDark);
79 | });
80 | });
81 |
82 | await transition.ready;
83 |
84 | const {top, left, width, height} = ref.current.getBoundingClientRect();
85 | const right = window.innerWidth - left;
86 | const bottom = window.innerHeight - top;
87 | const maxRadius = Math.hypot(
88 | Math.max(left, right),
89 | Math.max(top, bottom)
90 | );
91 |
92 | const animation = document.documentElement.animate({
93 | clipPath: [
94 | `circle(0px at ${left + width / 2}px ${top + height / 2}px)`,
95 | `circle(${maxRadius}px at ${left + width / 2}px ${top + height / 2}px)`
96 | ]
97 | }, {
98 | duration: 1500,
99 | easing: 'ease-in-out',
100 | pseudoElement: '::view-transition-new(root)'
101 | });
102 |
103 | // Remove the class after animation completes
104 | animation.addEventListener('finish', () => {
105 | document.body.classList.remove('dark-mode-transitioning');
106 | });
107 | } else {
108 | // No animation version
109 | toggleDarkMode();
110 | setIsDark(!isDark);
111 | }
112 | };
113 |
114 | return (
115 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { jwtVerify } from 'jose';
3 | import { getToken } from 'next-auth/jwt';
4 |
5 | // Secure JWT validation using jose
6 | if (!process.env.JWT_SECRET) {
7 | throw new Error('JWT_SECRET is not defined in environment variables');
8 | }
9 | const secret = new TextEncoder().encode(process.env.JWT_SECRET);
10 |
11 | async function verifyJwt(token) {
12 | try {
13 | const { payload } = await jwtVerify(token, secret, {
14 | algorithms: ['HS256'],
15 | issuer: process.env.JWT_ISSUER,
16 | audience: process.env.JWT_AUDIENCE
17 | });
18 | if (!payload.doctorId || !payload.exp) {
19 | return null;
20 | }
21 | return payload;
22 | } catch (err) {
23 | console.warn('JWT verification failed');
24 | return null;
25 | }
26 | }
27 |
28 | export async function middleware(request) {
29 | const staticAssetPattern = /^\/(darkUI\d\.png|lightUI\d\.png|favicon\.ico|logo\.png|.*\.(css|js|jpg|jpeg|png|webp|avif|svg|ico))$/;
30 | if (staticAssetPattern.test(request.nextUrl.pathname)) {
31 | return NextResponse.next();
32 | }
33 |
34 | // Check NextAuth session first
35 | const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
36 | let isAuthenticated = false;
37 | let doctorId = null;
38 | let userEmail = null;
39 |
40 | // If user has valid NextAuth session (Google auth)
41 | if (token && token.email && token.doctorId) {
42 | isAuthenticated = true;
43 | doctorId = token.doctorId;
44 | userEmail = token.email;
45 | console.log('✅ Middleware: Google auth session found, doctorId:', doctorId);
46 | }
47 |
48 | // If no NextAuth session, check custom JWT authentication
49 | if (!isAuthenticated) {
50 | const doctorAuthCookie = request.cookies.get('doctor-auth');
51 | const cookieValue = doctorAuthCookie?.value;
52 |
53 | if (cookieValue) {
54 | // Use jose for JWT verification
55 | let decoded = null;
56 | if (cookieValue.split('.').length === 3) {
57 | decoded = await verifyJwt(cookieValue);
58 | }
59 | if (decoded && decoded.doctorId) {
60 | doctorId = decoded.doctorId;
61 | isAuthenticated = !!doctorId;
62 | }
63 | }
64 | }
65 |
66 | // If accessing terms or privacy policy, always allow through (no auth required)
67 | if (request.nextUrl.pathname === '/terms' ||
68 | request.nextUrl.pathname === '/privacy') {
69 | console.log('🔓 Middleware: Allowing access to public page:', request.nextUrl.pathname);
70 | return NextResponse.next();
71 | }
72 |
73 | if (request.nextUrl.pathname === '/googlec2f92b121acd7f08.html') {
74 | return NextResponse.next();
75 | }
76 |
77 | // If accessing login page, allow through
78 | if (request.nextUrl.pathname === '/login') {
79 | // If already authenticated with valid doctor ID and accessing login, redirect to home
80 | if (isAuthenticated && doctorId) {
81 | console.log('🔄 Middleware: Redirecting authenticated user from login to home');
82 | return NextResponse.redirect(new URL('/', request.url));
83 | }
84 | console.log('🔓 Middleware: Allowing access to login page');
85 | return NextResponse.next();
86 | }
87 |
88 | // If accessing PIN entry page (legacy), redirect to login
89 | if (request.nextUrl.pathname === '/pin-entry') {
90 | return NextResponse.redirect(new URL('/login', request.url));
91 | }
92 |
93 | // If accessing API routes for PIN verification (legacy), allow through
94 | if (request.nextUrl.pathname.startsWith('/api/verify-pin')) {
95 | return NextResponse.next();
96 | }
97 |
98 | // Allow doctor authentication API routes
99 | if (request.nextUrl.pathname.startsWith('/api/auth')) {
100 | return NextResponse.next();
101 | }
102 |
103 | // Allow logout API route
104 | if (request.nextUrl.pathname.startsWith('/api/logout')) {
105 | return NextResponse.next();
106 | }
107 |
108 | // Allow refresh endpoint through
109 | if (request.nextUrl.pathname.startsWith('/api/auth/refresh')) {
110 | return NextResponse.next();
111 | }
112 |
113 | // For all other routes, check authentication with proper doctor ID
114 | if (!isAuthenticated || !doctorId) {
115 | return NextResponse.redirect(new URL('/login', request.url));
116 | }
117 |
118 | // Add doctor ID to request headers for API routes
119 | const response = NextResponse.next();
120 | if (doctorId) {
121 | response.headers.set('X-Doctor-ID', doctorId);
122 | }
123 | return response;
124 | }
125 |
126 | export const config = {
127 | matcher: [
128 | /*
129 | * Match all request paths except for the ones starting with:
130 | * - _next/static (static files)
131 | * - _next/image (image optimization files)
132 | * - favicon.ico (favicon file)
133 | */
134 | '/((?!_next/static|_next/image|favicon.ico).*)',
135 | ],
136 | };
137 |
--------------------------------------------------------------------------------
/services/imageOptimizationService.js:
--------------------------------------------------------------------------------
1 | // Image optimization service for efficient image delivery
2 | import { getBrowserImageSupport, detectImageFormat, logImageLoad, logImageError } from './imageUtils';
3 |
4 | class ImageOptimizationService {
5 | constructor() {
6 | this.browserSupport = null;
7 | this.loadedFormats = new Map();
8 | this.failedFormats = new Set();
9 | }
10 |
11 | // Initialize browser support detection
12 | async init() {
13 | if (typeof window !== 'undefined' && !this.browserSupport) {
14 | this.browserSupport = getBrowserImageSupport();
15 |
16 | console.log('🖼️ Image Optimization Service initialized:', {
17 | browserSupport: this.browserSupport,
18 | userAgent: navigator.userAgent.split(' ').pop(),
19 | timestamp: new Date().toISOString()
20 | });
21 | }
22 | }
23 |
24 | // Get the optimal image source for a given base name
25 | getOptimalImageSrc(baseName, formats = ['avif', 'webp', 'jpg', 'png']) {
26 | if (!this.browserSupport) {
27 | return `${baseName}.png`; // Fallback if not initialized
28 | }
29 |
30 | // Check for AVIF support first (best compression)
31 | if (formats.includes('avif') && this.browserSupport.avif) {
32 | return `${baseName}.avif`;
33 | }
34 |
35 | // Check for WebP support (good compression)
36 | if (formats.includes('webp') && this.browserSupport.webp) {
37 | return `${baseName}.webp`;
38 | }
39 |
40 | // Fallback to JPEG if available
41 | if (formats.includes('jpg')) {
42 | return `${baseName}.jpg`;
43 | }
44 |
45 | // Final fallback to PNG
46 | return `${baseName}.png`;
47 | }
48 |
49 | // Create picture element sources for responsive images
50 | createPictureSources(baseName, formats = ['avif', 'webp', 'jpg', 'png']) {
51 | const sources = [];
52 |
53 | if (formats.includes('avif')) {
54 | sources.push({
55 | srcSet: `${baseName}.avif`,
56 | type: 'image/avif'
57 | });
58 | }
59 |
60 | if (formats.includes('webp')) {
61 | sources.push({
62 | srcSet: `${baseName}.webp`,
63 | type: 'image/webp'
64 | });
65 | }
66 |
67 | if (formats.includes('jpg')) {
68 | sources.push({
69 | srcSet: `${baseName}.jpg`,
70 | type: 'image/jpeg'
71 | });
72 | }
73 |
74 | return sources;
75 | }
76 |
77 | // Handle image load events with logging
78 | handleImageLoad(event, additionalInfo = {}) {
79 | const src = event.target.currentSrc || event.target.src;
80 | const format = detectImageFormat(src);
81 |
82 | // Track successful loads
83 | this.loadedFormats.set(src, format);
84 |
85 | logImageLoad(format, src, {
86 | ...additionalInfo,
87 | service: 'ImageOptimizationService'
88 | });
89 |
90 | return format;
91 | }
92 |
93 | // Handle image error events with logging
94 | handleImageError(event, additionalInfo = {}) {
95 | const src = event.target.src;
96 | const format = detectImageFormat(src);
97 |
98 | // Track failed loads
99 | this.failedFormats.add(src);
100 |
101 | logImageError(format, src, {
102 | ...additionalInfo,
103 | service: 'ImageOptimizationService'
104 | });
105 |
106 | return format;
107 | }
108 |
109 | // Get performance statistics
110 | getPerformanceStats() {
111 | const stats = {
112 | totalLoaded: this.loadedFormats.size,
113 | totalFailed: this.failedFormats.size,
114 | formatBreakdown: {},
115 | compressionSavings: 0
116 | };
117 |
118 | // Calculate format breakdown
119 | for (const [src, format] of this.loadedFormats) {
120 | stats.formatBreakdown[format] = (stats.formatBreakdown[format] || 0) + 1;
121 | }
122 |
123 | // Estimate compression savings
124 | const avifCount = stats.formatBreakdown['AVIF'] || 0;
125 | const webpCount = stats.formatBreakdown['WebP'] || 0;
126 |
127 | stats.compressionSavings = (avifCount * 0.5) + (webpCount * 0.25); // Estimated savings
128 |
129 | return stats;
130 | }
131 |
132 | // Preload critical images
133 | preloadImage(src, type = 'image/webp') {
134 | if (typeof window !== 'undefined') {
135 | const link = document.createElement('link');
136 | link.rel = 'preload';
137 | link.as = 'image';
138 | link.href = src;
139 | link.type = type;
140 | document.head.appendChild(link);
141 |
142 | console.log(`🚀 Preloading image: ${src} (${type})`);
143 | }
144 | }
145 |
146 | // Check if an image format is supported
147 | isFormatSupported(format) {
148 | if (!this.browserSupport) return false;
149 |
150 | switch (format.toLowerCase()) {
151 | case 'avif':
152 | return this.browserSupport.avif;
153 | case 'webp':
154 | return this.browserSupport.webp;
155 | default:
156 | return true; // PNG, JPEG are universally supported
157 | }
158 | }
159 | }
160 |
161 | // Create singleton instance
162 | const imageOptimizationService = new ImageOptimizationService();
163 |
164 | // Auto-initialize on client side
165 | if (typeof window !== 'undefined') {
166 | imageOptimizationService.init();
167 | }
168 |
169 | export default imageOptimizationService;
--------------------------------------------------------------------------------
/components/AuthGuard.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useSession } from 'next-auth/react';
5 | import { useRouter } from 'next/navigation';
6 | import { usePathname } from 'next/navigation';
7 | import DocPill from './icons/DocPill';
8 |
9 | export default function AuthGuard({ children }) {
10 | const { data: session, status } = useSession();
11 | const [isInitializing, setIsInitializing] = useState(true);
12 | const [doctorContextReady, setDoctorContextReady] = useState(false);
13 | const [minDisplayTimeElapsed, setMinDisplayTimeElapsed] = useState(false);
14 | const router = useRouter();
15 | const pathname = usePathname();
16 |
17 | useEffect(() => {
18 | const initializeDoctorContext = async () => {
19 | try {
20 | // Don't guard the login page, terms, or privacy pages
21 | if (pathname === '/login' || pathname === '/terms' || pathname === '/privacy') {
22 | setIsInitializing(false);
23 | setDoctorContextReady(true);
24 | return;
25 | }
26 |
27 | // If we're loading the session, wait
28 | if (status === 'loading') {
29 | return;
30 | }
31 |
32 | // If not authenticated, redirect to login
33 | if (status === 'unauthenticated') {
34 | router.push('/login');
35 | return;
36 | }
37 |
38 | // If authenticated, check if doctor context is ready
39 | if (status === 'authenticated' && session?.user?.doctorId) {
40 | // Check if localStorage already has the doctor ID
41 | const existingDoctorId = localStorage.getItem('currentDoctorId');
42 |
43 | if (existingDoctorId) {
44 | // Context is already ready
45 | setDoctorContextReady(true);
46 | // Only end initialization when both context is ready AND minimum display time has elapsed
47 | if (minDisplayTimeElapsed) {
48 | setIsInitializing(false);
49 | }
50 | return;
51 | }
52 |
53 | // Wait for StoreDoctorId component to set the context
54 | console.log('🔄 Waiting for doctor context to be initialized...');
55 |
56 | // Set up event listener for when doctor context is ready
57 | const handleDoctorContextReady = () => {
58 | console.log('✅ Doctor context is now ready');
59 | setDoctorContextReady(true);
60 | // Only end initialization when both context is ready AND minimum display time has elapsed
61 | if (minDisplayTimeElapsed) {
62 | setIsInitializing(false);
63 | }
64 | };
65 |
66 | window.addEventListener('doctorContextReady', handleDoctorContextReady);
67 |
68 | // Also poll localStorage as a fallback
69 | const pollInterval = setInterval(() => {
70 | const currentDoctorId = localStorage.getItem('currentDoctorId');
71 | if (currentDoctorId) {
72 | console.log('✅ Doctor context detected via polling');
73 | clearInterval(pollInterval);
74 | window.removeEventListener('doctorContextReady', handleDoctorContextReady);
75 | setDoctorContextReady(true);
76 | // Only end initialization when both context is ready AND minimum display time has elapsed
77 | if (minDisplayTimeElapsed) {
78 | setIsInitializing(false);
79 | }
80 | }
81 | }, 100); // Check every 100ms
82 |
83 | // Timeout after 10 seconds
84 | const timeout = setTimeout(() => {
85 | console.error('❌ Timeout waiting for doctor context');
86 | clearInterval(pollInterval);
87 | window.removeEventListener('doctorContextReady', handleDoctorContextReady);
88 | // If timeout, redirect to login to retry
89 | router.push('/login');
90 | }, 10000);
91 |
92 | // Cleanup function
93 | return () => {
94 | clearInterval(pollInterval);
95 | clearTimeout(timeout);
96 | window.removeEventListener('doctorContextReady', handleDoctorContextReady);
97 | };
98 | } else if (status === 'authenticated' && !session?.user?.doctorId) {
99 | // Authenticated but no doctor ID - this shouldn't happen, redirect to login
100 | console.error('Authenticated but no doctor ID in session');
101 | router.push('/login');
102 | } else {
103 | // No valid session, redirect to login
104 | router.push('/login');
105 | }
106 | } catch (error) {
107 | console.error('Error initializing doctor context:', error);
108 | router.push('/login');
109 | }
110 | };
111 |
112 | initializeDoctorContext();
113 | }, [session, status, router, pathname, minDisplayTimeElapsed]);
114 |
115 | // Minimum display time effect (for testing purposes)
116 | useEffect(() => {
117 | const timer = setTimeout(() => {
118 | console.log('⏰ Minimum display time elapsed');
119 | setMinDisplayTimeElapsed(true);
120 | // If doctor context is already ready, end initialization
121 | if (doctorContextReady) {
122 | setIsInitializing(false);
123 | }
124 | }, 100);
125 |
126 | return () => clearTimeout(timer);
127 | }, [doctorContextReady]);
128 |
129 | // Don't guard the login page, terms, or privacy pages
130 | if (pathname === '/login' || pathname === '/terms' || pathname === '/privacy') {
131 | return children;
132 | }
133 |
134 | // Show loading screen while initializing
135 | if (isInitializing || !doctorContextReady) {
136 | return (
137 |
138 |
139 |
140 |
143 |
144 |
145 | Initializing Application
146 |
147 |
148 | Setting up your doctor profile...
149 |
150 |
151 |
152 | );
153 | }
154 |
155 | // Once doctor context is ready, render the application
156 | return children;
157 | }
--------------------------------------------------------------------------------
/components/ShareModal.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { X, Copy, MessageCircle, CheckCircle, ExternalLink } from 'lucide-react';
5 | import { toast } from 'sonner';
6 |
7 | export default function ShareModal({ isOpen, onClose, shareUrl, title = "Share Document", fileName = "document" }) {
8 | const [copied, setCopied] = useState(false);
9 |
10 | if (!isOpen) return null;
11 |
12 | const copyToClipboard = async () => {
13 | try {
14 | await navigator.clipboard.writeText(shareUrl);
15 | setCopied(true);
16 | toast.success('Link Copied', {
17 | description: 'The share link has been copied to your clipboard'
18 | });
19 |
20 | // Reset copied state after 2 seconds
21 | setTimeout(() => setCopied(false), 2000);
22 | } catch (error) {
23 | console.error('Failed to copy:', error);
24 | toast.error('Copy Failed', {
25 | description: 'Failed to copy link to clipboard'
26 | });
27 | }
28 | };
29 |
30 | const shareOnWhatsApp = () => {
31 | const message = `Hi! I'm sharing a ${title.toLowerCase()} with you. You can view it here: ${shareUrl}`;
32 | const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(message)}`;
33 | window.open(whatsappUrl, '_blank');
34 |
35 | toast.success('Opening WhatsApp', {
36 | description: 'WhatsApp is opening with the share message'
37 | });
38 | };
39 |
40 | const openInNewTab = () => {
41 | window.open(shareUrl, '_blank');
42 | toast.success('Opening Document', {
43 | description: 'Document is opening in a new tab'
44 | });
45 | };
46 |
47 | return (
48 |
49 |
50 | {/* Header */}
51 |
52 |
{title}
53 |
54 |
55 | {/* Content */}
56 |
57 | {/* Share URL Display */}
58 |
59 |
Share Link:
60 |
61 |
67 |
78 |
79 |
80 |
81 | {/* Share Options */}
82 |
83 |
Share Options:
84 |
85 | {/* Copy Link Button */}
86 |
104 |
105 | {/* WhatsApp Share Button */}
106 |
118 |
119 | {/* Open in New Tab Button */}
120 |
132 |
133 |
134 |
135 | {/* Footer */}
136 |
137 |
143 |
144 |
145 |
146 | );
147 | }
--------------------------------------------------------------------------------
/utils/billGenerator.js:
--------------------------------------------------------------------------------
1 | import jsPDF from 'jspdf';
2 | import { formatDate, formatDateTime } from './dateUtils';
3 | import { storage } from './storage';
4 |
5 | export const generateBillPDF = async (bill, patient) => {
6 | const pdf = new jsPDF();
7 | const pageWidth = pdf.internal.pageSize.width;
8 | const pageHeight = pdf.internal.pageSize.height;
9 | const margin = 15;
10 | let yPosition = 20;
11 |
12 | // Get current doctor context
13 | const doctorContext = storage.getDoctorContext();
14 | const doctorName = doctorContext?.name || 'Dr. Prashant Nikam';
15 | const doctorDegree = doctorContext?.degree || 'BAMS (College Name)';
16 | const hospitalName = doctorContext?.hospitalName || 'Chaitanya Hospital';
17 | const hospitalAddress = doctorContext?.hospitalAddress || 'Deola';
18 |
19 | // Get hospital logo for current doctor
20 | let hospitalLogo = null;
21 | try {
22 | if (doctorContext?.doctorId) {
23 | const logoData = await storage.getHospitalLogo(doctorContext.doctorId);
24 | if (logoData && logoData.base64) {
25 | hospitalLogo = logoData.base64;
26 | }
27 | }
28 | } catch (error) {
29 | console.warn('Could not load hospital logo:', error);
30 | }
31 |
32 | // Header Section
33 | // Hospital Logo (left side)
34 | if (hospitalLogo) {
35 | try {
36 | const imageFormat = hospitalLogo.split(';')[0].split('/')[1].toUpperCase();
37 | const validFormats = ['PNG', 'JPEG', 'JPG', 'WEBP', 'AVIF'];
38 | const format = validFormats.includes(imageFormat) ? imageFormat : 'PNG';
39 |
40 | pdf.addImage(hospitalLogo, format, margin, yPosition, 50, 20);
41 | } catch (error) {
42 | console.warn('Could not add hospital logo to PDF:', error);
43 | // Add placeholder text if logo fails to load
44 | pdf.setFillColor(240, 240, 240);
45 | pdf.rect(margin, yPosition, 50, 20, 'F');
46 | pdf.setFontSize(8);
47 | pdf.setFont('helvetica', 'normal');
48 | pdf.setTextColor(120, 120, 120);
49 | pdf.text('HOSPITAL LOGO', margin + 25, yPosition + 12, { align: 'center' });
50 | }
51 | } else {
52 | // Fallback: Show hospital name as placeholder
53 | pdf.setFillColor(240, 240, 240);
54 | pdf.rect(margin, yPosition, 50, 20, 'F');
55 | pdf.setFontSize(8);
56 | pdf.setFont('helvetica', 'normal');
57 | pdf.setTextColor(120, 120, 120);
58 | pdf.text('HOSPITAL LOGO', margin + 25, yPosition + 12, { align: 'center' });
59 | }
60 |
61 | // Doctor Details (right side)
62 | const doctorDetailsX = pageWidth - margin - 80;
63 | pdf.setTextColor(0, 0, 0);
64 |
65 | pdf.setFontSize(16);
66 | pdf.setFont('helvetica', 'bold');
67 | pdf.text(doctorName, doctorDetailsX, yPosition + 5);
68 |
69 | pdf.setFontSize(12);
70 | pdf.setFont('helvetica', 'normal');
71 | pdf.text(doctorDegree, doctorDetailsX, yPosition + 12);
72 |
73 | pdf.setFontSize(10);
74 | pdf.setTextColor(80, 80, 80);
75 | pdf.text(`${hospitalName}, ${hospitalAddress}`, doctorDetailsX, yPosition + 18);
76 |
77 | yPosition += 30;
78 |
79 | // Horizontal Divider
80 | pdf.setDrawColor(0, 0, 0);
81 | pdf.setLineWidth(0.15);
82 | pdf.line(margin, yPosition, pageWidth - margin, yPosition);
83 |
84 | yPosition += 15;
85 |
86 | // Bill Title
87 | pdf.setFontSize(18);
88 | pdf.setFont('helvetica', 'bold');
89 | pdf.setTextColor(0, 0, 0);
90 | pdf.text('MEDICAL BILL', pageWidth / 2, yPosition, { align: 'center' });
91 | yPosition += 15;
92 |
93 | // Bill Information
94 | pdf.setFontSize(12);
95 | pdf.setFont('helvetica', 'bold');
96 | pdf.text('Bill Information', 20, yPosition);
97 |
98 | yPosition += 10;
99 | pdf.setFontSize(11);
100 | pdf.setFont('helvetica', 'normal');
101 | pdf.text(`Bill ID: ${bill.billId}`, 20, yPosition);
102 | pdf.text(`Date: ${formatDate(bill.createdAt)}`, 120, yPosition);
103 |
104 | yPosition += 8;
105 | pdf.text(`Time: ${formatDateTime(bill.createdAt)}`, 20, yPosition);
106 | pdf.text(`Status: ${bill.isPaid ? 'PAID' : 'PENDING'}`, 120, yPosition);
107 |
108 | yPosition += 15;
109 |
110 | // Patient Information
111 | pdf.setFontSize(12);
112 | pdf.setFont('helvetica', 'bold');
113 | pdf.text('Patient Information', 20, yPosition);
114 |
115 | yPosition += 10;
116 | pdf.setFontSize(11);
117 | pdf.setFont('helvetica', 'normal');
118 | pdf.text(`Name: ${patient.name}`, 20, yPosition);
119 | pdf.text(`Patient ID: ${patient.id}`, 120, yPosition);
120 |
121 | yPosition += 8;
122 | pdf.text(`Age: ${patient.age} years`, 20, yPosition);
123 | pdf.text(`Phone: ${patient.phone}`, 120, yPosition);
124 |
125 | yPosition += 20;
126 |
127 | // Bill Details Table
128 | pdf.setFontSize(12);
129 | pdf.setFont('helvetica', 'bold');
130 | pdf.text('Bill Details', 20, yPosition);
131 | yPosition += 10;
132 |
133 | // Table Header
134 | pdf.setFillColor(240, 240, 240);
135 | pdf.rect(20, yPosition, pageWidth - 40, 10, 'F');
136 | pdf.setFontSize(10);
137 | pdf.setFont('helvetica', 'bold');
138 | pdf.text('Description', 25, yPosition + 7);
139 | pdf.text('Amount (₹)', pageWidth - 50, yPosition + 7);
140 | yPosition += 15;
141 |
142 | // Table Content
143 | pdf.setFont('helvetica', 'normal');
144 | pdf.text(bill.description, 25, yPosition);
145 | pdf.text(bill.amount.toString(), pageWidth - 50, yPosition);
146 | yPosition += 10;
147 |
148 | // Total Line
149 | pdf.line(20, yPosition, pageWidth - 20, yPosition);
150 | yPosition += 10;
151 |
152 | // Total Amount
153 | pdf.setFontSize(14);
154 | pdf.setFont('helvetica', 'bold');
155 | pdf.text('Total Amount: ₹' + bill.amount, pageWidth - 80, yPosition);
156 | yPosition += 20;
157 |
158 | // Payment Status
159 | if (bill.isPaid && bill.paidAt) {
160 | pdf.setFontSize(12);
161 | pdf.setFont('helvetica', 'bold');
162 | pdf.setTextColor(0, 150, 0);
163 | pdf.text('PAYMENT RECEIVED', 20, yPosition);
164 | pdf.setFont('helvetica', 'normal');
165 | pdf.setTextColor(0, 0, 0);
166 | yPosition += 8;
167 | pdf.text(`Payment Date: ${formatDateTime(bill.paidAt)}`, 20, yPosition);
168 | yPosition += 15;
169 | } else {
170 | pdf.setFontSize(12);
171 | pdf.setFont('helvetica', 'bold');
172 | pdf.setTextColor(200, 0, 0);
173 | pdf.text('PAYMENT PENDING', 20, yPosition);
174 | pdf.setTextColor(0, 0, 0);
175 | yPosition += 15;
176 | }
177 |
178 | // Footer
179 | const footerY = pageHeight - 30;
180 | pdf.setDrawColor(0, 0, 0);
181 | pdf.setLineWidth(0.15);
182 | pdf.line(margin, footerY - 5, pageWidth - margin, footerY - 5);
183 |
184 | pdf.setFontSize(6);
185 | pdf.setTextColor(100, 100, 100);
186 | pdf.text('This is a computer generated bill and does not require signature.', pageWidth / 2, footerY, { align: 'center' });
187 |
188 | pdf.setFontSize(6);
189 | pdf.text('Thank you for choosing our medical services.', pageWidth / 2, footerY + 4, { align: 'center' });
190 |
191 | // Return blob instead of auto-downloading
192 | return pdf.output('blob');
193 | };
194 |
--------------------------------------------------------------------------------
/components/PillSelector.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useRef, useEffect } from 'react';
4 | import { Search, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
5 | import useScrollToTop from '../hooks/useScrollToTop';
6 |
7 | export default function PillSelector({
8 | title,
9 | items,
10 | onSelect,
11 | searchPlaceholder = "Search...",
12 | addButtonText = "Add Custom",
13 | onAddCustom
14 | }) {
15 | const [searchTerm, setSearchTerm] = useState('');
16 | const [showSearch, setShowSearch] = useState(false);
17 | const scrollContainerRef = useRef(null);
18 | const [canScrollLeft, setCanScrollLeft] = useState(false);
19 | const [canScrollRight, setCanScrollRight] = useState(false);
20 |
21 | // Add scroll to top when component mounts
22 | useScrollToTop();
23 |
24 | const filteredItems = items.filter(item =>
25 | item.toLowerCase().includes(searchTerm.toLowerCase())
26 | );
27 |
28 | const checkScrollability = () => {
29 | if (scrollContainerRef.current) {
30 | const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
31 | setCanScrollLeft(scrollLeft > 0);
32 | setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
33 | }
34 | };
35 |
36 | useEffect(() => {
37 | checkScrollability();
38 | const container = scrollContainerRef.current;
39 | if (container) {
40 | container.addEventListener('scroll', checkScrollability);
41 | return () => container.removeEventListener('scroll', checkScrollability);
42 | }
43 | }, [items]);
44 |
45 | const scroll = (direction) => {
46 | if (scrollContainerRef.current) {
47 | const scrollAmount = 200;
48 | scrollContainerRef.current.scrollBy({
49 | left: direction === 'left' ? -scrollAmount : scrollAmount,
50 | behavior: 'smooth'
51 | });
52 | }
53 | };
54 |
55 | const handleSelect = (item) => {
56 | onSelect(item);
57 | setSearchTerm('');
58 | setShowSearch(false);
59 | };
60 |
61 | const handleAddCustom = () => {
62 | if (searchTerm.trim() && onAddCustom) {
63 | onAddCustom(searchTerm.trim());
64 | setSearchTerm('');
65 | setShowSearch(false);
66 | }
67 | };
68 |
69 | return (
70 |
71 |
72 |
{title}
73 |
74 |
81 | {canScrollLeft && (
82 |
88 | )}
89 | {canScrollRight && (
90 |
96 | )}
97 |
98 |
99 |
100 | {showSearch && (
101 |
102 |
103 |
104 | setSearchTerm(e.target.value)}
109 | className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-0 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 transition-colors"
110 | autoFocus
111 | />
112 |
113 |
114 | {searchTerm && (
115 |
116 | {filteredItems.length > 0 ? (
117 |
118 | {filteredItems.slice(0, 10).map((item, index) => (
119 |
126 | ))}
127 |
128 | ) : (
129 |
130 |
No matches found
131 | {onAddCustom && (
132 |
139 | )}
140 |
141 | )}
142 |
143 | )}
144 |
145 | )}
146 |
147 | {/* Horizontal scrolling pills */}
148 |
149 |
154 | {items.map((item, index) => (
155 |
162 | ))}
163 |
164 |
165 |
166 | );
167 | }
--------------------------------------------------------------------------------
/components/CustomDropdown.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
4 | import { ChevronDown } from 'lucide-react';
5 |
6 | const CustomDropdown = forwardRef(function CustomDropdown({
7 | options,
8 | value,
9 | onChange,
10 | placeholder,
11 | disabled = false,
12 | onEnterPress,
13 | showDirectionToggle = false,
14 | sortDirection = 'desc',
15 | onDirectionToggle
16 | }, ref) {
17 | const [isOpen, setIsOpen] = useState(false);
18 | const [highlightedIndex, setHighlightedIndex] = useState(-1);
19 | const wrapperRef = useRef(null);
20 | const buttonRef = useRef(null);
21 |
22 | const selectedOption = options.find(option => option.value === value);
23 | const selectedIndex = options.findIndex(option => option.value === value);
24 |
25 | // Expose methods to parent component
26 | useImperativeHandle(ref, () => ({
27 | focus: () => {
28 | buttonRef.current?.focus();
29 | },
30 | open: () => {
31 | setIsOpen(true);
32 | buttonRef.current?.focus();
33 | },
34 | close: () => {
35 | setIsOpen(false);
36 | setHighlightedIndex(-1);
37 | }
38 | }));
39 |
40 | useEffect(() => {
41 | function handleClickOutside(event) {
42 | if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
43 | setIsOpen(false);
44 | setHighlightedIndex(-1);
45 | }
46 | }
47 | document.addEventListener("mousedown", handleClickOutside);
48 | return () => {
49 | document.removeEventListener("mousedown", handleClickOutside);
50 | };
51 | }, [wrapperRef]);
52 |
53 | useEffect(() => {
54 | if (isOpen && selectedIndex >= 0) {
55 | setHighlightedIndex(selectedIndex);
56 | }
57 | }, [isOpen, selectedIndex]);
58 |
59 | const handleSelect = (optionValue) => {
60 | onChange(optionValue);
61 | setIsOpen(false);
62 | setHighlightedIndex(-1);
63 | if (onEnterPress) {
64 | onEnterPress();
65 | }
66 | };
67 |
68 | const handleSortDirectionClick = (e) => {
69 | e.preventDefault();
70 | e.stopPropagation();
71 | if (onDirectionToggle) {
72 | onDirectionToggle();
73 | }
74 | };
75 |
76 | const handleMainButtonClick = (e) => {
77 | // Check if the click was on the direction toggle area
78 | if (showDirectionToggle && e.target.closest('.direction-toggle')) {
79 | return; // Don't toggle dropdown if clicking on direction toggle
80 | }
81 | if (!disabled) {
82 | setIsOpen(!isOpen);
83 | }
84 | };
85 |
86 | const handleKeyDown = (e) => {
87 | if (disabled) return;
88 |
89 | switch (e.key) {
90 | case 'Enter':
91 | e.preventDefault();
92 | if (isOpen && highlightedIndex >= 0) {
93 | handleSelect(options[highlightedIndex].value);
94 | } else if (isOpen && selectedOption) {
95 | handleSelect(selectedOption.value);
96 | } else if (!isOpen) {
97 | setIsOpen(true);
98 | } else if (onEnterPress) {
99 | setIsOpen(false);
100 | onEnterPress();
101 | }
102 | break;
103 | case 'ArrowDown':
104 | e.preventDefault();
105 | if (!isOpen) {
106 | setIsOpen(true);
107 | setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
108 | } else {
109 | setHighlightedIndex(prev =>
110 | prev < options.length - 1 ? prev + 1 : 0
111 | );
112 | }
113 | break;
114 | case 'ArrowUp':
115 | e.preventDefault();
116 | if (!isOpen) {
117 | setIsOpen(true);
118 | setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : options.length - 1);
119 | } else {
120 | setHighlightedIndex(prev =>
121 | prev > 0 ? prev - 1 : options.length - 1
122 | );
123 | }
124 | break;
125 | case 'Escape':
126 | setIsOpen(false);
127 | setHighlightedIndex(-1);
128 | buttonRef.current?.blur();
129 | break;
130 | case ' ':
131 | e.preventDefault();
132 | if (!isOpen) {
133 | setIsOpen(true);
134 | }
135 | break;
136 | }
137 | };
138 |
139 | return (
140 |
141 |
145 |
169 |
170 |
171 |
176 | {options.map((option, index) => (
177 |
handleSelect(option.value)}
180 | onMouseEnter={() => setHighlightedIndex(index)}
181 | className={`cursor-pointer mb-1 rounded-lg select-none relative py-2.5 px-3 font-medium transition-colors ${index === highlightedIndex
182 | ? 'bg-blue-50 dark:bg-gray-800 text-blue-600 dark:text-blue-300'
183 | : option.value === value ? 'bg-[#EFF6FF] dark:bg-gray-800 text-[#1D4ED8] dark:text-blue-300' : 'text-[#374151] dark:text-gray-600 hover:bg-[#F3F4F6]'
184 | }`}
185 | >
186 |
187 | {option.label}
188 |
189 |
190 | ))}
191 |
192 |
193 | );
194 | });
195 |
196 | export default CustomDropdown;
197 |
--------------------------------------------------------------------------------
/components/KeyGeneratorModal.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useRef, useEffect } from 'react';
4 | import { Copy, Check } from 'lucide-react';
5 | import { toast } from 'sonner';
6 |
7 | export default function KeyGeneratorTooltip({ isOpen, onClose, triggerRef }) {
8 | const [password, setPassword] = useState('');
9 | const [generatedKey, setGeneratedKey] = useState('');
10 | const [isLoading, setIsLoading] = useState(false);
11 | const [copied, setCopied] = useState(false);
12 | const [state, setState] = useState('input'); // 'input', 'loading', 'result'
13 | const inputRef = useRef(null);
14 | const tooltipRef = useRef(null);
15 | const autoCloseTimerRef = useRef(null);
16 |
17 | useEffect(() => {
18 | if (isOpen && state === 'input') {
19 | setTimeout(() => {
20 | inputRef.current?.focus();
21 | }, 300);
22 | }
23 | }, [isOpen, state]);
24 |
25 | useEffect(() => {
26 | if (isOpen) {
27 | setState('input');
28 | setPassword('');
29 | setGeneratedKey('');
30 | setCopied(false);
31 | setIsLoading(false);
32 | // Clear any existing auto-close timer when opening
33 | if (autoCloseTimerRef.current) {
34 | clearTimeout(autoCloseTimerRef.current);
35 | autoCloseTimerRef.current = null;
36 | }
37 | }
38 | }, [isOpen]);
39 |
40 | const handleKeyPress = async (e) => {
41 | if (e.key === 'Enter' && password.trim() && !isLoading) {
42 | await handleGenerate();
43 | }
44 | };
45 |
46 | const handleGenerate = async () => {
47 | if (!password.trim()) return;
48 |
49 | setState('loading');
50 | setIsLoading(true);
51 |
52 | try {
53 | // Create a minimum loading time promise
54 | const minLoadingTime = new Promise(resolve => setTimeout(resolve, 200));
55 |
56 | const apiCall = fetch('/api/auth/generate-key', {
57 | method: 'POST',
58 | headers: {
59 | 'Content-Type': 'application/json',
60 | },
61 | body: JSON.stringify({ password }),
62 | });
63 |
64 | // Wait for both the API call and minimum loading time
65 | const [response] = await Promise.all([apiCall, minLoadingTime]);
66 | const data = await response.json();
67 |
68 | if (data.success) {
69 | setGeneratedKey(data.key);
70 | setState('result');
71 |
72 | // Automatically copy the key to clipboard
73 | try {
74 | await navigator.clipboard.writeText(data.key);
75 | toast.success('Key generated and copied to clipboard');
76 |
77 | // Set auto-close timer for 2 seconds after successful copy
78 | autoCloseTimerRef.current = setTimeout(() => {
79 | handleClose();
80 | }, 2000);
81 | } catch (copyError) {
82 | toast.success('Registration key generated successfully');
83 | console.warn('Auto-copy failed:', copyError);
84 | }
85 | } else {
86 | toast.error(data.error || 'Failed to generate key');
87 | setState('input');
88 | setPassword('');
89 | }
90 | } catch (error) {
91 | console.error('Key generation error:', error);
92 | toast.error('Network error. Please try again.');
93 | setState('input');
94 | setPassword('');
95 | } finally {
96 | setIsLoading(false);
97 | }
98 | };
99 |
100 | const handleCopy = async () => {
101 | try {
102 | await navigator.clipboard.writeText(generatedKey);
103 | setCopied(true);
104 | toast.success('Key copied to clipboard');
105 | setTimeout(() => setCopied(false), 2000);
106 | } catch (error) {
107 | toast.error('Failed to copy key');
108 | }
109 | };
110 |
111 | const handleClose = () => {
112 | // Clear auto-close timer when manually closing
113 | if (autoCloseTimerRef.current) {
114 | clearTimeout(autoCloseTimerRef.current);
115 | autoCloseTimerRef.current = null;
116 | }
117 | setState('input');
118 | setPassword('');
119 | setGeneratedKey('');
120 | setCopied(false);
121 | onClose();
122 | };
123 |
124 | // Click outside handler
125 | useEffect(() => {
126 | const handleClickOutside = (event) => {
127 | if (isOpen && tooltipRef.current && !tooltipRef.current.contains(event.target) &&
128 | triggerRef.current && !triggerRef.current.contains(event.target)) {
129 | handleClose();
130 | }
131 | };
132 |
133 | if (isOpen) {
134 | document.addEventListener('mousedown', handleClickOutside);
135 | return () => document.removeEventListener('mousedown', handleClickOutside);
136 | }
137 | }, [isOpen]);
138 |
139 | // Cleanup auto-close timer on unmount
140 | useEffect(() => {
141 | return () => {
142 | if (autoCloseTimerRef.current) {
143 | clearTimeout(autoCloseTimerRef.current);
144 | }
145 | };
146 | }, []);
147 |
148 | return (
149 |
162 | {state === 'input' && (
163 |
166 | setPassword(e.target.value)}
171 | onKeyPress={handleKeyPress}
172 | className="w-full px-3 py-2 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-all duration-300"
173 | placeholder="Admin Password"
174 | />
175 |
176 | )}
177 |
178 | {state === 'loading' && (
179 |
184 | )}
185 |
186 | {state === 'result' && (
187 |
190 |
191 |
192 | {generatedKey}
193 |
194 |
205 |
206 |
207 |
208 | )}
209 |
210 | );
211 | }
212 |
--------------------------------------------------------------------------------
/app/api/auth/register/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import bcrypt from 'bcryptjs';
3 | import clientPromise from '../../../../lib/mongodb';
4 | import { Redis } from '@upstash/redis';
5 |
6 | const redis = new Redis({
7 | url: process.env.UPSTASH_REDIS_REST_URL,
8 | token: process.env.UPSTASH_REDIS_REST_TOKEN,
9 | });
10 |
11 | const REGISTER_ATTEMPT_LIMIT = 5;
12 | const REGISTER_ATTEMPT_WINDOW = 30 * 60; // 30 minutes
13 | const REGISTER_LOCKOUT_TIME = 60 * 60; // 1 hour
14 |
15 | async function checkRegisterRateLimit(email, phone, ip) {
16 | const key = `register:attempts:${email}:${phone}:${ip}`;
17 | const lockKey = `register:lockout:${email}:${phone}:${ip}`;
18 | const locked = await redis.get(lockKey);
19 | if (locked) return { locked: true };
20 |
21 | let attempts = await redis.get(key);
22 | attempts = attempts ? parseInt(attempts) : 0;
23 | if (attempts >= REGISTER_ATTEMPT_LIMIT) {
24 | await redis.set(lockKey, '1', { ex: REGISTER_LOCKOUT_TIME });
25 | return { locked: true };
26 | }
27 | return { locked: false, attempts, key, lockKey };
28 | }
29 |
30 | async function incrementRegisterAttempts(key) {
31 | await redis.incr(key);
32 | await redis.expire(key, REGISTER_ATTEMPT_WINDOW);
33 | }
34 |
35 | async function resetRegisterAttempts(key, lockKey) {
36 | await redis.del(key);
37 | await redis.del(lockKey);
38 | }
39 |
40 | // Generate unique doctor ID
41 | function generateDoctorId(firstName, lastName, hospitalName) {
42 | const baseId = `${firstName.toLowerCase().replace(/[^a-z]/g, '')}_${lastName.toLowerCase().replace(/[^a-z]/g, '')}_${hospitalName.toLowerCase().replace(/[^a-z]/g, '')}`;
43 | return baseId;
44 | }
45 |
46 | export async function POST(request) {
47 | // Redirect HTTP to HTTPS (only in production)
48 | if (process.env.NODE_ENV === 'production' && request.headers.get('x-forwarded-proto') === 'http') {
49 | return NextResponse.redirect(`https://${request.headers.get('host')}${request.url}`, 308);
50 | }
51 |
52 | try {
53 | const {
54 | firstName,
55 | lastName,
56 | email,
57 | password,
58 | hospitalName,
59 | hospitalAddress,
60 | degree,
61 | registrationNumber,
62 | phone,
63 | accessKey
64 | } = await request.json();
65 |
66 | // Validate required fields
67 | if (!firstName || !lastName || !email || !password || !hospitalName || !degree || !registrationNumber || !phone) {
68 | return NextResponse.json(
69 | { success: false, error: 'All required fields must be provided including first name, last name, and phone number' },
70 | { status: 400 }
71 | );
72 | }
73 |
74 | // Validate email format
75 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
76 | if (!emailRegex.test(email)) {
77 | return NextResponse.json(
78 | { success: false, error: 'Invalid email format' },
79 | { status: 400 }
80 | );
81 | }
82 |
83 | // Validate phone format (basic validation)
84 | const phoneRegex = /^[\+]?[1-9][\d]{3,14}$/;
85 | if (!phoneRegex.test(phone.replace(/[\s\-\(\)]/g, ''))) {
86 | return NextResponse.json(
87 | { success: false, error: 'Invalid phone number format' },
88 | { status: 400 }
89 | );
90 | }
91 |
92 | // Validate password strength
93 | const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_\-+=\[\]{};':",.<>/?\\|`~]).{8,}$/;
94 | if (!strongPasswordRegex.test(password)) {
95 | return NextResponse.json(
96 | { success: false, error: 'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.' },
97 | { status: 400 }
98 | );
99 | }
100 |
101 | const { doctorService } = await import('../../../../services/doctorService');
102 |
103 | const client = await clientPromise;
104 | const db = client.db('doc-prescrip');
105 | const doctors = db.collection('doctors');
106 |
107 | // Check if email already exists
108 | const emailExists = await doctors.findOne({ email });
109 | if (emailExists) {
110 | return NextResponse.json(
111 | { success: false, error: 'An account with this email already exists' },
112 | { status: 409 }
113 | );
114 | }
115 |
116 | // Check if phone already exists
117 | const phoneExists = await doctors.findOne({ phone });
118 | if (phoneExists) {
119 | return NextResponse.json(
120 | { success: false, error: 'An account with this phone number already exists' },
121 | { status: 409 }
122 | );
123 | }
124 |
125 | let accessType = 'trial';
126 | let expiryDate = new Date();
127 | expiryDate.setMonth(expiryDate.getMonth() + 6); // 6 months from now
128 |
129 | // Validate access key if provided
130 | if (accessKey && accessKey.trim()) {
131 | const keyValid = await doctorService.validateRegistrationKey(accessKey.trim());
132 | if (!keyValid) {
133 | return NextResponse.json(
134 | { success: false, error: 'Invalid or already used access key' },
135 | { status: 400 }
136 | );
137 | }
138 | accessType = 'lifetime_free';
139 | expiryDate = null;
140 | }
141 |
142 | // Generate unique doctor ID
143 | let doctorId = generateDoctorId(firstName, lastName, hospitalName);
144 |
145 | // Ensure uniqueness
146 | let counter = 1;
147 | let uniqueDoctorId = doctorId;
148 | while (await doctors.findOne({ doctorId: uniqueDoctorId })) {
149 | uniqueDoctorId = `${doctorId}_${counter}`;
150 | counter++;
151 | }
152 |
153 | // Hash password
154 | const salt = await bcrypt.genSalt(10);
155 | const passwordHash = await bcrypt.hash(password, salt);
156 |
157 | // Create doctor object
158 | const newDoctor = {
159 | doctorId: uniqueDoctorId,
160 | firstName,
161 | lastName,
162 | name: `${firstName} ${lastName}`,
163 | email,
164 | passwordHash,
165 | hospitalName,
166 | hospitalAddress: hospitalAddress || '',
167 | degree,
168 | registrationNumber,
169 | phone,
170 | accessType,
171 | expiryDate,
172 | emailVerified: false, // Will be verified through NextAuth
173 | isActive: true,
174 | profileComplete: true,
175 | createdAt: new Date(),
176 | updatedAt: new Date()
177 | };
178 |
179 | const ip = request.headers.get('x-forwarded-for') || 'unknown';
180 |
181 | // Rate limiting and lockout
182 | const rate = await checkRegisterRateLimit(email, phone, ip);
183 | if (rate.locked) {
184 | return NextResponse.json(
185 | { success: false, error: 'Too many registration attempts. Please try again later.' },
186 | { status: 429 }
187 | );
188 | }
189 |
190 | // Save doctor
191 | const result = await doctors.insertOne(newDoctor);
192 |
193 | if (result.insertedId) {
194 | await resetRegisterAttempts(rate.key, rate.lockKey);
195 | // If access key was used, mark it as used
196 | if (accessKey && accessKey.trim()) {
197 | await doctorService.useRegistrationKey(accessKey.trim(), doctorId);
198 | }
199 |
200 | return NextResponse.json({
201 | success: true,
202 | message: 'Doctor registered successfully. Please sign in with your credentials.',
203 | doctor: {
204 | doctorId: newDoctor.doctorId,
205 | firstName: newDoctor.firstName,
206 | lastName: newDoctor.lastName,
207 | name: newDoctor.name,
208 | email: newDoctor.email,
209 | hospitalName: newDoctor.hospitalName,
210 | hospitalAddress: newDoctor.hospitalAddress,
211 | degree: newDoctor.degree,
212 | registrationNumber: newDoctor.registrationNumber,
213 | phone: newDoctor.phone,
214 | accessType: newDoctor.accessType,
215 | expiryDate: newDoctor.expiryDate
216 | }
217 | });
218 | } else {
219 | await incrementRegisterAttempts(rate.key);
220 | return NextResponse.json(
221 | { success: false, error: 'Failed to register doctor' },
222 | { status: 500 }
223 | );
224 | }
225 | } catch (error) {
226 | console.error('Doctor registration error:', error);
227 | return NextResponse.json(
228 | { success: false, error: 'Internal server error' },
229 | { status: 500 }
230 | );
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/components/SharePDFButton.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState } from 'react'
3 | import { useSession, signIn } from 'next-auth/react'
4 | import { Share2, Loader2 } from 'lucide-react'
5 | import { storage } from '../utils/storage'
6 | import { toast } from 'sonner'
7 |
8 | export default function SharePDFButton({
9 | pdfUrl,
10 | filename,
11 | phone,
12 | disabled,
13 | type,
14 | patientName,
15 | visitDate,
16 | billDate,
17 | amount,
18 | isPaid,
19 | certificateDate,
20 | certificateFor,
21 | className,
22 | variant = 'button', // 'button' or 'dropdown'
23 | onShare,
24 | customText, // Add customText prop
25 | // Add these props for regeneration
26 | prescription,
27 | patient,
28 | bill
29 | }) {
30 | const { data: session, status } = useSession()
31 | const [isUploading, setIsUploading] = useState(false)
32 |
33 | const generateMessage = () => {
34 | // Get current doctor context
35 | const currentDoctor = storage.getDoctorContext();
36 | const doctorName = currentDoctor?.name || 'Dr. Prashant Nikam';
37 |
38 | if (type === 'prescription') {
39 | return `Hello ${patientName},
40 |
41 | Your prescription for the consultation on ${visitDate} is ready.
42 |
43 | Please find your prescription attached.
44 |
45 | Thank you for visiting us.
46 |
47 | Best regards,
48 | ${doctorName}`
49 | } else if (type === 'bill') {
50 | return `Hello ${patientName},
51 |
52 | Your bill for the consultation on ${billDate} is ready.
53 |
54 | Amount: ₹${amount}
55 | Status: ${isPaid ? 'Paid' : 'Pending'}
56 |
57 | Please find your bill attached.
58 |
59 | Thank you for visiting us.
60 |
61 | Best regards,
62 | ${doctorName}`
63 | } else if (type === 'certificate') {
64 | return `Hello ${patientName},
65 |
66 | Your medical certificate dated ${certificateDate} is ready.
67 |
68 | Certificate Purpose: ${certificateFor}
69 |
70 | Please find your certificate attached.
71 |
72 | Thank you for visiting us.
73 |
74 | Best regards,
75 | ${doctorName}`
76 | }
77 | return `Hello ${patientName}, your document is ready.`
78 | }
79 |
80 | const handleClick = async () => {
81 | if (status === 'loading') {
82 | toast.info('Please Wait', {
83 | description: 'Checking authentication status...'
84 | });
85 | return
86 | }
87 |
88 | if (!session) {
89 | try {
90 | await signIn('google', {
91 | callbackUrl: window.location.href,
92 | redirect: false
93 | })
94 | } catch (error) {
95 | console.error('Sign in error:', error)
96 | toast.error('Sign In Failed', {
97 | description: 'Failed to sign in. Please try again.'
98 | });
99 | }
100 | return
101 | }
102 |
103 | // Check if session has authentication error
104 | if (session.error === "RefreshAccessTokenError") {
105 | toast.warning('Session Expired', {
106 | description: 'Your session has expired. Please sign in again.'
107 | });
108 | signIn('google', { callbackUrl: window.location.href })
109 | return
110 | }
111 |
112 | setIsUploading(true)
113 |
114 | try {
115 | let validPdfUrl = pdfUrl
116 |
117 | // If no PDF URL or we suspect it might be invalid, try to regenerate
118 | if (!validPdfUrl || validPdfUrl.startsWith('blob:')) {
119 | if (type === 'prescription' && prescription && patient) {
120 | validPdfUrl = await storage.regeneratePDFIfNeeded(prescription, patient, 'prescription')
121 | } else if (type === 'bill' && bill && patient) {
122 | validPdfUrl = await storage.regeneratePDFIfNeeded(bill, patient, 'bill')
123 | }
124 | }
125 |
126 | if (!validPdfUrl) {
127 | toast.error('PDF Not Available', {
128 | description: 'PDF is not available. Please try regenerating the document.'
129 | });
130 | return
131 | }
132 |
133 | // Convert blob URL to blob
134 | const response = await fetch(validPdfUrl)
135 | if (!response.ok) {
136 | throw new Error('Failed to fetch PDF')
137 | }
138 |
139 | const blob = await response.blob()
140 |
141 | // Create FormData to send the file
142 | const formData = new FormData()
143 | formData.append('file', blob, filename)
144 | formData.append('filename', filename)
145 | formData.append('patientName', patientName)
146 | formData.append('visitDate', visitDate || new Date().toISOString().split("T")[0])
147 | formData.append('billDate', billDate || new Date().toISOString().split("T")[0])
148 | formData.append('amount', amount || '0')
149 | formData.append('isPaid', isPaid ? 'true' : 'false')
150 | formData.append('certificateDate', certificateDate || new Date().toISOString().split("T")[0])
151 | formData.append('certificateFor', certificateFor || 'General')
152 |
153 | const res = await fetch('/api/upload-to-drive', {
154 | method: 'POST',
155 | body: formData,
156 | })
157 |
158 | const data = await res.json()
159 |
160 | if (!res.ok) {
161 | if (res.status === 401) {
162 | // Token expired or invalid, trigger re-authentication
163 | toast.warning('Authentication Required', {
164 | description: 'Your Google authentication has expired. Please sign in again.'
165 | });
166 | signIn('google', { callbackUrl: window.location.href })
167 | return
168 | }
169 | throw new Error(data.error || 'Upload failed')
170 | }
171 |
172 | if (data.link) {
173 | const message = generateMessage()
174 | const whatsappMessage = `${message}\n\nDocument link: ${data.link}`
175 | const encoded = encodeURIComponent(whatsappMessage)
176 | const phoneNumber = phone.replace(/\D/g, '') // Remove non-digits
177 | const formattedPhone = phoneNumber.startsWith('91') ? phoneNumber : `91${phoneNumber}`
178 |
179 | window.open(`https://wa.me/${formattedPhone}?text=${encoded}`, '_blank')
180 |
181 | toast.success('Document Shared', {
182 | description: `${type === 'prescription' ? 'Prescription' : type === 'bill' ? 'Bill' : 'Certificate'} shared via WhatsApp successfully`
183 | });
184 |
185 | // Call onShare callback if provided (for dropdown variant)
186 | if (onShare) {
187 | onShare()
188 | }
189 | } else {
190 | throw new Error('No link received from Google Drive')
191 | }
192 | } catch (error) {
193 | console.error('Error sharing PDF:', error)
194 | if (error.message.includes('Unauthorized') || error.message.includes('authentication')) {
195 | toast.warning('Authentication Required', {
196 | description: 'Please sign in with Google to share documents'
197 | });
198 | signIn('google', { callbackUrl: window.location.href })
199 | } else {
200 | toast.error('Share Failed', {
201 | description: `Failed to share PDF: ${error.message}`
202 | });
203 | }
204 | } finally {
205 | setIsUploading(false)
206 | }
207 | }
208 |
209 | // Dropdown variant styling
210 | if (variant === 'dropdown') {
211 | return (
212 |
220 | )
221 | }
222 |
223 | // Default button variant styling
224 | return (
225 |
239 | )
240 | }
241 |
--------------------------------------------------------------------------------
/components/CustomSelect.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useRef, useEffect } from 'react';
4 | import { ChevronDown, Check } from 'lucide-react';
5 |
6 | export default function CustomSelect({ options, value, onChange, placeholder, onAddNew }) {
7 | const [isOpen, setIsOpen] = useState(false);
8 | const [searchTerm, setSearchTerm] = useState('');
9 | const [highlightedIndex, setHighlightedIndex] = useState(-1);
10 | const wrapperRef = useRef(null);
11 | const inputRef = useRef(null);
12 | const optionsListRef = useRef(null);
13 |
14 | const selectedOption = options.find(option => option.value === value);
15 |
16 | useEffect(() => {
17 | function handleClickOutside(event) {
18 | if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
19 | setIsOpen(false);
20 | setSearchTerm('');
21 | setHighlightedIndex(-1);
22 | }
23 | }
24 | document.addEventListener("mousedown", handleClickOutside);
25 | return () => {
26 | document.removeEventListener("mousedown", handleClickOutside);
27 | };
28 | }, [wrapperRef]);
29 |
30 | useEffect(() => {
31 | if (isOpen && inputRef.current) {
32 | inputRef.current.focus();
33 | }
34 | }, [isOpen]);
35 |
36 | // Reset highlighted index when search term changes
37 | useEffect(() => {
38 | setHighlightedIndex(-1);
39 | }, [searchTerm]);
40 |
41 | // Scroll highlighted option into view
42 | useEffect(() => {
43 | if (highlightedIndex >= 0 && optionsListRef.current) {
44 | const highlightedElement = optionsListRef.current.children[highlightedIndex];
45 | if (highlightedElement) {
46 | highlightedElement.scrollIntoView({
47 | behavior: 'smooth',
48 | block: 'nearest'
49 | });
50 | }
51 | }
52 | }, [highlightedIndex]);
53 |
54 | const filteredOptions = options.filter(option =>
55 | option.label.toLowerCase().includes(searchTerm.toLowerCase())
56 | );
57 |
58 | const handleSelect = (optionValue) => {
59 | onChange(optionValue);
60 | setIsOpen(false);
61 | setSearchTerm('');
62 | setHighlightedIndex(-1);
63 | };
64 |
65 | const handleKeyDown = (e) => {
66 | if (!isOpen) {
67 | if (e.key === 'Enter' || e.key === ' ') {
68 | e.preventDefault();
69 | setIsOpen(true);
70 | }
71 | return;
72 | }
73 |
74 | switch (e.key) {
75 | case 'Enter':
76 | e.preventDefault();
77 | if (searchTerm.length >= 1) {
78 | // If user is searching and has typed at least one letter
79 | if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
80 | // Select highlighted option
81 | handleSelect(filteredOptions[highlightedIndex].value);
82 | } else if (filteredOptions.length > 0) {
83 | // Select first (most relevant) option
84 | handleSelect(filteredOptions[0].value);
85 | } else if (onAddNew && searchTerm.trim()) {
86 | // No matches found, create new with search term
87 | onAddNew(searchTerm.trim());
88 | setIsOpen(false);
89 | setSearchTerm('');
90 | setHighlightedIndex(-1);
91 | }
92 | } else if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
93 | // No search term, select highlighted option
94 | handleSelect(filteredOptions[highlightedIndex].value);
95 | }
96 | break;
97 |
98 | case 'ArrowDown':
99 | e.preventDefault();
100 | setHighlightedIndex(prev => {
101 | const maxIndex = filteredOptions.length - 1;
102 | return prev < maxIndex ? prev + 1 : 0;
103 | });
104 | break;
105 |
106 | case 'ArrowUp':
107 | e.preventDefault();
108 | setHighlightedIndex(prev => {
109 | const maxIndex = filteredOptions.length - 1;
110 | return prev > 0 ? prev - 1 : maxIndex;
111 | });
112 | break;
113 |
114 | case 'Escape':
115 | e.preventDefault();
116 | setIsOpen(false);
117 | setSearchTerm('');
118 | setHighlightedIndex(-1);
119 | break;
120 |
121 | case 'Tab':
122 | // Allow tab to close dropdown and move to next element
123 | setIsOpen(false);
124 | setSearchTerm('');
125 | setHighlightedIndex(-1);
126 | break;
127 |
128 | default:
129 | // For any other key, reset highlighted index to allow natural search
130 | if (e.key.length === 1) {
131 | setHighlightedIndex(-1);
132 | }
133 | break;
134 | }
135 | };
136 |
137 | const handleMouseEnter = (index) => {
138 | setHighlightedIndex(index);
139 | };
140 |
141 | const handleMouseLeave = () => {
142 | setHighlightedIndex(-1);
143 | };
144 |
145 | return (
146 |
147 |
158 |
159 |
164 |
165 | setSearchTerm(e.target.value)}
171 | onKeyDown={handleKeyDown}
172 | className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md text-sm focus:outline-none focus:ring-0 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500"
173 | />
174 |
175 |
176 |
177 | {filteredOptions.length > 0 ? (
178 | filteredOptions.map((option, index) => (
179 |
handleSelect(option.value)}
182 | onMouseEnter={() => handleMouseEnter(index)}
183 | onMouseLeave={handleMouseLeave}
184 | className={`cursor-pointer select-none relative py-2 pl-3 pr-9 text-gray-900 dark:text-white flex items-center transition-colors ${
185 | highlightedIndex === index
186 | ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
187 | : option.value === value
188 | ? 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
189 | : 'hover:bg-gray-50 dark:hover:bg-gray-800'
190 | }`}
191 | >
192 |
193 | {option.label}
194 |
195 | {option.value === value && (
196 |
197 |
198 |
199 | )}
200 |
201 | ))
202 | ) : (
203 | onAddNew ? (
204 |
{
206 | onAddNew(searchTerm.trim());
207 | setIsOpen(false);
208 | setSearchTerm('');
209 | setHighlightedIndex(-1);
210 | }}
211 | className="cursor-pointer select-none relative py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 text-blue-600 text-sm"
212 | >
213 | Add "{searchTerm}" as new patient
214 |
215 | ) : (
216 |
217 | No options found.
218 |
219 | )
220 | )}
221 |
222 |
223 | {onAddNew && filteredOptions.length > 0 && (
224 |
{
226 | onChange('new');
227 | setIsOpen(false);
228 | setSearchTerm('');
229 | setHighlightedIndex(-1);
230 | }}
231 | className="cursor-pointer select-none relative py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 text-blue-600 border-t border-gray-200 dark:border-gray-600 mt-1 pt-2 text-sm"
232 | >
233 | Add New Patient...
234 |
235 | )}
236 |
237 |
238 | );
239 | }
240 |
--------------------------------------------------------------------------------