├── app
├── favicon.ico
├── api
│ └── sentry-example-api
│ │ └── route.ts
├── loading.tsx
├── global-error.tsx
├── layout.tsx
├── patients
│ └── [userId]
│ │ ├── new-appointment
│ │ ├── page.tsx
│ │ └── success
│ │ │ └── page.tsx
│ │ └── register
│ │ └── page.tsx
├── page.tsx
├── admin
│ └── page.tsx
├── sentry-example-page
│ └── page.tsx
└── globals.css
├── public
├── favicon.ico
├── assets
│ ├── gifs
│ │ └── success.gif
│ ├── images
│ │ ├── admin.png
│ │ ├── dr-cruz.png
│ │ ├── dr-lee.png
│ │ ├── dr-green.png
│ │ ├── dr-peter.png
│ │ ├── dr-powell.png
│ │ ├── dr-sharma.png
│ │ ├── cancelled-bg.png
│ │ ├── dr-cameron.png
│ │ ├── dr-remirez.png
│ │ ├── pending-bg.png
│ │ ├── register-img.png
│ │ ├── dr-livingston.png
│ │ ├── onboarding-img.png
│ │ ├── appointment-img.png
│ │ └── appointments-bg.png
│ └── icons
│ │ ├── check.svg
│ │ ├── arrow.svg
│ │ ├── user.svg
│ │ ├── email.svg
│ │ ├── upload.svg
│ │ ├── appointments.svg
│ │ ├── cancelled.svg
│ │ ├── calendar.svg
│ │ ├── pending.svg
│ │ ├── close.svg
│ │ ├── check-circle.svg
│ │ ├── loader.svg
│ │ ├── logo-icon.svg
│ │ └── logo-full.svg
├── vercel.svg
└── next.svg
├── postcss.config.mjs
├── instrumentation.ts
├── components
├── ThemeProvider.tsx
├── ui
│ ├── label.tsx
│ ├── separator.tsx
│ ├── textarea.tsx
│ ├── input.tsx
│ ├── checkbox.tsx
│ ├── popover.tsx
│ ├── radio-group.tsx
│ ├── button.tsx
│ ├── input-otp.tsx
│ ├── table.tsx
│ ├── dialog.tsx
│ ├── form.tsx
│ ├── alert-dialog.tsx
│ ├── command.tsx
│ └── select.tsx
├── SubmitButton.tsx
├── StatusBadge.tsx
├── StatCard.tsx
├── FileUploader.tsx
├── AppointmentModal.tsx
├── forms
│ ├── PatientForm.tsx
│ ├── AppointmentForm.tsx
│ └── RegisterForm.tsx
├── table
│ ├── columns.tsx
│ └── DataTable.tsx
├── PasskeyModal.tsx
└── CustomFormField.tsx
├── components.json
├── .vscode
└── settings.json
├── .gitignore
├── lib
├── appwrite.config.ts
├── utils.ts
├── actions
│ ├── patient.actions.ts
│ └── appointment.actions.ts
└── validation.ts
├── .eslintrc.json
├── tsconfig.json
├── sentry.server.config.ts
├── sentry.edge.config.ts
├── types
├── appwrite.types.ts
└── index.d.ts
├── sentry.client.config.ts
├── next.config.mjs
├── package.json
├── constants
└── index.ts
├── tailwind.config.ts
└── README.md
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/assets/gifs/success.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/gifs/success.gif
--------------------------------------------------------------------------------
/public/assets/images/admin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/admin.png
--------------------------------------------------------------------------------
/public/assets/images/dr-cruz.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-cruz.png
--------------------------------------------------------------------------------
/public/assets/images/dr-lee.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-lee.png
--------------------------------------------------------------------------------
/public/assets/images/dr-green.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-green.png
--------------------------------------------------------------------------------
/public/assets/images/dr-peter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-peter.png
--------------------------------------------------------------------------------
/public/assets/images/dr-powell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-powell.png
--------------------------------------------------------------------------------
/public/assets/images/dr-sharma.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-sharma.png
--------------------------------------------------------------------------------
/public/assets/images/cancelled-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/cancelled-bg.png
--------------------------------------------------------------------------------
/public/assets/images/dr-cameron.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-cameron.png
--------------------------------------------------------------------------------
/public/assets/images/dr-remirez.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-remirez.png
--------------------------------------------------------------------------------
/public/assets/images/pending-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/pending-bg.png
--------------------------------------------------------------------------------
/public/assets/images/register-img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/register-img.png
--------------------------------------------------------------------------------
/public/assets/images/dr-livingston.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/dr-livingston.png
--------------------------------------------------------------------------------
/public/assets/images/onboarding-img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/onboarding-img.png
--------------------------------------------------------------------------------
/public/assets/images/appointment-img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/appointment-img.png
--------------------------------------------------------------------------------
/public/assets/images/appointments-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/healthcare/HEAD/public/assets/images/appointments-bg.png
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/assets/icons/check.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/instrumentation.ts:
--------------------------------------------------------------------------------
1 | export async function register() {
2 | if (process.env.NEXT_RUNTIME === "nodejs") {
3 | await import("./sentry.server.config");
4 | }
5 |
6 | if (process.env.NEXT_RUNTIME === "edge") {
7 | await import("./sentry.edge.config");
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/public/assets/icons/arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThemeProvider as NextThemesProvider } from "next-themes";
4 | import { type ThemeProviderProps } from "next-themes/dist/types";
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children};
8 | }
9 |
--------------------------------------------------------------------------------
/app/api/sentry-example-api/route.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unreachable */
2 | import { NextResponse } from "next/server";
3 |
4 | export const dynamic = "force-dynamic";
5 |
6 | // A faulty API route to test Sentry's error monitoring
7 | export function GET() {
8 | throw new Error("Sentry Example API Route Error");
9 | return NextResponse.json({ data: "Testing Sentry Error..." });
10 | }
11 |
--------------------------------------------------------------------------------
/public/assets/icons/user.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export default function Loading() {
4 | return (
5 |
6 |
13 | Loading...
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": false,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/public/assets/icons/email.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll.eslint": "explicit",
6 | "source.addMissingImports": "explicit"
7 | },
8 | "prettier.tabWidth": 2,
9 | "prettier.useTabs": false,
10 | "prettier.semi": true,
11 | "prettier.singleQuote": false,
12 | "prettier.jsxSingleQuote": false,
13 | "prettier.trailingComma": "es5",
14 | "prettier.arrowParens": "always",
15 | "[typescriptreact]": {
16 | "editor.defaultFormatter": "esbenp.prettier-vscode"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.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.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # Sentry Config File
39 | .env.sentry-build-plugin
40 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/icons/upload.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/lib/appwrite.config.ts:
--------------------------------------------------------------------------------
1 | import * as sdk from "node-appwrite";
2 |
3 | export const {
4 | NEXT_PUBLIC_ENDPOINT: ENDPOINT,
5 | PROJECT_ID,
6 | API_KEY,
7 | DATABASE_ID,
8 | PATIENT_COLLECTION_ID,
9 | DOCTOR_COLLECTION_ID,
10 | APPOINTMENT_COLLECTION_ID,
11 | NEXT_PUBLIC_BUCKET_ID: BUCKET_ID,
12 | } = process.env;
13 |
14 | const client = new sdk.Client();
15 |
16 | client.setEndpoint(ENDPOINT!).setProject(PROJECT_ID!).setKey(API_KEY!);
17 |
18 | export const databases = new sdk.Databases(client);
19 | export const users = new sdk.Users(client);
20 | export const messaging = new sdk.Messaging(client);
21 | export const storage = new sdk.Storage(client);
22 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["import"],
3 | "extends": [
4 | "next/core-web-vitals",
5 | "standard",
6 | "plugin:tailwindcss/recommended",
7 | "prettier"
8 | ],
9 | "rules": {
10 | "no-undef": "off",
11 | "import/order": [
12 | "error",
13 | {
14 | "groups": [
15 | "builtin",
16 | "external",
17 | "internal",
18 | "parent",
19 | "sibling",
20 | "index"
21 | ],
22 | "newlines-between": "always",
23 | "alphabetize": {
24 | "order": "asc",
25 | "caseInsensitive": true
26 | }
27 | }
28 | ]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/public/assets/icons/appointments.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/cancelled.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/calendar.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as Sentry from "@sentry/nextjs";
4 | import NextError from "next/error";
5 | import { useEffect } from "react";
6 |
7 | export default function GlobalError({
8 | error,
9 | }: {
10 | error: Error & { digest?: string };
11 | }) {
12 | useEffect(() => {
13 | Sentry.captureException(error);
14 | }, [error]);
15 |
16 | return (
17 |
18 |
19 | {/* `NextError` is the default Next.js error page component. Its type
20 | definition requires a `statusCode` prop. However, since the App Router
21 | does not expose status codes for errors, we simply pass 0 to render a
22 | generic error message. */}
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/sentry.server.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from "@sentry/nextjs";
6 |
7 | Sentry.init({
8 | dsn: "https://3d627de24f5d06a1fc39000a06ca9a94@o4506813739368448.ingest.us.sentry.io/4507458386526208",
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 |
16 | // Uncomment the line below to enable Spotlight (https://spotlightjs.com)
17 | // spotlight: process.env.NODE_ENV === 'development',
18 | });
19 |
--------------------------------------------------------------------------------
/sentry.edge.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
2 | // The config you add here will be used whenever one of the edge features is loaded.
3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
5 |
6 | import * as Sentry from "@sentry/nextjs";
7 |
8 | Sentry.init({
9 | dsn: "https://3d627de24f5d06a1fc39000a06ca9a94@o4506813739368448.ingest.us.sentry.io/4507458386526208",
10 |
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1,
13 |
14 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
15 | debug: false,
16 | });
17 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as LabelPrimitive from "@radix-ui/react-label";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/components/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { Button } from "./ui/button";
4 |
5 | interface ButtonProps {
6 | isLoading: boolean;
7 | className?: string;
8 | children: React.ReactNode;
9 | }
10 |
11 | const SubmitButton = ({ isLoading, className, children }: ButtonProps) => {
12 | return (
13 |
33 | );
34 | };
35 |
36 | export default SubmitButton;
37 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
4 | import * as React from "react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/components/StatusBadge.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import Image from "next/image";
3 |
4 | import { StatusIcon } from "@/constants";
5 |
6 | export const StatusBadge = ({ status }: { status: Status }) => {
7 | return (
8 |
15 |
22 |
29 | {status}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/public/assets/icons/pending.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/components/StatCard.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import Image from "next/image";
3 |
4 | type StatCardProps = {
5 | type: "appointments" | "pending" | "cancelled";
6 | count: number;
7 | label: string;
8 | icon: string;
9 | };
10 |
11 | export const StatCard = ({ count = 0, label, icon, type }: StatCardProps) => {
12 | return (
13 |
20 |
21 |
28 |
{count}
29 |
30 |
31 |
{label}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/types/appwrite.types.ts:
--------------------------------------------------------------------------------
1 | import { Models } from "node-appwrite";
2 |
3 | export interface Patient extends Models.Document {
4 | userId: string;
5 | name: string;
6 | email: string;
7 | phone: string;
8 | birthDate: Date;
9 | gender: Gender;
10 | address: string;
11 | occupation: string;
12 | emergencyContactName: string;
13 | emergencyContactNumber: string;
14 | primaryPhysician: string;
15 | insuranceProvider: string;
16 | insurancePolicyNumber: string;
17 | allergies: string | undefined;
18 | currentMedication: string | undefined;
19 | familyMedicalHistory: string | undefined;
20 | pastMedicalHistory: string | undefined;
21 | identificationType: string | undefined;
22 | identificationNumber: string | undefined;
23 | identificationDocument: FormData | undefined;
24 | privacyConsent: boolean;
25 | }
26 |
27 | export interface Appointment extends Models.Document {
28 | patient: Patient;
29 | schedule: Date;
30 | status: Status;
31 | primaryPhysician: string;
32 | reason: string;
33 | note: string;
34 | userId: string;
35 | cancellationReason: string | null;
36 | }
37 |
--------------------------------------------------------------------------------
/sentry.client.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the client.
2 | // The config you add here will be used whenever a users loads a page in their browser.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from "@sentry/nextjs";
6 |
7 | Sentry.init({
8 | dsn: "https://3d627de24f5d06a1fc39000a06ca9a94@o4506813739368448.ingest.us.sentry.io/4507458386526208",
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 |
16 | replaysOnErrorSampleRate: 1.0,
17 |
18 | // This sets the sample rate to be 10%. You may want this to be 100% while
19 | // in development and sample at a lower rate in production
20 | replaysSessionSampleRate: 0.1,
21 |
22 | // You can remove this option if you're not planning to use the Sentry Session Replay feature:
23 | integrations: [
24 | Sentry.replayIntegration({
25 | // Additional Replay configuration goes in here, for example:
26 | maskAllText: true,
27 | blockAllMedia: true,
28 | }),
29 | ],
30 | });
31 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import "./globals.css";
3 | import { Plus_Jakarta_Sans as FontSans } from "next/font/google";
4 | import { ThemeProvider } from "next-themes";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const fontSans = FontSans({
9 | subsets: ["latin"],
10 | weight: ["300", "400", "500", "600", "700"],
11 | variable: "--font-sans",
12 | });
13 |
14 | export const metadata: Metadata = {
15 | title: "CarePulse",
16 | description:
17 | "A healthcare patient management System designed to streamline patient registration, appointment scheduling, and medical records management for healthcare providers.",
18 | icons: {
19 | icon: "/assets/icons/logo-icon.svg",
20 | },
21 | };
22 |
23 | export default function RootLayout({
24 | children,
25 | }: Readonly<{
26 | children: React.ReactNode;
27 | }>) {
28 | return (
29 |
30 |
36 |
37 | {children}
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/patients/[userId]/new-appointment/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { AppointmentForm } from "@/components/forms/AppointmentForm";
4 | import { getPatient } from "@/lib/actions/patient.actions";
5 |
6 | const Appointment = async ({ params: { userId } }: SearchParamProps) => {
7 | const patient = await getPatient(userId);
8 |
9 | return (
10 |
11 |
12 |
13 |
20 |
21 |
26 |
27 |
© 2024 CarePluse
28 |
29 |
30 |
31 |
38 |
39 | );
40 | };
41 |
42 | export default Appointment;
43 |
--------------------------------------------------------------------------------
/app/patients/[userId]/register/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { redirect } from "next/navigation";
3 |
4 | import RegisterForm from "@/components/forms/RegisterForm";
5 | import { getPatient, getUser } from "@/lib/actions/patient.actions";
6 |
7 | const Register = async ({ params: { userId } }: SearchParamProps) => {
8 | const user = await getUser(userId);
9 | const patient = await getPatient(userId);
10 |
11 | if (patient) redirect(`/patients/${userId}/new-appointment`);
12 |
13 | return (
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
© 2024 CarePluse
28 |
29 |
30 |
31 |
38 |
39 | );
40 | };
41 |
42 | export default Register;
43 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
4 | import { Check } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ));
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
29 |
30 | export { Checkbox };
31 |
--------------------------------------------------------------------------------
/public/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as PopoverPrimitive from "@radix-ui/react-popover";
4 | import * as React from "react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent };
32 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import { PatientForm } from "@/components/forms/PatientForm";
5 | import { PasskeyModal } from "@/components/PasskeyModal";
6 |
7 | const Home = ({ searchParams }: SearchParamProps) => {
8 | const isAdmin = searchParams?.admin === "true";
9 |
10 | return (
11 |
12 | {isAdmin &&
}
13 |
14 |
15 |
16 |
23 |
24 |
25 |
26 |
27 |
28 | © 2024 CarePluse
29 |
30 |
31 | Admin
32 |
33 |
34 |
35 |
36 |
37 |
44 |
45 | );
46 | };
47 |
48 | export default Home;
49 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { withSentryConfig } from "@sentry/nextjs";
2 | /** @type {import('next').NextConfig} */
3 | const nextConfig = {};
4 |
5 | export default withSentryConfig(nextConfig, {
6 | // For all available options, see:
7 | // https://github.com/getsentry/sentry-webpack-plugin#options
8 |
9 | org: "javascript-mastery",
10 | project: "care-pulse",
11 |
12 | // Only print logs for uploading source maps in CI
13 | silent: !process.env.CI,
14 |
15 | // For all available options, see:
16 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
17 |
18 | // Upload a larger set of source maps for prettier stack traces (increases build time)
19 | widenClientFileUpload: true,
20 |
21 | // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
22 | // This can increase your server load as well as your hosting bill.
23 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
24 | // side errors will fail.
25 | // tunnelRoute: "/monitoring",
26 |
27 | // Hides source maps from generated client bundles
28 | hideSourceMaps: true,
29 |
30 | // Automatically tree-shake Sentry logger statements to reduce bundle size
31 | disableLogger: true,
32 |
33 | // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
34 | // See the following for more information:
35 | // https://docs.sentry.io/product/crons/
36 | // https://vercel.com/docs/cron-jobs
37 | automaticVercelMonitors: true,
38 | });
39 |
--------------------------------------------------------------------------------
/components/FileUploader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import React, { useCallback } from "react";
5 | import { useDropzone } from "react-dropzone";
6 |
7 | import { convertFileToUrl } from "@/lib/utils";
8 |
9 | type FileUploaderProps = {
10 | files: File[] | undefined;
11 | onChange: (files: File[]) => void;
12 | };
13 |
14 | export const FileUploader = ({ files, onChange }: FileUploaderProps) => {
15 | const onDrop = useCallback((acceptedFiles: File[]) => {
16 | onChange(acceptedFiles);
17 | }, []);
18 |
19 | const { getRootProps, getInputProps } = useDropzone({ onDrop });
20 |
21 | return (
22 |
23 |
24 | {files && files?.length > 0 ? (
25 |
32 | ) : (
33 | <>
34 |
40 |
41 |
42 | Click to upload
43 | or drag and drop
44 |
45 |
46 | SVG, PNG, JPG or GIF (max. 800x400px)
47 |
48 |
49 | >
50 | )}
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 |
3 | declare type SearchParamProps = {
4 | params: { [key: string]: string };
5 | searchParams: { [key: string]: string | string[] | undefined };
6 | };
7 |
8 | declare type Gender = "Male" | "Female" | "Other";
9 | declare type Status = "pending" | "scheduled" | "cancelled";
10 |
11 | declare interface CreateUserParams {
12 | name: string;
13 | email: string;
14 | phone: string;
15 | }
16 | declare interface User extends CreateUserParams {
17 | $id: string;
18 | }
19 |
20 | declare interface RegisterUserParams extends CreateUserParams {
21 | userId: string;
22 | birthDate: Date;
23 | gender: Gender;
24 | address: string;
25 | occupation: string;
26 | emergencyContactName: string;
27 | emergencyContactNumber: string;
28 | primaryPhysician: string;
29 | insuranceProvider: string;
30 | insurancePolicyNumber: string;
31 | allergies: string | undefined;
32 | currentMedication: string | undefined;
33 | familyMedicalHistory: string | undefined;
34 | pastMedicalHistory: string | undefined;
35 | identificationType: string | undefined;
36 | identificationNumber: string | undefined;
37 | identificationDocument: FormData | undefined;
38 | privacyConsent: boolean;
39 | }
40 |
41 | declare type CreateAppointmentParams = {
42 | userId: string;
43 | patient: string;
44 | primaryPhysician: string;
45 | reason: string;
46 | schedule: Date;
47 | status: Status;
48 | note: string | undefined;
49 | };
50 |
51 | declare type UpdateAppointmentParams = {
52 | appointmentId: string;
53 | userId: string;
54 | timeZone: string;
55 | appointment: Appointment;
56 | type: string;
57 | };
58 |
--------------------------------------------------------------------------------
/public/assets/icons/check-circle.svg:
--------------------------------------------------------------------------------
1 |
24 |
--------------------------------------------------------------------------------
/components/AppointmentModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "@/components/ui/dialog";
14 | import { Appointment } from "@/types/appwrite.types";
15 |
16 | import { AppointmentForm } from "./forms/AppointmentForm";
17 |
18 | import "react-datepicker/dist/react-datepicker.css";
19 |
20 | export const AppointmentModal = ({
21 | patientId,
22 | userId,
23 | appointment,
24 | type,
25 | }: {
26 | patientId: string;
27 | userId: string;
28 | appointment?: Appointment;
29 | type: "schedule" | "cancel";
30 | title: string;
31 | description: string;
32 | }) => {
33 | const [open, setOpen] = useState(false);
34 |
35 | return (
36 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
4 | import { Circle } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | );
20 | });
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | );
41 | });
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
43 |
44 | export { RadioGroup, RadioGroupItem };
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "carepulse",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.3.4",
13 | "@radix-ui/react-alert-dialog": "^1.0.5",
14 | "@radix-ui/react-checkbox": "^1.0.4",
15 | "@radix-ui/react-dialog": "^1.0.5",
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-popover": "^1.0.7",
18 | "@radix-ui/react-radio-group": "^1.1.3",
19 | "@radix-ui/react-select": "^2.0.0",
20 | "@radix-ui/react-separator": "^1.0.3",
21 | "@radix-ui/react-slot": "^1.0.2",
22 | "@sentry/nextjs": "^8.9.2",
23 | "@tanstack/react-table": "^8.17.0",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.1.1",
26 | "cmdk": "^1.0.0",
27 | "eslint-config-prettier": "^9.1.0",
28 | "eslint-config-standard": "^17.1.0",
29 | "eslint-plugin-tailwindcss": "^3.15.1",
30 | "fs": "^0.0.1-security",
31 | "input-otp": "^1.2.4",
32 | "lucide-react": "^0.378.0",
33 | "next": "14.2.3",
34 | "next-themes": "^0.3.0",
35 | "node-appwrite": "^12.0.1",
36 | "prettier": "^3.2.5",
37 | "react": "^18",
38 | "react-datepicker": "^6.9.0",
39 | "react-dom": "^18",
40 | "react-dropzone": "^14.2.3",
41 | "react-hook-form": "^7.51.4",
42 | "react-phone-number-input": "^3.4.1",
43 | "tailwind-merge": "^2.3.0",
44 | "tailwindcss-animate": "^1.0.7",
45 | "twilio": "^5.0.4",
46 | "zod": "^3.23.6"
47 | },
48 | "devDependencies": {
49 | "@types/node": "^20",
50 | "@types/react": "^18",
51 | "@types/react-datepicker": "^6.2.0",
52 | "@types/react-dom": "^18",
53 | "eslint": "^8",
54 | "eslint-config-next": "14.2.3",
55 | "eslint-plugin-import": "^2.29.1",
56 | "postcss": "^8",
57 | "tailwindcss": "^3.4.1",
58 | "typescript": "^5"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/admin/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import { StatCard } from "@/components/StatCard";
5 | import { columns } from "@/components/table/columns";
6 | import { DataTable } from "@/components/table/DataTable";
7 | import { getRecentAppointmentList } from "@/lib/actions/appointment.actions";
8 |
9 | const AdminPage = async () => {
10 | const appointments = await getRecentAppointmentList();
11 |
12 | return (
13 |
14 |
15 |
16 |
23 |
24 |
25 | Admin Dashboard
26 |
27 |
28 |
29 |
30 | Welcome 👋
31 |
32 | Start the day with managing new appointments
33 |
34 |
35 |
36 |
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default AdminPage;
64 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const GenderOptions = ["Male", "Female", "Other"];
2 |
3 | export const PatientFormDefaultValues = {
4 | firstName: "",
5 | lastName: "",
6 | email: "",
7 | phone: "",
8 | birthDate: new Date(Date.now()),
9 | gender: "Male" as Gender,
10 | address: "",
11 | occupation: "",
12 | emergencyContactName: "",
13 | emergencyContactNumber: "",
14 | primaryPhysician: "",
15 | insuranceProvider: "",
16 | insurancePolicyNumber: "",
17 | allergies: "",
18 | currentMedication: "",
19 | familyMedicalHistory: "",
20 | pastMedicalHistory: "",
21 | identificationType: "Birth Certificate",
22 | identificationNumber: "",
23 | identificationDocument: [],
24 | treatmentConsent: false,
25 | disclosureConsent: false,
26 | privacyConsent: false,
27 | };
28 |
29 | export const IdentificationTypes = [
30 | "Birth Certificate",
31 | "Driver's License",
32 | "Medical Insurance Card/Policy",
33 | "Military ID Card",
34 | "National Identity Card",
35 | "Passport",
36 | "Resident Alien Card (Green Card)",
37 | "Social Security Card",
38 | "State ID Card",
39 | "Student ID Card",
40 | "Voter ID Card",
41 | ];
42 |
43 | export const Doctors = [
44 | {
45 | image: "/assets/images/dr-green.png",
46 | name: "John Green",
47 | },
48 | {
49 | image: "/assets/images/dr-cameron.png",
50 | name: "Leila Cameron",
51 | },
52 | {
53 | image: "/assets/images/dr-livingston.png",
54 | name: "David Livingston",
55 | },
56 | {
57 | image: "/assets/images/dr-peter.png",
58 | name: "Evan Peter",
59 | },
60 | {
61 | image: "/assets/images/dr-powell.png",
62 | name: "Jane Powell",
63 | },
64 | {
65 | image: "/assets/images/dr-remirez.png",
66 | name: "Alex Ramirez",
67 | },
68 | {
69 | image: "/assets/images/dr-lee.png",
70 | name: "Jasmine Lee",
71 | },
72 | {
73 | image: "/assets/images/dr-cruz.png",
74 | name: "Alyana Cruz",
75 | },
76 | {
77 | image: "/assets/images/dr-sharma.png",
78 | name: "Hardik Sharma",
79 | },
80 | ];
81 |
82 | export const StatusIcon = {
83 | scheduled: "/assets/icons/check.svg",
84 | pending: "/assets/icons/pending.svg",
85 | cancelled: "/assets/icons/cancelled.svg",
86 | };
87 |
--------------------------------------------------------------------------------
/public/assets/icons/loader.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const { fontFamily } = require("tailwindcss/defaultTheme");
4 |
5 | const config = {
6 | darkMode: ["class"],
7 | content: [
8 | "./pages/**/*.{ts,tsx}",
9 | "./components/**/*.{ts,tsx}",
10 | "./app/**/*.{ts,tsx}",
11 | "./src/**/*.{ts,tsx}",
12 | ],
13 | prefix: "",
14 | theme: {
15 | container: {
16 | center: true,
17 | padding: "2rem",
18 | screens: {
19 | "2xl": "1400px",
20 | },
21 | },
22 | extend: {
23 | colors: {
24 | green: {
25 | 500: "#24AE7C",
26 | 600: "#0D2A1F",
27 | },
28 | blue: {
29 | 500: "#79B5EC",
30 | 600: "#152432",
31 | },
32 | red: {
33 | 500: "#F37877",
34 | 600: "#3E1716",
35 | 700: "#F24E43",
36 | },
37 | light: {
38 | 200: "#E8E9E9",
39 | },
40 | dark: {
41 | 200: "#0D0F10",
42 | 300: "#131619",
43 | 400: "#1A1D21",
44 | 500: "#363A3D",
45 | 600: "#76828D",
46 | 700: "#ABB8C4",
47 | },
48 | },
49 | fontFamily: {
50 | sans: ["var(--font-sans)", ...fontFamily.sans],
51 | },
52 | backgroundImage: {
53 | appointments: "url('/assets/images/appointments-bg.png')",
54 | pending: "url('/assets/images/pending-bg.png')",
55 | cancelled: "url('/assets/images/cancelled-bg.png')",
56 | },
57 | keyframes: {
58 | "accordion-down": {
59 | from: { height: "0" },
60 | to: { height: "var(--radix-accordion-content-height)" },
61 | },
62 | "accordion-up": {
63 | from: { height: "var(--radix-accordion-content-height)" },
64 | to: { height: "0" },
65 | },
66 | "caret-blink": {
67 | "0%,70%,100%": { opacity: "1" },
68 | "20%,50%": { opacity: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | "caret-blink": "caret-blink 1.25s ease-out infinite",
75 | },
76 | },
77 | },
78 | plugins: [require("tailwindcss-animate")],
79 | } satisfies Config;
80 |
81 | export default config;
82 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import * as React from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
14 | destructive:
15 | "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
18 | secondary:
19 | "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
20 | ghost:
21 | "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
22 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
23 | },
24 | size: {
25 | default: "h-11 px-4 py-2",
26 | sm: "h-9 rounded-md px-3",
27 | lg: "h-11 rounded-md px-8",
28 | icon: "size-10",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | }
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { OTPInput, OTPInputContext } from "input-otp";
4 | import { Dot } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const InputOTP = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, containerClassName, ...props }, ref) => (
13 |
22 | ));
23 | InputOTP.displayName = "InputOTP";
24 |
25 | const InputOTPGroup = React.forwardRef<
26 | React.ElementRef<"div">,
27 | React.ComponentPropsWithoutRef<"div">
28 | >(({ className, ...props }, ref) => (
29 |
30 | ));
31 | InputOTPGroup.displayName = "InputOTPGroup";
32 |
33 | const InputOTPSlot = React.forwardRef<
34 | React.ElementRef<"div">,
35 | React.ComponentPropsWithoutRef<"div"> & { index: number }
36 | >(({ index, className, ...props }, ref) => {
37 | const inputOTPContext = React.useContext(OTPInputContext);
38 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
39 |
40 | return (
41 |
51 | {char}
52 | {hasFakeCaret && (
53 |
56 | )}
57 |
58 | );
59 | });
60 | InputOTPSlot.displayName = "InputOTPSlot";
61 |
62 | const InputOTPSeparator = React.forwardRef<
63 | React.ElementRef<"div">,
64 | React.ComponentPropsWithoutRef<"div">
65 | >(({ ...props }, ref) => (
66 |
67 |
68 |
69 | ));
70 | InputOTPSeparator.displayName = "InputOTPSeparator";
71 |
72 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
73 |
--------------------------------------------------------------------------------
/app/patients/[userId]/new-appointment/success/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import { Button } from "@/components/ui/button";
5 | import { Doctors } from "@/constants";
6 | import { getAppointment } from "@/lib/actions/appointment.actions";
7 | import { formatDateTime } from "@/lib/utils";
8 |
9 | const RequestSuccess = async ({
10 | searchParams,
11 | params: { userId },
12 | }: SearchParamProps) => {
13 | const appointmentId = (searchParams?.appointmentId as string) || "";
14 | const appointment = await getAppointment(appointmentId);
15 |
16 | const doctor = Doctors.find(
17 | (doctor) => doctor.name === appointment.primaryPhysician
18 | );
19 |
20 | return (
21 |
22 |
23 |
24 |
31 |
32 |
33 |
34 |
40 |
41 | Your appointment request has
42 | been successfully submitted!
43 |
44 | We'll be in touch shortly to confirm.
45 |
46 |
47 |
48 | Requested appointment details:
49 |
50 |
57 |
Dr. {doctor?.name}
58 |
59 |
60 |
66 |
{formatDateTime(appointment.schedule).dateTime}
67 |
68 |
69 |
70 |
75 |
76 |
© 2024 CarePluse
77 |
78 |
79 | );
80 | };
81 |
82 | export default RequestSuccess;
83 |
--------------------------------------------------------------------------------
/components/forms/PatientForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useRouter } from "next/navigation";
5 | import { useState } from "react";
6 | import { useForm } from "react-hook-form";
7 | import { z } from "zod";
8 |
9 | import { Form } from "@/components/ui/form";
10 | import { createUser } from "@/lib/actions/patient.actions";
11 | import { UserFormValidation } from "@/lib/validation";
12 |
13 | import "react-phone-number-input/style.css";
14 | import CustomFormField, { FormFieldType } from "../CustomFormField";
15 | import SubmitButton from "../SubmitButton";
16 |
17 | export const PatientForm = () => {
18 | const router = useRouter();
19 | const [isLoading, setIsLoading] = useState(false);
20 |
21 | const form = useForm>({
22 | resolver: zodResolver(UserFormValidation),
23 | defaultValues: {
24 | name: "",
25 | email: "",
26 | phone: "",
27 | },
28 | });
29 |
30 | const onSubmit = async (values: z.infer) => {
31 | setIsLoading(true);
32 |
33 | try {
34 | const user = {
35 | name: values.name,
36 | email: values.email,
37 | phone: values.phone,
38 | };
39 |
40 | const newUser = await createUser(user);
41 |
42 | if (newUser) {
43 | router.push(`/patients/${newUser.$id}/register`);
44 | }
45 | } catch (error) {
46 | console.log(error);
47 | }
48 |
49 | setIsLoading(false);
50 | };
51 |
52 | return (
53 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
9 |
10 | export const convertFileToUrl = (file: File) => URL.createObjectURL(file);
11 |
12 | // FORMAT DATE TIME
13 | export const formatDateTime = (dateString: Date | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) => {
14 | const dateTimeOptions: Intl.DateTimeFormatOptions = {
15 | // weekday: "short", // abbreviated weekday name (e.g., 'Mon')
16 | month: "short", // abbreviated month name (e.g., 'Oct')
17 | day: "numeric", // numeric day of the month (e.g., '25')
18 | year: "numeric", // numeric year (e.g., '2023')
19 | hour: "numeric", // numeric hour (e.g., '8')
20 | minute: "numeric", // numeric minute (e.g., '30')
21 | hour12: true, // use 12-hour clock (true) or 24-hour clock (false),
22 | timeZone: timeZone, // use the provided timezone
23 | };
24 |
25 | const dateDayOptions: Intl.DateTimeFormatOptions = {
26 | weekday: "short", // abbreviated weekday name (e.g., 'Mon')
27 | year: "numeric", // numeric year (e.g., '2023')
28 | month: "2-digit", // abbreviated month name (e.g., 'Oct')
29 | day: "2-digit", // numeric day of the month (e.g., '25')
30 | timeZone: timeZone, // use the provided timezone
31 | };
32 |
33 | const dateOptions: Intl.DateTimeFormatOptions = {
34 | month: "short", // abbreviated month name (e.g., 'Oct')
35 | year: "numeric", // numeric year (e.g., '2023')
36 | day: "numeric", // numeric day of the month (e.g., '25')
37 | timeZone: timeZone, // use the provided timezone
38 | };
39 |
40 | const timeOptions: Intl.DateTimeFormatOptions = {
41 | hour: "numeric", // numeric hour (e.g., '8')
42 | minute: "numeric", // numeric minute (e.g., '30')
43 | hour12: true, // use 12-hour clock (true) or 24-hour clock (false)
44 | timeZone: timeZone, // use the provided timezone
45 | };
46 |
47 | const formattedDateTime: string = new Date(dateString).toLocaleString(
48 | "en-US",
49 | dateTimeOptions
50 | );
51 |
52 | const formattedDateDay: string = new Date(dateString).toLocaleString(
53 | "en-US",
54 | dateDayOptions
55 | );
56 |
57 | const formattedDate: string = new Date(dateString).toLocaleString(
58 | "en-US",
59 | dateOptions
60 | );
61 |
62 | const formattedTime: string = new Date(dateString).toLocaleString(
63 | "en-US",
64 | timeOptions
65 | );
66 |
67 | return {
68 | dateTime: formattedDateTime,
69 | dateDay: formattedDateDay,
70 | dateOnly: formattedDate,
71 | timeOnly: formattedTime,
72 | };
73 | };
74 |
75 | export function encryptKey(passkey: string) {
76 | return btoa(passkey);
77 | }
78 |
79 | export function decryptKey(passkey: string) {
80 | return atob(passkey);
81 | }
82 |
--------------------------------------------------------------------------------
/components/table/columns.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ColumnDef } from "@tanstack/react-table";
4 | import Image from "next/image";
5 |
6 | import { Doctors } from "@/constants";
7 | import { formatDateTime } from "@/lib/utils";
8 | import { Appointment } from "@/types/appwrite.types";
9 |
10 | import { AppointmentModal } from "../AppointmentModal";
11 | import { StatusBadge } from "../StatusBadge";
12 |
13 | export const columns: ColumnDef[] = [
14 | {
15 | header: "#",
16 | cell: ({ row }) => {
17 | return {row.index + 1}
;
18 | },
19 | },
20 | {
21 | accessorKey: "patient",
22 | header: "Patient",
23 | cell: ({ row }) => {
24 | const appointment = row.original;
25 | return {appointment.patient.name}
;
26 | },
27 | },
28 | {
29 | accessorKey: "status",
30 | header: "Status",
31 | cell: ({ row }) => {
32 | const appointment = row.original;
33 | return (
34 |
35 |
36 |
37 | );
38 | },
39 | },
40 | {
41 | accessorKey: "schedule",
42 | header: "Appointment",
43 | cell: ({ row }) => {
44 | const appointment = row.original;
45 | return (
46 |
47 | {formatDateTime(appointment.schedule).dateTime}
48 |
49 | );
50 | },
51 | },
52 | {
53 | accessorKey: "primaryPhysician",
54 | header: "Doctor",
55 | cell: ({ row }) => {
56 | const appointment = row.original;
57 |
58 | const doctor = Doctors.find(
59 | (doctor) => doctor.name === appointment.primaryPhysician
60 | );
61 |
62 | return (
63 |
64 |
71 |
Dr. {doctor?.name}
72 |
73 | );
74 | },
75 | },
76 | {
77 | id: "actions",
78 | header: () => Actions
,
79 | cell: ({ row }) => {
80 | const appointment = row.original;
81 |
82 | return (
83 |
101 | );
102 | },
103 | },
104 | ];
105 |
--------------------------------------------------------------------------------
/lib/actions/patient.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { ID, InputFile, Query } from "node-appwrite";
4 |
5 | import {
6 | BUCKET_ID,
7 | DATABASE_ID,
8 | ENDPOINT,
9 | PATIENT_COLLECTION_ID,
10 | PROJECT_ID,
11 | databases,
12 | storage,
13 | users,
14 | } from "../appwrite.config";
15 | import { parseStringify } from "../utils";
16 |
17 | // CREATE APPWRITE USER
18 | export const createUser = async (user: CreateUserParams) => {
19 | try {
20 | // Create new user -> https://appwrite.io/docs/references/1.5.x/server-nodejs/users#create
21 | const newuser = await users.create(
22 | ID.unique(),
23 | user.email,
24 | user.phone,
25 | undefined,
26 | user.name
27 | );
28 |
29 | return parseStringify(newuser);
30 | } catch (error: any) {
31 | // Check existing user
32 | if (error && error?.code === 409) {
33 | const existingUser = await users.list([
34 | Query.equal("email", [user.email]),
35 | ]);
36 |
37 | return existingUser.users[0];
38 | }
39 | console.error("An error occurred while creating a new user:", error);
40 | }
41 | };
42 |
43 | // GET USER
44 | export const getUser = async (userId: string) => {
45 | try {
46 | const user = await users.get(userId);
47 |
48 | return parseStringify(user);
49 | } catch (error) {
50 | console.error(
51 | "An error occurred while retrieving the user details:",
52 | error
53 | );
54 | }
55 | };
56 |
57 | // REGISTER PATIENT
58 | export const registerPatient = async ({
59 | identificationDocument,
60 | ...patient
61 | }: RegisterUserParams) => {
62 | try {
63 | // Upload file -> // https://appwrite.io/docs/references/cloud/client-web/storage#createFile
64 | let file;
65 | if (identificationDocument) {
66 | const inputFile =
67 | identificationDocument &&
68 | InputFile.fromBlob(
69 | identificationDocument?.get("blobFile") as Blob,
70 | identificationDocument?.get("fileName") as string
71 | );
72 |
73 | file = await storage.createFile(BUCKET_ID!, ID.unique(), inputFile);
74 | }
75 |
76 | // Create new patient document -> https://appwrite.io/docs/references/cloud/server-nodejs/databases#createDocument
77 | const newPatient = await databases.createDocument(
78 | DATABASE_ID!,
79 | PATIENT_COLLECTION_ID!,
80 | ID.unique(),
81 | {
82 | identificationDocumentId: file?.$id ? file.$id : null,
83 | identificationDocumentUrl: file?.$id
84 | ? `${ENDPOINT}/storage/buckets/${BUCKET_ID}/files/${file.$id}/view??project=${PROJECT_ID}`
85 | : null,
86 | ...patient,
87 | }
88 | );
89 |
90 | return parseStringify(newPatient);
91 | } catch (error) {
92 | console.error("An error occurred while creating a new patient:", error);
93 | }
94 | };
95 |
96 | // GET PATIENT
97 | export const getPatient = async (userId: string) => {
98 | try {
99 | const patients = await databases.listDocuments(
100 | DATABASE_ID!,
101 | PATIENT_COLLECTION_ID!,
102 | [Query.equal("userId", [userId])]
103 | );
104 |
105 | return parseStringify(patients.documents[0]);
106 | } catch (error) {
107 | console.error(
108 | "An error occurred while retrieving the patient details:",
109 | error
110 | );
111 | }
112 | };
113 |
--------------------------------------------------------------------------------
/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0 dark:bg-slate-800/50",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/app/sentry-example-page/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as Sentry from "@sentry/nextjs";
4 | import Head from "next/head";
5 |
6 | export default function Page() {
7 | return (
8 |
9 |
10 |
Sentry Onboarding
11 |
12 |
13 |
14 |
23 |
24 |
36 |
37 |
38 | Get started by sending us a sample error:
39 |
68 |
69 |
70 | Next, look for the error on the{" "}
71 |
72 | Issues Page
73 |
74 | .
75 |
76 |
77 | For more information, see{" "}
78 |
79 | https://docs.sentry.io/platforms/javascript/guides/nextjs/
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/components/PasskeyModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { usePathname, useRouter } from "next/navigation";
5 | import { useEffect, useState } from "react";
6 |
7 | import {
8 | AlertDialog,
9 | AlertDialogAction,
10 | AlertDialogContent,
11 | AlertDialogDescription,
12 | AlertDialogFooter,
13 | AlertDialogHeader,
14 | AlertDialogTitle,
15 | } from "@/components/ui/alert-dialog";
16 | import {
17 | InputOTP,
18 | InputOTPGroup,
19 | InputOTPSlot,
20 | } from "@/components/ui/input-otp";
21 | import { decryptKey, encryptKey } from "@/lib/utils";
22 |
23 | export const PasskeyModal = () => {
24 | const router = useRouter();
25 | const path = usePathname();
26 | const [open, setOpen] = useState(false);
27 | const [passkey, setPasskey] = useState("");
28 | const [error, setError] = useState("");
29 |
30 | const encryptedKey =
31 | typeof window !== "undefined"
32 | ? window.localStorage.getItem("accessKey")
33 | : null;
34 |
35 | useEffect(() => {
36 | const accessKey = encryptedKey && decryptKey(encryptedKey);
37 |
38 | if (path)
39 | if (accessKey === process.env.NEXT_PUBLIC_ADMIN_PASSKEY!.toString()) {
40 | setOpen(false);
41 | router.push("/admin");
42 | } else {
43 | setOpen(true);
44 | }
45 | }, [encryptedKey]);
46 |
47 | const closeModal = () => {
48 | setOpen(false);
49 | router.push("/");
50 | };
51 |
52 | const validatePasskey = (
53 | e: React.MouseEvent
54 | ) => {
55 | e.preventDefault();
56 |
57 | if (passkey === process.env.NEXT_PUBLIC_ADMIN_PASSKEY) {
58 | const encryptedKey = encryptKey(passkey);
59 |
60 | localStorage.setItem("accessKey", encryptedKey);
61 |
62 | setOpen(false);
63 | } else {
64 | setError("Invalid passkey. Please try again.");
65 | }
66 | };
67 |
68 | return (
69 |
70 |
71 |
72 |
73 | Admin Access Verification
74 | closeModal()}
80 | className="cursor-pointer"
81 | />
82 |
83 |
84 | To access the admin page, please enter the passkey.
85 |
86 |
87 |
88 |
setPasskey(value)}
92 | >
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | {error && (
104 |
105 | {error}
106 |
107 | )}
108 |
109 |
110 | validatePasskey(e)}
112 | className="shad-primary-btn w-full"
113 | >
114 | Enter Admin Passkey
115 |
116 |
117 |
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/components/table/DataTable.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | getPaginationRowModel,
5 | ColumnDef,
6 | flexRender,
7 | getCoreRowModel,
8 | useReactTable,
9 | } from "@tanstack/react-table";
10 | import Image from "next/image";
11 | import { redirect } from "next/navigation";
12 | import { useEffect } from "react";
13 |
14 | import { Button } from "@/components/ui/button";
15 | import {
16 | Table,
17 | TableBody,
18 | TableCell,
19 | TableHead,
20 | TableHeader,
21 | TableRow,
22 | } from "@/components/ui/table";
23 | import { decryptKey } from "@/lib/utils";
24 |
25 | interface DataTableProps {
26 | columns: ColumnDef[];
27 | data: TData[];
28 | }
29 |
30 | export function DataTable({
31 | columns,
32 | data,
33 | }: DataTableProps) {
34 | const encryptedKey =
35 | typeof window !== "undefined"
36 | ? window.localStorage.getItem("accessKey")
37 | : null;
38 |
39 | useEffect(() => {
40 | const accessKey = encryptedKey && decryptKey(encryptedKey);
41 |
42 | if (accessKey !== process.env.NEXT_PUBLIC_ADMIN_PASSKEY!.toString()) {
43 | redirect("/");
44 | }
45 | }, [encryptedKey]);
46 |
47 | const table = useReactTable({
48 | data,
49 | columns,
50 | getCoreRowModel: getCoreRowModel(),
51 | getPaginationRowModel: getPaginationRowModel(),
52 | });
53 |
54 | return (
55 |
56 |
57 |
58 | {table.getHeaderGroups().map((headerGroup) => (
59 |
60 | {headerGroup.headers.map((header) => {
61 | return (
62 |
63 | {header.isPlaceholder
64 | ? null
65 | : flexRender(
66 | header.column.columnDef.header,
67 | header.getContext()
68 | )}
69 |
70 | );
71 | })}
72 |
73 | ))}
74 |
75 |
76 | {table.getRowModel().rows?.length ? (
77 | table.getRowModel().rows.map((row) => (
78 |
83 | {row.getVisibleCells().map((cell) => (
84 |
85 | {flexRender(cell.column.columnDef.cell, cell.getContext())}
86 |
87 | ))}
88 |
89 | ))
90 | ) : (
91 |
92 |
93 | No results.
94 |
95 |
96 | )}
97 |
98 |
99 |
100 |
114 |
129 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/lib/validation.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const UserFormValidation = z.object({
4 | name: z
5 | .string()
6 | .min(2, "Name must be at least 2 characters")
7 | .max(50, "Name must be at most 50 characters"),
8 | email: z.string().email("Invalid email address"),
9 | phone: z
10 | .string()
11 | .refine((phone) => /^\+\d{10,15}$/.test(phone), "Invalid phone number"),
12 | });
13 |
14 | export const PatientFormValidation = z.object({
15 | name: z
16 | .string()
17 | .min(2, "Name must be at least 2 characters")
18 | .max(50, "Name must be at most 50 characters"),
19 | email: z.string().email("Invalid email address"),
20 | phone: z
21 | .string()
22 | .refine((phone) => /^\+\d{10,15}$/.test(phone), "Invalid phone number"),
23 | birthDate: z.coerce.date(),
24 | gender: z.enum(["Male", "Female", "Other"]),
25 | address: z
26 | .string()
27 | .min(5, "Address must be at least 5 characters")
28 | .max(500, "Address must be at most 500 characters"),
29 | occupation: z
30 | .string()
31 | .min(2, "Occupation must be at least 2 characters")
32 | .max(500, "Occupation must be at most 500 characters"),
33 | emergencyContactName: z
34 | .string()
35 | .min(2, "Contact name must be at least 2 characters")
36 | .max(50, "Contact name must be at most 50 characters"),
37 | emergencyContactNumber: z
38 | .string()
39 | .refine(
40 | (emergencyContactNumber) => /^\+\d{10,15}$/.test(emergencyContactNumber),
41 | "Invalid phone number"
42 | ),
43 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
44 | insuranceProvider: z
45 | .string()
46 | .min(2, "Insurance name must be at least 2 characters")
47 | .max(50, "Insurance name must be at most 50 characters"),
48 | insurancePolicyNumber: z
49 | .string()
50 | .min(2, "Policy number must be at least 2 characters")
51 | .max(50, "Policy number must be at most 50 characters"),
52 | allergies: z.string().optional(),
53 | currentMedication: z.string().optional(),
54 | familyMedicalHistory: z.string().optional(),
55 | pastMedicalHistory: z.string().optional(),
56 | identificationType: z.string().optional(),
57 | identificationNumber: z.string().optional(),
58 | identificationDocument: z.custom().optional(),
59 | treatmentConsent: z
60 | .boolean()
61 | .default(false)
62 | .refine((value) => value === true, {
63 | message: "You must consent to treatment in order to proceed",
64 | }),
65 | disclosureConsent: z
66 | .boolean()
67 | .default(false)
68 | .refine((value) => value === true, {
69 | message: "You must consent to disclosure in order to proceed",
70 | }),
71 | privacyConsent: z
72 | .boolean()
73 | .default(false)
74 | .refine((value) => value === true, {
75 | message: "You must consent to privacy in order to proceed",
76 | }),
77 | });
78 |
79 | export const CreateAppointmentSchema = z.object({
80 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
81 | schedule: z.coerce.date(),
82 | reason: z
83 | .string()
84 | .min(2, "Reason must be at least 2 characters")
85 | .max(500, "Reason must be at most 500 characters"),
86 | note: z.string().optional(),
87 | cancellationReason: z.string().optional(),
88 | });
89 |
90 | export const ScheduleAppointmentSchema = z.object({
91 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
92 | schedule: z.coerce.date(),
93 | reason: z.string().optional(),
94 | note: z.string().optional(),
95 | cancellationReason: z.string().optional(),
96 | });
97 |
98 | export const CancelAppointmentSchema = z.object({
99 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
100 | schedule: z.coerce.date(),
101 | reason: z.string().optional(),
102 | note: z.string().optional(),
103 | cancellationReason: z
104 | .string()
105 | .min(2, "Reason must be at least 2 characters")
106 | .max(500, "Reason must be at most 500 characters"),
107 | });
108 |
109 | export function getAppointmentSchema(type: string) {
110 | switch (type) {
111 | case "create":
112 | return CreateAppointmentSchema;
113 | case "cancel":
114 | return CancelAppointmentSchema;
115 | default:
116 | return ScheduleAppointmentSchema;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as DialogPrimitive from "@radix-ui/react-dialog";
4 | import { X } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/public/assets/icons/logo-icon.svg:
--------------------------------------------------------------------------------
1 |
46 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from "@radix-ui/react-label";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import * as React from "react";
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { Label } from "@/components/ui/label";
14 | import { cn } from "@/lib/utils";
15 |
16 | const Form = FormProvider;
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath,
21 | > = {
22 | name: TName;
23 | };
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | );
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath,
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext);
44 | const itemContext = React.useContext(FormItemContext);
45 | const { getFieldState, formState } = useFormContext();
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState);
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ");
51 | }
52 |
53 | const { id } = itemContext;
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | };
63 | };
64 |
65 | type FormItemContextValue = {
66 | id: string;
67 | };
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | );
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId();
78 |
79 | return (
80 |
81 |
82 |
83 | );
84 | });
85 | FormItem.displayName = "FormItem";
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField();
92 |
93 | return (
94 |
100 | );
101 | });
102 | FormLabel.displayName = "FormLabel";
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } =
109 | useFormField();
110 |
111 | return (
112 |
123 | );
124 | });
125 | FormControl.displayName = "FormControl";
126 |
127 | const FormDescription = React.forwardRef<
128 | HTMLParagraphElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | });
142 | FormDescription.displayName = "FormDescription";
143 |
144 | const FormMessage = React.forwardRef<
145 | HTMLParagraphElement,
146 | React.HTMLAttributes
147 | >(({ className, children, ...props }, ref) => {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error?.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
165 | {body}
166 |
167 | );
168 | });
169 | FormMessage.displayName = "FormMessage";
170 |
171 | export {
172 | useFormField,
173 | Form,
174 | FormItem,
175 | FormLabel,
176 | FormControl,
177 | FormDescription,
178 | FormMessage,
179 | FormField,
180 | };
181 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
4 | import * as React from "react";
5 |
6 | import { buttonVariants } from "@/components/ui/button";
7 | import { cn } from "@/lib/utils";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/lib/actions/appointment.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { ID, Query } from "node-appwrite";
5 |
6 | import { Appointment } from "@/types/appwrite.types";
7 |
8 | import {
9 | APPOINTMENT_COLLECTION_ID,
10 | DATABASE_ID,
11 | databases,
12 | messaging,
13 | } from "../appwrite.config";
14 | import { formatDateTime, parseStringify } from "../utils";
15 |
16 | // CREATE APPOINTMENT
17 | export const createAppointment = async (
18 | appointment: CreateAppointmentParams
19 | ) => {
20 | try {
21 | const newAppointment = await databases.createDocument(
22 | DATABASE_ID!,
23 | APPOINTMENT_COLLECTION_ID!,
24 | ID.unique(),
25 | appointment
26 | );
27 |
28 | revalidatePath("/admin");
29 | return parseStringify(newAppointment);
30 | } catch (error) {
31 | console.error("An error occurred while creating a new appointment:", error);
32 | }
33 | };
34 |
35 | // GET RECENT APPOINTMENTS
36 | export const getRecentAppointmentList = async () => {
37 | try {
38 | const appointments = await databases.listDocuments(
39 | DATABASE_ID!,
40 | APPOINTMENT_COLLECTION_ID!,
41 | [Query.orderDesc("$createdAt")]
42 | );
43 |
44 | // const scheduledAppointments = (
45 | // appointments.documents as Appointment[]
46 | // ).filter((appointment) => appointment.status === "scheduled");
47 |
48 | // const pendingAppointments = (
49 | // appointments.documents as Appointment[]
50 | // ).filter((appointment) => appointment.status === "pending");
51 |
52 | // const cancelledAppointments = (
53 | // appointments.documents as Appointment[]
54 | // ).filter((appointment) => appointment.status === "cancelled");
55 |
56 | // const data = {
57 | // totalCount: appointments.total,
58 | // scheduledCount: scheduledAppointments.length,
59 | // pendingCount: pendingAppointments.length,
60 | // cancelledCount: cancelledAppointments.length,
61 | // documents: appointments.documents,
62 | // };
63 |
64 | const initialCounts = {
65 | scheduledCount: 0,
66 | pendingCount: 0,
67 | cancelledCount: 0,
68 | };
69 |
70 | const counts = (appointments.documents as Appointment[]).reduce(
71 | (acc, appointment) => {
72 | switch (appointment.status) {
73 | case "scheduled":
74 | acc.scheduledCount++;
75 | break;
76 | case "pending":
77 | acc.pendingCount++;
78 | break;
79 | case "cancelled":
80 | acc.cancelledCount++;
81 | break;
82 | }
83 | return acc;
84 | },
85 | initialCounts
86 | );
87 |
88 | const data = {
89 | totalCount: appointments.total,
90 | ...counts,
91 | documents: appointments.documents,
92 | };
93 |
94 | return parseStringify(data);
95 | } catch (error) {
96 | console.error(
97 | "An error occurred while retrieving the recent appointments:",
98 | error
99 | );
100 | }
101 | };
102 |
103 | // SEND SMS NOTIFICATION
104 | export const sendSMSNotification = async (userId: string, content: string) => {
105 | try {
106 | // https://appwrite.io/docs/references/1.5.x/server-nodejs/messaging#createSms
107 | const message = await messaging.createSms(
108 | ID.unique(),
109 | content,
110 | [],
111 | [userId]
112 | );
113 | return parseStringify(message);
114 | } catch (error) {
115 | console.error("An error occurred while sending sms:", error);
116 | }
117 | };
118 |
119 | // UPDATE APPOINTMENT
120 | export const updateAppointment = async ({
121 | appointmentId,
122 | userId,
123 | timeZone,
124 | appointment,
125 | type,
126 | }: UpdateAppointmentParams) => {
127 | try {
128 | // Update appointment to scheduled -> https://appwrite.io/docs/references/cloud/server-nodejs/databases#updateDocument
129 | const updatedAppointment = await databases.updateDocument(
130 | DATABASE_ID!,
131 | APPOINTMENT_COLLECTION_ID!,
132 | appointmentId,
133 | appointment
134 | );
135 |
136 | if (!updatedAppointment) throw Error;
137 |
138 | const smsMessage = `Greetings from CarePulse. ${type === "schedule" ? `Your appointment is confirmed for ${formatDateTime(appointment.schedule!, timeZone).dateTime} with Dr. ${appointment.primaryPhysician}` : `We regret to inform that your appointment for ${formatDateTime(appointment.schedule!, timeZone).dateTime} is cancelled. Reason: ${appointment.cancellationReason}`}.`;
139 | await sendSMSNotification(userId, smsMessage);
140 |
141 | revalidatePath("/admin");
142 | return parseStringify(updatedAppointment);
143 | } catch (error) {
144 | console.error("An error occurred while scheduling an appointment:", error);
145 | }
146 | };
147 |
148 | // GET APPOINTMENT
149 | export const getAppointment = async (appointmentId: string) => {
150 | try {
151 | const appointment = await databases.getDocument(
152 | DATABASE_ID!,
153 | APPOINTMENT_COLLECTION_ID!,
154 | appointmentId
155 | );
156 |
157 | return parseStringify(appointment);
158 | } catch (error) {
159 | console.error(
160 | "An error occurred while retrieving the existing patient:",
161 | error
162 | );
163 | }
164 | };
165 |
--------------------------------------------------------------------------------
/components/CustomFormField.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { E164Number } from "libphonenumber-js/core";
3 | import Image from "next/image";
4 | import ReactDatePicker from "react-datepicker";
5 | import { Control } from "react-hook-form";
6 | import PhoneInput from "react-phone-number-input";
7 |
8 | import { Checkbox } from "./ui/checkbox";
9 | import {
10 | FormControl,
11 | FormField,
12 | FormItem,
13 | FormLabel,
14 | FormMessage,
15 | } from "./ui/form";
16 | import { Input } from "./ui/input";
17 | import { Select, SelectContent, SelectTrigger, SelectValue } from "./ui/select";
18 | import { Textarea } from "./ui/textarea";
19 |
20 | export enum FormFieldType {
21 | INPUT = "input",
22 | TEXTAREA = "textarea",
23 | PHONE_INPUT = "phoneInput",
24 | CHECKBOX = "checkbox",
25 | DATE_PICKER = "datePicker",
26 | SELECT = "select",
27 | SKELETON = "skeleton",
28 | }
29 |
30 | interface CustomProps {
31 | control: Control;
32 | name: string;
33 | label?: string;
34 | placeholder?: string;
35 | iconSrc?: string;
36 | iconAlt?: string;
37 | disabled?: boolean;
38 | dateFormat?: string;
39 | showTimeSelect?: boolean;
40 | children?: React.ReactNode;
41 | renderSkeleton?: (field: any) => React.ReactNode;
42 | fieldType: FormFieldType;
43 | }
44 |
45 | const RenderInput = ({ field, props }: { field: any; props: CustomProps }) => {
46 | switch (props.fieldType) {
47 | case FormFieldType.INPUT:
48 | return (
49 |
50 | {props.iconSrc && (
51 |
58 | )}
59 |
60 |
65 |
66 |
67 | );
68 | case FormFieldType.TEXTAREA:
69 | return (
70 |
71 |
77 |
78 | );
79 | case FormFieldType.PHONE_INPUT:
80 | return (
81 |
82 |
91 |
92 | );
93 | case FormFieldType.CHECKBOX:
94 | return (
95 |
96 |
97 |
102 |
105 |
106 |
107 | );
108 | case FormFieldType.DATE_PICKER:
109 | return (
110 |
111 |
118 |
119 | field.onChange(date)}
123 | timeInputLabel="Time:"
124 | dateFormat={props.dateFormat ?? "MM/dd/yyyy"}
125 | wrapperClassName="date-picker"
126 | />
127 |
128 |
129 | );
130 | case FormFieldType.SELECT:
131 | return (
132 |
133 |
143 |
144 | );
145 | case FormFieldType.SKELETON:
146 | return props.renderSkeleton ? props.renderSkeleton(field) : null;
147 | default:
148 | return null;
149 | }
150 | };
151 |
152 | const CustomFormField = (props: CustomProps) => {
153 | const { control, name, label } = props;
154 |
155 | return (
156 | (
160 |
161 | {props.fieldType !== FormFieldType.CHECKBOX && label && (
162 | {label}
163 | )}
164 |
165 |
166 |
167 |
168 | )}
169 | />
170 | );
171 | };
172 |
173 | export default CustomFormField;
174 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type DialogProps } from "@radix-ui/react-dialog";
4 | import { Command as CommandPrimitive } from "cmdk";
5 | import { Search } from "lucide-react";
6 | import * as React from "react";
7 |
8 | import { Dialog, DialogContent } from "@/components/ui/dialog";
9 | import { cn } from "@/lib/utils";
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ));
24 | Command.displayName = CommandPrimitive.displayName;
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
37 | );
38 | };
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ));
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName;
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ));
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName;
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ));
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ));
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ));
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName;
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | );
142 | };
143 | CommandShortcut.displayName = "CommandShortcut";
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | };
156 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SelectPrimitive from "@radix-ui/react-select";
4 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ));
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ));
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName;
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ));
100 | SelectContent.displayName = SelectPrimitive.Content.displayName;
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ));
135 | SelectItem.displayName = SelectPrimitive.Item.displayName;
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ));
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | };
161 |
--------------------------------------------------------------------------------
/components/forms/AppointmentForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import Image from "next/image";
5 | import { useRouter } from "next/navigation";
6 | import { Dispatch, SetStateAction, useState } from "react";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 |
10 | import { SelectItem } from "@/components/ui/select";
11 | import { Doctors } from "@/constants";
12 | import {
13 | createAppointment,
14 | updateAppointment,
15 | } from "@/lib/actions/appointment.actions";
16 | import { getAppointmentSchema } from "@/lib/validation";
17 | import { Appointment } from "@/types/appwrite.types";
18 |
19 | import "react-datepicker/dist/react-datepicker.css";
20 |
21 | import CustomFormField, { FormFieldType } from "../CustomFormField";
22 | import SubmitButton from "../SubmitButton";
23 | import { Form } from "../ui/form";
24 |
25 | export const AppointmentForm = ({
26 | userId,
27 | patientId,
28 | type = "create",
29 | appointment,
30 | setOpen,
31 | }: {
32 | userId: string;
33 | patientId: string;
34 | type: "create" | "schedule" | "cancel";
35 | appointment?: Appointment;
36 | setOpen?: Dispatch>;
37 | }) => {
38 | const router = useRouter();
39 | const [isLoading, setIsLoading] = useState(false);
40 |
41 | const AppointmentFormValidation = getAppointmentSchema(type);
42 |
43 | const form = useForm>({
44 | resolver: zodResolver(AppointmentFormValidation),
45 | defaultValues: {
46 | primaryPhysician: appointment ? appointment?.primaryPhysician : "",
47 | schedule: appointment
48 | ? new Date(appointment?.schedule!)
49 | : new Date(Date.now()),
50 | reason: appointment ? appointment.reason : "",
51 | note: appointment?.note || "",
52 | cancellationReason: appointment?.cancellationReason || "",
53 | },
54 | });
55 |
56 | const onSubmit = async (
57 | values: z.infer
58 | ) => {
59 | setIsLoading(true);
60 |
61 | let status;
62 | switch (type) {
63 | case "schedule":
64 | status = "scheduled";
65 | break;
66 | case "cancel":
67 | status = "cancelled";
68 | break;
69 | default:
70 | status = "pending";
71 | }
72 |
73 | try {
74 | if (type === "create" && patientId) {
75 | const appointment = {
76 | userId,
77 | patient: patientId,
78 | primaryPhysician: values.primaryPhysician,
79 | schedule: new Date(values.schedule),
80 | reason: values.reason!,
81 | status: status as Status,
82 | note: values.note,
83 | };
84 |
85 | const newAppointment = await createAppointment(appointment);
86 |
87 | if (newAppointment) {
88 | form.reset();
89 | router.push(
90 | `/patients/${userId}/new-appointment/success?appointmentId=${newAppointment.$id}`
91 | );
92 | }
93 | } else {
94 | const appointmentToUpdate = {
95 | userId,
96 | appointmentId: appointment?.$id!,
97 | appointment: {
98 | primaryPhysician: values.primaryPhysician,
99 | schedule: new Date(values.schedule),
100 | status: status as Status,
101 | cancellationReason: values.cancellationReason,
102 | },
103 | type,
104 | };
105 |
106 | const updatedAppointment = await updateAppointment(appointmentToUpdate);
107 |
108 | if (updatedAppointment) {
109 | setOpen && setOpen(false);
110 | form.reset();
111 | }
112 | }
113 | } catch (error) {
114 | console.log(error);
115 | }
116 | setIsLoading(false);
117 | };
118 |
119 | let buttonLabel;
120 | switch (type) {
121 | case "cancel":
122 | buttonLabel = "Cancel Appointment";
123 | break;
124 | case "schedule":
125 | buttonLabel = "Schedule Appointment";
126 | break;
127 | default:
128 | buttonLabel = "Submit Apppointment";
129 | }
130 |
131 | return (
132 |
218 |
219 | );
220 | };
221 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* ========================================== TAILWIND STYLES */
6 | @layer base {
7 | /* Remove scrollbar */
8 | .remove-scrollbar::-webkit-scrollbar {
9 | width: 0px;
10 | height: 0px;
11 | border-radius: 0px;
12 | }
13 |
14 | .remove-scrollbar::-webkit-scrollbar-track {
15 | background: transparent;
16 | }
17 |
18 | .remove-scrollbar::-webkit-scrollbar-thumb {
19 | background: transparent;
20 | border-radius: 0px;
21 | }
22 |
23 | .remove-scrollbar::-webkit-scrollbar-thumb:hover {
24 | /* background: #1e2238; */
25 | background: transparent;
26 | }
27 | }
28 |
29 | @layer utilities {
30 | /* ===== UTILITIES */
31 | .sidebar {
32 | @apply remove-scrollbar w-full max-w-72 flex-col overflow-auto bg-black-800 px-7 py-10;
33 | }
34 |
35 | .left-sidebar {
36 | @apply hidden lg:flex;
37 | }
38 |
39 | .right-sidebar {
40 | @apply hidden xl:flex;
41 | }
42 |
43 | .clip-text {
44 | @apply bg-clip-text text-transparent;
45 | }
46 |
47 | .bg-image {
48 | @apply bg-black-900 bg-light-rays bg-cover bg-no-repeat;
49 | }
50 |
51 | .header {
52 | @apply text-32-bold md:text-36-bold;
53 | }
54 |
55 | .sub-header {
56 | @apply text-18-bold md:text-24-bold;
57 | }
58 |
59 | .container {
60 | @apply relative flex-1 overflow-y-auto px-[5%];
61 | }
62 |
63 | .sub-container {
64 | @apply mx-auto flex size-full flex-col py-10;
65 | }
66 |
67 | .side-img {
68 | @apply hidden h-full object-cover md:block;
69 | }
70 |
71 | .copyright {
72 | @apply text-14-regular justify-items-end text-center text-dark-600 xl:text-left;
73 | }
74 |
75 | /* ==== SUCCESS */
76 | .success-img {
77 | @apply m-auto flex flex-1 flex-col items-center justify-between gap-10 py-10;
78 | }
79 |
80 | .request-details {
81 | @apply flex w-full flex-col items-center gap-8 border-y-2 border-dark-400 py-8 md:w-fit md:flex-row;
82 | }
83 |
84 | /* ===== ADMIN */
85 | .admin-header {
86 | @apply sticky top-3 z-20 mx-3 flex items-center justify-between rounded-2xl bg-dark-200 px-[5%] py-5 shadow-lg xl:px-12;
87 | }
88 |
89 | .admin-main {
90 | @apply flex flex-col items-center space-y-6 px-[5%] pb-12 xl:space-y-12 xl:px-12;
91 | }
92 |
93 | .admin-stat {
94 | @apply flex w-full flex-col justify-between gap-5 sm:flex-row xl:gap-10;
95 | }
96 |
97 | /* ==== FORM */
98 | .radio-group {
99 | @apply flex h-full flex-1 items-center gap-2 rounded-md border border-dashed border-dark-500 bg-dark-400 p-3;
100 | }
101 |
102 | .checkbox-label {
103 | @apply cursor-pointer text-sm font-medium text-dark-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 md:leading-none;
104 | }
105 |
106 | /* ==== File Upload */
107 | .file-upload {
108 | @apply text-12-regular flex cursor-pointer flex-col items-center justify-center gap-3 rounded-md border border-dashed border-dark-500 bg-dark-400 p-5;
109 | }
110 |
111 | .file-upload_label {
112 | @apply flex flex-col justify-center gap-2 text-center text-dark-600;
113 | }
114 |
115 | /* ==== Stat Card */
116 | .stat-card {
117 | @apply flex flex-1 flex-col gap-6 rounded-2xl bg-cover p-6 shadow-lg;
118 | }
119 |
120 | /* ==== Status Badge */
121 | .status-badge {
122 | @apply flex w-fit items-center gap-2 rounded-full px-4 py-2;
123 | }
124 |
125 | /* Data Table */
126 | .data-table {
127 | @apply z-10 w-full overflow-hidden rounded-lg border border-dark-400 shadow-lg;
128 | }
129 |
130 | .table-actions {
131 | @apply flex w-full items-center justify-between space-x-2 p-4;
132 | }
133 |
134 | /* ===== ALIGNMENTS */
135 | .flex-center {
136 | @apply flex items-center justify-center;
137 | }
138 |
139 | .flex-between {
140 | @apply flex items-center justify-between;
141 | }
142 |
143 | /* ===== TYPOGRAPHY */
144 | .text-36-bold {
145 | @apply text-[36px] leading-[40px] font-bold;
146 | }
147 |
148 | .text-24-bold {
149 | @apply text-[24px] leading-[28px] font-bold;
150 | }
151 |
152 | .text-32-bold {
153 | @apply text-[32px] leading-[36px] font-bold;
154 | }
155 |
156 | .text-18-bold {
157 | @apply text-[18px] leading-[24px] font-bold;
158 | }
159 |
160 | .text-16-semibold {
161 | @apply text-[16px] leading-[20px] font-semibold;
162 | }
163 |
164 | .text-16-regular {
165 | @apply text-[16px] leading-[20px] font-normal;
166 | }
167 |
168 | .text-14-medium {
169 | @apply text-[14px] leading-[18px] font-medium;
170 | }
171 |
172 | .text-14-regular {
173 | @apply text-[14px] leading-[18px] font-normal;
174 | }
175 |
176 | .text-12-regular {
177 | @apply text-[12px] leading-[16px] font-normal;
178 | }
179 |
180 | .text-12-semibold {
181 | @apply text-[12px] leading-[16px] font-semibold;
182 | }
183 |
184 | /* ===== SHADCN OVERRIDES */
185 | .shad-primary-btn {
186 | @apply bg-green-500 text-white !important;
187 | }
188 |
189 | .shad-danger-btn {
190 | @apply bg-red-700 text-white !important;
191 | }
192 |
193 | .shad-gray-btn {
194 | @apply border border-dark-500 cursor-pointer bg-dark-400 text-white !important;
195 | }
196 |
197 | .shad-input-label {
198 | @apply text-14-medium text-dark-700 !important;
199 | }
200 |
201 | .shad-input {
202 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
203 | }
204 |
205 | .shad-input-icon {
206 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
207 | }
208 |
209 | .shad-textArea {
210 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
211 | }
212 |
213 | .shad-combobox-item {
214 | @apply data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 !important;
215 | }
216 |
217 | .shad-combobox-trigger {
218 | @apply h-11 !important;
219 | }
220 |
221 | .shad-select-trigger {
222 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus:ring-0 focus:ring-offset-0 !important;
223 | }
224 |
225 | .shad-select-content {
226 | @apply bg-dark-400 border-dark-500 !important;
227 | }
228 |
229 | .shad-dialog {
230 | @apply bg-dark-400 border-dark-500 !important;
231 | }
232 |
233 | .shad-dialog button {
234 | @apply focus:ring-0 focus:ring-offset-0 focus-visible:border-none focus-visible:outline-none focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
235 | }
236 |
237 | .shad-error {
238 | @apply text-red-400 !important;
239 | }
240 |
241 | .shad-table {
242 | @apply rounded-lg overflow-hidden !important;
243 | }
244 |
245 | .shad-table-row-header {
246 | @apply border-b border-dark-400 text-light-200 hover:bg-transparent !important;
247 | }
248 |
249 | .shad-table-row {
250 | @apply border-b border-dark-400 text-light-200 !important;
251 | }
252 |
253 | .shad-otp {
254 | @apply w-full flex justify-between !important;
255 | }
256 |
257 | .shad-otp-slot {
258 | @apply text-36-bold justify-center flex border border-dark-500 rounded-lg size-16 gap-4 !important;
259 | }
260 |
261 | .shad-alert-dialog {
262 | @apply space-y-5 bg-dark-400 border-dark-500 outline-none !important;
263 | }
264 |
265 | .shad-sheet-content button {
266 | @apply top-2 focus:ring-0 focus:ring-offset-0 focus-visible:border-none focus-visible:outline-none focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
267 | }
268 |
269 | /* ===== REACT PHONE NUMBER INPUT OVERRIDES */
270 | .input-phone {
271 | @apply mt-2 h-11 rounded-md px-3 text-sm border bg-dark-400 placeholder:text-dark-600 border-dark-500 !important;
272 | }
273 |
274 | /* ===== REACT DATE PICKER OVERRIDES */
275 | .date-picker {
276 | @apply overflow-hidden border-transparent w-full placeholder:text-dark-600 h-11 text-14-medium rounded-md px-3 outline-none !important;
277 | }
278 | }
279 |
280 | /* ===== REACT-DATEPICKER OVERRIDES */
281 | .react-datepicker-wrapper.date-picker {
282 | display: flex;
283 | align-items: center;
284 | }
285 |
286 | .react-datepicker,
287 | .react-datepicker__time,
288 | .react-datepicker__header,
289 | .react-datepicker__current-month,
290 | .react-datepicker__day-name,
291 | .react-datepicker__day,
292 | .react-datepicker-time__header {
293 | background-color: #1a1d21 !important;
294 | border-color: #363a3d !important;
295 | color: #abb8c4 !important;
296 | }
297 |
298 | .react-datepicker__current-month,
299 | .react-datepicker__day-name,
300 | .react-datepicker-time__header {
301 | color: #ffffff !important;
302 | }
303 |
304 | .react-datepicker__triangle {
305 | fill: #1a1d21 !important;
306 | color: #1a1d21 !important;
307 | stroke: #1a1d21 !important;
308 | }
309 |
310 | .react-datepicker__time-list-item:hover {
311 | background-color: #363a3d !important;
312 | }
313 |
314 | .react-datepicker__input-container input {
315 | background-color: #1a1d21 !important;
316 | width: 100%;
317 | outline: none;
318 | }
319 |
320 | .react-datepicker__day--selected {
321 | background-color: #24ae7c !important;
322 | color: #ffffff !important;
323 | border-radius: 4px;
324 | }
325 |
326 | .react-datepicker__time-list-item--selected {
327 | background-color: #24ae7c !important;
328 | }
329 |
330 | .react-datepicker__time-container {
331 | border-left: 1px solid #363a3d !important;
332 | }
333 |
334 | .react-datepicker__time-list-item {
335 | display: flex !important;
336 | align-items: center !important;
337 | }
338 |
339 | /* ===== REACT PHONE NUMBER INPUT OVERRIDES */
340 | .PhoneInputInput {
341 | outline: none;
342 | margin-left: 4px;
343 | background: #1a1d21;
344 | font-size: 14px;
345 | font-weight: 500;
346 | }
347 |
348 | .PhoneInputInput::placeholder {
349 | color: #1a1d21;
350 | }
351 |
--------------------------------------------------------------------------------
/public/assets/icons/logo-full.svg:
--------------------------------------------------------------------------------
1 |
47 |
--------------------------------------------------------------------------------
/components/forms/RegisterForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import Image from "next/image";
5 | import { useRouter } from "next/navigation";
6 | import { useState } from "react";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 |
10 | import { Form, FormControl } from "@/components/ui/form";
11 | import { Label } from "@/components/ui/label";
12 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
13 | import { SelectItem } from "@/components/ui/select";
14 | import {
15 | Doctors,
16 | GenderOptions,
17 | IdentificationTypes,
18 | PatientFormDefaultValues,
19 | } from "@/constants";
20 | import { registerPatient } from "@/lib/actions/patient.actions";
21 | import { PatientFormValidation } from "@/lib/validation";
22 |
23 | import "react-datepicker/dist/react-datepicker.css";
24 | import "react-phone-number-input/style.css";
25 | import CustomFormField, { FormFieldType } from "../CustomFormField";
26 | import { FileUploader } from "../FileUploader";
27 | import SubmitButton from "../SubmitButton";
28 |
29 | const RegisterForm = ({ user }: { user: User }) => {
30 | const router = useRouter();
31 | const [isLoading, setIsLoading] = useState(false);
32 |
33 | const form = useForm>({
34 | resolver: zodResolver(PatientFormValidation),
35 | defaultValues: {
36 | ...PatientFormDefaultValues,
37 | name: user.name,
38 | email: user.email,
39 | phone: user.phone,
40 | },
41 | });
42 |
43 | const onSubmit = async (values: z.infer) => {
44 | setIsLoading(true);
45 |
46 | // Store file info in form data as
47 | let formData;
48 | if (
49 | values.identificationDocument &&
50 | values.identificationDocument?.length > 0
51 | ) {
52 | const blobFile = new Blob([values.identificationDocument[0]], {
53 | type: values.identificationDocument[0].type,
54 | });
55 |
56 | formData = new FormData();
57 | formData.append("blobFile", blobFile);
58 | formData.append("fileName", values.identificationDocument[0].name);
59 | }
60 |
61 | try {
62 | const patient = {
63 | userId: user.$id,
64 | name: values.name,
65 | email: values.email,
66 | phone: values.phone,
67 | birthDate: new Date(values.birthDate),
68 | gender: values.gender,
69 | address: values.address,
70 | occupation: values.occupation,
71 | emergencyContactName: values.emergencyContactName,
72 | emergencyContactNumber: values.emergencyContactNumber,
73 | primaryPhysician: values.primaryPhysician,
74 | insuranceProvider: values.insuranceProvider,
75 | insurancePolicyNumber: values.insurancePolicyNumber,
76 | allergies: values.allergies,
77 | currentMedication: values.currentMedication,
78 | familyMedicalHistory: values.familyMedicalHistory,
79 | pastMedicalHistory: values.pastMedicalHistory,
80 | identificationType: values.identificationType,
81 | identificationNumber: values.identificationNumber,
82 | identificationDocument: values.identificationDocument
83 | ? formData
84 | : undefined,
85 | privacyConsent: values.privacyConsent,
86 | };
87 |
88 | const newPatient = await registerPatient(patient);
89 |
90 | if (newPatient) {
91 | router.push(`/patients/${user.$id}/new-appointment`);
92 | }
93 | } catch (error) {
94 | console.log(error);
95 | }
96 |
97 | setIsLoading(false);
98 | };
99 |
100 | return (
101 |
380 |
381 | );
382 | };
383 |
384 | export default RegisterForm;
385 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
14 |
15 |
A HealthCare Management System
16 |
17 |
18 | Build this project step by step with our detailed tutorial on
JavaScript Mastery YouTube. Join the JSM family!
19 |
20 |
21 |
22 | ## 📋 Table of Contents
23 |
24 | 1. 🤖 [Introduction](#introduction)
25 | 2. ⚙️ [Tech Stack](#tech-stack)
26 | 3. 🔋 [Features](#features)
27 | 4. 🤸 [Quick Start](#quick-start)
28 | 5. 🕸️ [Snippets (Code to Copy)](#snippets)
29 | 6. 🔗 [Assets](#links)
30 | 7. 🚀 [More](#more)
31 |
32 | ## 🚨 Tutorial
33 |
34 | This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery.
35 |
36 | If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!
37 |
38 |
39 |
40 | ## 🤖 Introduction
41 |
42 | A healthcare patient management application that allows patients to easily register, book, and manage their appointments with doctors, featuring administrative tools for scheduling, confirming, and canceling appointments, along with SMS notifications, all built using Next.js.
43 |
44 | If you're getting started and need assistance or face any bugs, join our active Discord community with over **34k+** members. It's a place where people help each other out.
45 |
46 |
47 |
48 | ## ⚙️ Tech Stack
49 |
50 | - Next.js
51 | - Appwrite
52 | - Typescript
53 | - TailwindCSS
54 | - ShadCN
55 | - Twilio
56 |
57 | ## 🔋 Features
58 |
59 | 👉 **Register as a Patient**: Users can sign up and create a personal profile as a patient.
60 |
61 | 👉 **Book a New Appointment with Doctor**: Patients can schedule appointments with doctors at their convenience and can book multiple appointments.
62 |
63 | 👉 **Manage Appointments on Admin Side**: Administrators can efficiently view and handle all scheduled appointments.
64 |
65 | 👉 **Confirm/Schedule Appointment from Admin Side**: Admins can confirm and set appointment times to ensure they are properly scheduled.
66 |
67 | 👉 **Cancel Appointment from Admin Side**: Administrators have the ability to cancel any appointment as needed.
68 |
69 | 👉 **Send SMS on Appointment Confirmation**: Patients receive SMS notifications to confirm their appointment details.
70 |
71 | 👉 **Complete Responsiveness**: The application works seamlessly on all device types and screen sizes.
72 |
73 | 👉 **File Upload Using Appwrite Storage**: Users can upload and store files securely within the app using Appwrite storage services.
74 |
75 | 👉 **Manage and Track Application Performance Using Sentry**: The application uses Sentry to monitor and track its performance and detect any errors.
76 |
77 | and many more, including code architecture and reusability
78 |
79 | ## 🤸 Quick Start
80 |
81 | Follow these steps to set up the project locally on your machine.
82 |
83 | **Prerequisites**
84 |
85 | Make sure you have the following installed on your machine:
86 |
87 | - [Git](https://git-scm.com/)
88 | - [Node.js](https://nodejs.org/en)
89 | - [npm](https://www.npmjs.com/) (Node Package Manager)
90 |
91 | **Cloning the Repository**
92 |
93 | ```bash
94 | git clone https://github.com/adrianhajdin/healthcare.git
95 | cd healthcare
96 | ```
97 |
98 | **Installation**
99 |
100 | Install the project dependencies using npm:
101 |
102 | ```bash
103 | npm install
104 | ```
105 |
106 | **Set Up Environment Variables**
107 |
108 | Create a new file named `.env.local` in the root of your project and add the following content:
109 |
110 | ```env
111 | #APPWRITE
112 | NEXT_PUBLIC_ENDPOINT=https://cloud.appwrite.io/v1
113 | PROJECT_ID=
114 | API_KEY=
115 | DATABASE_ID=
116 | PATIENT_COLLECTION_ID=
117 | APPOINTMENT_COLLECTION_ID=
118 | NEXT_PUBLIC_BUCKET_ID=
119 |
120 | NEXT_PUBLIC_ADMIN_PASSKEY=111111
121 | ```
122 |
123 | Replace the placeholder values with your actual Appwrite credentials. You can obtain these credentials by signing up on the [Appwrite website](https://appwrite.io/).
124 |
125 | **Running the Project**
126 |
127 | ```bash
128 | npm run dev
129 | ```
130 |
131 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project.
132 |
133 | ## 🕸️ Snippets
134 |
135 |
136 | tailwind.config.ts
137 |
138 | ```typescript
139 | import type { Config } from "tailwindcss";
140 |
141 | const { fontFamily } = require("tailwindcss/defaultTheme");
142 |
143 | const config = {
144 | darkMode: ["class"],
145 | content: [
146 | "./pages/**/*.{ts,tsx}",
147 | "./components/**/*.{ts,tsx}",
148 | "./app/**/*.{ts,tsx}",
149 | "./src/**/*.{ts,tsx}",
150 | ],
151 | prefix: "",
152 | theme: {
153 | container: {
154 | center: true,
155 | padding: "2rem",
156 | screens: {
157 | "2xl": "1400px",
158 | },
159 | },
160 | extend: {
161 | colors: {
162 | green: {
163 | 500: "#24AE7C",
164 | 600: "#0D2A1F",
165 | },
166 | blue: {
167 | 500: "#79B5EC",
168 | 600: "#152432",
169 | },
170 | red: {
171 | 500: "#F37877",
172 | 600: "#3E1716",
173 | 700: "#F24E43",
174 | },
175 | light: {
176 | 200: "#E8E9E9",
177 | },
178 | dark: {
179 | 200: "#0D0F10",
180 | 300: "#131619",
181 | 400: "#1A1D21",
182 | 500: "#363A3D",
183 | 600: "#76828D",
184 | 700: "#ABB8C4",
185 | },
186 | },
187 | fontFamily: {
188 | sans: ["var(--font-sans)", ...fontFamily.sans],
189 | },
190 | backgroundImage: {
191 | appointments: "url('/assets/images/appointments-bg.png')",
192 | pending: "url('/assets/images/pending-bg.png')",
193 | cancelled: "url('/assets/images/cancelled-bg.png')",
194 | },
195 | keyframes: {
196 | "accordion-down": {
197 | from: { height: "0" },
198 | to: { height: "var(--radix-accordion-content-height)" },
199 | },
200 | "accordion-up": {
201 | from: { height: "var(--radix-accordion-content-height)" },
202 | to: { height: "0" },
203 | },
204 | "caret-blink": {
205 | "0%,70%,100%": { opacity: "1" },
206 | "20%,50%": { opacity: "0" },
207 | },
208 | },
209 | animation: {
210 | "accordion-down": "accordion-down 0.2s ease-out",
211 | "accordion-up": "accordion-up 0.2s ease-out",
212 | "caret-blink": "caret-blink 1.25s ease-out infinite",
213 | },
214 | },
215 | },
216 | plugins: [require("tailwindcss-animate")],
217 | } satisfies Config;
218 |
219 | export default config;
220 | ```
221 |
222 |
223 |
224 |
225 | app/globals.css
226 |
227 | ```css
228 | @tailwind base;
229 | @tailwind components;
230 | @tailwind utilities;
231 |
232 | /* ========================================== TAILWIND STYLES */
233 | @layer base {
234 | /* Remove scrollbar */
235 | .remove-scrollbar::-webkit-scrollbar {
236 | width: 0px;
237 | height: 0px;
238 | border-radius: 0px;
239 | }
240 |
241 | .remove-scrollbar::-webkit-scrollbar-track {
242 | background: transparent;
243 | }
244 |
245 | .remove-scrollbar::-webkit-scrollbar-thumb {
246 | background: transparent;
247 | border-radius: 0px;
248 | }
249 |
250 | .remove-scrollbar::-webkit-scrollbar-thumb:hover {
251 | /* background: #1e2238; */
252 | background: transparent;
253 | }
254 | }
255 |
256 | @layer utilities {
257 | /* ===== UTILITIES */
258 | .sidebar {
259 | @apply remove-scrollbar w-full max-w-72 flex-col overflow-auto bg-black-800 px-7 py-10;
260 | }
261 |
262 | .left-sidebar {
263 | @apply hidden lg:flex;
264 | }
265 |
266 | .right-sidebar {
267 | @apply hidden xl:flex;
268 | }
269 |
270 | .clip-text {
271 | @apply bg-clip-text text-transparent;
272 | }
273 |
274 | .bg-image {
275 | @apply bg-black-900 bg-light-rays bg-cover bg-no-repeat;
276 | }
277 |
278 | .header {
279 | @apply text-32-bold md:text-36-bold;
280 | }
281 |
282 | .sub-header {
283 | @apply text-18-bold md:text-24-bold;
284 | }
285 |
286 | .container {
287 | @apply relative flex-1 overflow-y-auto px-[5%];
288 | }
289 |
290 | .sub-container {
291 | @apply mx-auto flex size-full flex-col py-10;
292 | }
293 |
294 | .side-img {
295 | @apply hidden h-full object-cover md:block;
296 | }
297 |
298 | .copyright {
299 | @apply text-14-regular justify-items-end text-center text-dark-600 xl:text-left;
300 | }
301 |
302 | /* ==== SUCCESS */
303 | .success-img {
304 | @apply m-auto flex flex-1 flex-col items-center justify-between gap-10 py-10;
305 | }
306 |
307 | .request-details {
308 | @apply flex w-full flex-col items-center gap-8 border-y-2 border-dark-400 py-8 md:w-fit md:flex-row;
309 | }
310 |
311 | /* ===== ADMIN */
312 | .admin-header {
313 | @apply sticky top-3 z-20 mx-3 flex items-center justify-between rounded-2xl bg-dark-200 px-[5%] py-5 shadow-lg xl:px-12;
314 | }
315 |
316 | .admin-main {
317 | @apply flex flex-col items-center space-y-6 px-[5%] pb-12 xl:space-y-12 xl:px-12;
318 | }
319 |
320 | .admin-stat {
321 | @apply flex w-full flex-col justify-between gap-5 sm:flex-row xl:gap-10;
322 | }
323 |
324 | /* ==== FORM */
325 | .radio-group {
326 | @apply flex h-full flex-1 items-center gap-2 rounded-md border border-dashed border-dark-500 bg-dark-400 p-3;
327 | }
328 |
329 | .checkbox-label {
330 | @apply cursor-pointer text-sm font-medium text-dark-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 md:leading-none;
331 | }
332 |
333 | /* ==== File Upload */
334 | .file-upload {
335 | @apply text-12-regular flex cursor-pointer flex-col items-center justify-center gap-3 rounded-md border border-dashed border-dark-500 bg-dark-400 p-5;
336 | }
337 |
338 | .file-upload_label {
339 | @apply flex flex-col justify-center gap-2 text-center text-dark-600;
340 | }
341 |
342 | /* ==== Stat Card */
343 | .stat-card {
344 | @apply flex flex-1 flex-col gap-6 rounded-2xl bg-cover p-6 shadow-lg;
345 | }
346 |
347 | /* ==== Status Badge */
348 | .status-badge {
349 | @apply flex w-fit items-center gap-2 rounded-full px-4 py-2;
350 | }
351 |
352 | /* Data Table */
353 | .data-table {
354 | @apply z-10 w-full overflow-hidden rounded-lg border border-dark-400 shadow-lg;
355 | }
356 |
357 | .table-actions {
358 | @apply flex w-full items-center justify-between space-x-2 p-4;
359 | }
360 |
361 | /* ===== ALIGNMENTS */
362 | .flex-center {
363 | @apply flex items-center justify-center;
364 | }
365 |
366 | .flex-between {
367 | @apply flex items-center justify-between;
368 | }
369 |
370 | /* ===== TYPOGRAPHY */
371 | .text-36-bold {
372 | @apply text-[36px] leading-[40px] font-bold;
373 | }
374 |
375 | .text-24-bold {
376 | @apply text-[24px] leading-[28px] font-bold;
377 | }
378 |
379 | .text-32-bold {
380 | @apply text-[32px] leading-[36px] font-bold;
381 | }
382 |
383 | .text-18-bold {
384 | @apply text-[18px] leading-[24px] font-bold;
385 | }
386 |
387 | .text-16-semibold {
388 | @apply text-[16px] leading-[20px] font-semibold;
389 | }
390 |
391 | .text-16-regular {
392 | @apply text-[16px] leading-[20px] font-normal;
393 | }
394 |
395 | .text-14-medium {
396 | @apply text-[14px] leading-[18px] font-medium;
397 | }
398 |
399 | .text-14-regular {
400 | @apply text-[14px] leading-[18px] font-normal;
401 | }
402 |
403 | .text-12-regular {
404 | @apply text-[12px] leading-[16px] font-normal;
405 | }
406 |
407 | .text-12-semibold {
408 | @apply text-[12px] leading-[16px] font-semibold;
409 | }
410 |
411 | /* ===== SHADCN OVERRIDES */
412 | .shad-primary-btn {
413 | @apply bg-green-500 text-white !important;
414 | }
415 |
416 | .shad-danger-btn {
417 | @apply bg-red-700 text-white !important;
418 | }
419 |
420 | .shad-gray-btn {
421 | @apply border border-dark-500 cursor-pointer bg-dark-400 text-white !important;
422 | }
423 |
424 | .shad-input-label {
425 | @apply text-14-medium text-dark-700 !important;
426 | }
427 |
428 | .shad-input {
429 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
430 | }
431 |
432 | .shad-input-icon {
433 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
434 | }
435 |
436 | .shad-textArea {
437 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
438 | }
439 |
440 | .shad-combobox-item {
441 | @apply data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 !important;
442 | }
443 |
444 | .shad-combobox-trigger {
445 | @apply h-11 !important;
446 | }
447 |
448 | .shad-select-trigger {
449 | @apply bg-dark-400 placeholder:text-dark-600 border-dark-500 h-11 focus:ring-0 focus:ring-offset-0 !important;
450 | }
451 |
452 | .shad-select-content {
453 | @apply bg-dark-400 border-dark-500 !important;
454 | }
455 |
456 | .shad-dialog {
457 | @apply bg-dark-400 border-dark-500 !important;
458 | }
459 |
460 | .shad-dialog button {
461 | @apply focus:ring-0 focus:ring-offset-0 focus-visible:border-none focus-visible:outline-none focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
462 | }
463 |
464 | .shad-error {
465 | @apply text-red-400 !important;
466 | }
467 |
468 | .shad-table {
469 | @apply rounded-lg overflow-hidden !important;
470 | }
471 |
472 | .shad-table-row-header {
473 | @apply border-b border-dark-400 text-light-200 hover:bg-transparent !important;
474 | }
475 |
476 | .shad-table-row {
477 | @apply border-b border-dark-400 text-light-200 !important;
478 | }
479 |
480 | .shad-otp {
481 | @apply w-full flex justify-between !important;
482 | }
483 |
484 | .shad-otp-slot {
485 | @apply text-36-bold justify-center flex border border-dark-500 rounded-lg size-16 gap-4 !important;
486 | }
487 |
488 | .shad-alert-dialog {
489 | @apply space-y-5 bg-dark-400 border-dark-500 outline-none !important;
490 | }
491 |
492 | .shad-sheet-content button {
493 | @apply top-2 focus:ring-0 focus:ring-offset-0 focus-visible:border-none focus-visible:outline-none focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
494 | }
495 |
496 | /* ===== REACT PHONE NUMBER INPUT OVERRIDES */
497 | .input-phone {
498 | @apply mt-2 h-11 rounded-md px-3 text-sm border bg-dark-400 placeholder:text-dark-600 border-dark-500 !important;
499 | }
500 |
501 | /* ===== REACT DATE PICKER OVERRIDES */
502 | .date-picker {
503 | @apply overflow-hidden border-transparent w-full placeholder:text-dark-600 h-11 text-14-medium rounded-md px-3 outline-none !important;
504 | }
505 | }
506 |
507 | /* ===== REACT-DATEPICKER OVERRIDES */
508 | .react-datepicker-wrapper.date-picker {
509 | display: flex;
510 | align-items: center;
511 | }
512 |
513 | .react-datepicker,
514 | .react-datepicker__time,
515 | .react-datepicker__header,
516 | .react-datepicker__current-month,
517 | .react-datepicker__day-name,
518 | .react-datepicker__day,
519 | .react-datepicker-time__header {
520 | background-color: #1a1d21 !important;
521 | border-color: #363a3d !important;
522 | color: #abb8c4 !important;
523 | }
524 |
525 | .react-datepicker__current-month,
526 | .react-datepicker__day-name,
527 | .react-datepicker-time__header {
528 | color: #ffffff !important;
529 | }
530 |
531 | .react-datepicker__triangle {
532 | fill: #1a1d21 !important;
533 | color: #1a1d21 !important;
534 | stroke: #1a1d21 !important;
535 | }
536 |
537 | .react-datepicker__time-list-item:hover {
538 | background-color: #363a3d !important;
539 | }
540 |
541 | .react-datepicker__input-container input {
542 | background-color: #1a1d21 !important;
543 | width: 100%;
544 | outline: none;
545 | }
546 |
547 | .react-datepicker__day--selected {
548 | background-color: #24ae7c !important;
549 | color: #ffffff !important;
550 | border-radius: 4px;
551 | }
552 |
553 | .react-datepicker__time-list-item--selected {
554 | background-color: #24ae7c !important;
555 | }
556 |
557 | .react-datepicker__time-container {
558 | border-left: 1px solid #363a3d !important;
559 | }
560 |
561 | .react-datepicker__time-list-item {
562 | display: flex !important;
563 | align-items: center !important;
564 | }
565 |
566 | /* ===== REACT PHONE NUMBER INPUT OVERRIDES */
567 | .PhoneInputInput {
568 | outline: none;
569 | margin-left: 4px;
570 | background: #1a1d21;
571 | font-size: 14px;
572 | font-weight: 500;
573 | }
574 |
575 | .PhoneInputInput::placeholder {
576 | color: #1a1d21;
577 | }
578 | ```
579 |
580 |
581 |
582 |
583 | types/index.d.ts
584 |
585 | ```typescript
586 | /* eslint-disable no-unused-vars */
587 |
588 | declare type SearchParamProps = {
589 | params: { [key: string]: string };
590 | searchParams: { [key: string]: string | string[] | undefined };
591 | };
592 |
593 | declare type Gender = "Male" | "Female" | "Other";
594 | declare type Status = "pending" | "scheduled" | "cancelled";
595 |
596 | declare interface CreateUserParams {
597 | name: string;
598 | email: string;
599 | phone: string;
600 | }
601 | declare interface User extends CreateUserParams {
602 | $id: string;
603 | }
604 |
605 | declare interface RegisterUserParams extends CreateUserParams {
606 | userId: string;
607 | birthDate: Date;
608 | gender: Gender;
609 | address: string;
610 | occupation: string;
611 | emergencyContactName: string;
612 | emergencyContactNumber: string;
613 | primaryPhysician: string;
614 | insuranceProvider: string;
615 | insurancePolicyNumber: string;
616 | allergies: string | undefined;
617 | currentMedication: string | undefined;
618 | familyMedicalHistory: string | undefined;
619 | pastMedicalHistory: string | undefined;
620 | identificationType: string | undefined;
621 | identificationNumber: string | undefined;
622 | identificationDocument: FormData | undefined;
623 | privacyConsent: boolean;
624 | }
625 |
626 | declare type CreateAppointmentParams = {
627 | userId: string;
628 | patient: string;
629 | primaryPhysician: string;
630 | reason: string;
631 | schedule: Date;
632 | status: Status;
633 | note: string | undefined;
634 | };
635 |
636 | declare type UpdateAppointmentParams = {
637 | appointmentId: string;
638 | userId: string;
639 | appointment: Appointment;
640 | type: string;
641 | };
642 | ```
643 |
644 |
645 |
646 |
647 | types/appwrite.types.ts
648 |
649 | ```typescript
650 | import { Models } from "node-appwrite";
651 |
652 | export interface Patient extends Models.Document {
653 | userId: string;
654 | name: string;
655 | email: string;
656 | phone: string;
657 | birthDate: Date;
658 | gender: Gender;
659 | address: string;
660 | occupation: string;
661 | emergencyContactName: string;
662 | emergencyContactNumber: string;
663 | primaryPhysician: string;
664 | insuranceProvider: string;
665 | insurancePolicyNumber: string;
666 | allergies: string | undefined;
667 | currentMedication: string | undefined;
668 | familyMedicalHistory: string | undefined;
669 | pastMedicalHistory: string | undefined;
670 | identificationType: string | undefined;
671 | identificationNumber: string | undefined;
672 | identificationDocument: FormData | undefined;
673 | privacyConsent: boolean;
674 | }
675 |
676 | export interface Appointment extends Models.Document {
677 | patient: Patient;
678 | schedule: Date;
679 | status: Status;
680 | primaryPhysician: string;
681 | reason: string;
682 | note: string;
683 | userId: string;
684 | cancellationReason: string | null;
685 | }
686 | ```
687 |
688 |
689 |
690 |
691 | lib/utils.ts
692 |
693 | ```typescript
694 | import { type ClassValue, clsx } from "clsx";
695 | import { twMerge } from "tailwind-merge";
696 |
697 | export function cn(...inputs: ClassValue[]) {
698 | return twMerge(clsx(inputs));
699 | }
700 |
701 | export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
702 |
703 | export const convertFileToUrl = (file: File) => URL.createObjectURL(file);
704 |
705 | // FORMAT DATE TIME
706 | export const formatDateTime = (dateString: Date | string) => {
707 | const dateTimeOptions: Intl.DateTimeFormatOptions = {
708 | // weekday: "short", // abbreviated weekday name (e.g., 'Mon')
709 | month: "short", // abbreviated month name (e.g., 'Oct')
710 | day: "numeric", // numeric day of the month (e.g., '25')
711 | year: "numeric", // numeric year (e.g., '2023')
712 | hour: "numeric", // numeric hour (e.g., '8')
713 | minute: "numeric", // numeric minute (e.g., '30')
714 | hour12: true, // use 12-hour clock (true) or 24-hour clock (false)
715 | };
716 |
717 | const dateDayOptions: Intl.DateTimeFormatOptions = {
718 | weekday: "short", // abbreviated weekday name (e.g., 'Mon')
719 | year: "numeric", // numeric year (e.g., '2023')
720 | month: "2-digit", // abbreviated month name (e.g., 'Oct')
721 | day: "2-digit", // numeric day of the month (e.g., '25')
722 | };
723 |
724 | const dateOptions: Intl.DateTimeFormatOptions = {
725 | month: "short", // abbreviated month name (e.g., 'Oct')
726 | year: "numeric", // numeric year (e.g., '2023')
727 | day: "numeric", // numeric day of the month (e.g., '25')
728 | };
729 |
730 | const timeOptions: Intl.DateTimeFormatOptions = {
731 | hour: "numeric", // numeric hour (e.g., '8')
732 | minute: "numeric", // numeric minute (e.g., '30')
733 | hour12: true, // use 12-hour clock (true) or 24-hour clock (false)
734 | };
735 |
736 | const formattedDateTime: string = new Date(dateString).toLocaleString(
737 | "en-US",
738 | dateTimeOptions
739 | );
740 |
741 | const formattedDateDay: string = new Date(dateString).toLocaleString(
742 | "en-US",
743 | dateDayOptions
744 | );
745 |
746 | const formattedDate: string = new Date(dateString).toLocaleString(
747 | "en-US",
748 | dateOptions
749 | );
750 |
751 | const formattedTime: string = new Date(dateString).toLocaleString(
752 | "en-US",
753 | timeOptions
754 | );
755 |
756 | return {
757 | dateTime: formattedDateTime,
758 | dateDay: formattedDateDay,
759 | dateOnly: formattedDate,
760 | timeOnly: formattedTime,
761 | };
762 | };
763 |
764 | export function encryptKey(passkey: string) {
765 | return btoa(passkey);
766 | }
767 |
768 | export function decryptKey(passkey: string) {
769 | return atob(passkey);
770 | }
771 | ```
772 |
773 |
774 |
775 |
776 | lib/validation.ts
777 |
778 | ```typescript
779 | import { z } from "zod";
780 |
781 | export const UserFormValidation = z.object({
782 | name: z
783 | .string()
784 | .min(2, "Name must be at least 2 characters")
785 | .max(50, "Name must be at most 50 characters"),
786 | email: z.string().email("Invalid email address"),
787 | phone: z
788 | .string()
789 | .refine((phone) => /^\+\d{10,15}$/.test(phone), "Invalid phone number"),
790 | });
791 |
792 | export const PatientFormValidation = z.object({
793 | name: z
794 | .string()
795 | .min(2, "Name must be at least 2 characters")
796 | .max(50, "Name must be at most 50 characters"),
797 | email: z.string().email("Invalid email address"),
798 | phone: z
799 | .string()
800 | .refine((phone) => /^\+\d{10,15}$/.test(phone), "Invalid phone number"),
801 | birthDate: z.coerce.date(),
802 | gender: z.enum(["Male", "Female", "Other"]),
803 | address: z
804 | .string()
805 | .min(5, "Address must be at least 5 characters")
806 | .max(500, "Address must be at most 500 characters"),
807 | occupation: z
808 | .string()
809 | .min(2, "Occupation must be at least 2 characters")
810 | .max(500, "Occupation must be at most 500 characters"),
811 | emergencyContactName: z
812 | .string()
813 | .min(2, "Contact name must be at least 2 characters")
814 | .max(50, "Contact name must be at most 50 characters"),
815 | emergencyContactNumber: z
816 | .string()
817 | .refine(
818 | (emergencyContactNumber) => /^\+\d{10,15}$/.test(emergencyContactNumber),
819 | "Invalid phone number"
820 | ),
821 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
822 | insuranceProvider: z
823 | .string()
824 | .min(2, "Insurance name must be at least 2 characters")
825 | .max(50, "Insurance name must be at most 50 characters"),
826 | insurancePolicyNumber: z
827 | .string()
828 | .min(2, "Policy number must be at least 2 characters")
829 | .max(50, "Policy number must be at most 50 characters"),
830 | allergies: z.string().optional(),
831 | currentMedication: z.string().optional(),
832 | familyMedicalHistory: z.string().optional(),
833 | pastMedicalHistory: z.string().optional(),
834 | identificationType: z.string().optional(),
835 | identificationNumber: z.string().optional(),
836 | identificationDocument: z.custom().optional(),
837 | treatmentConsent: z
838 | .boolean()
839 | .default(false)
840 | .refine((value) => value === true, {
841 | message: "You must consent to treatment in order to proceed",
842 | }),
843 | disclosureConsent: z
844 | .boolean()
845 | .default(false)
846 | .refine((value) => value === true, {
847 | message: "You must consent to disclosure in order to proceed",
848 | }),
849 | privacyConsent: z
850 | .boolean()
851 | .default(false)
852 | .refine((value) => value === true, {
853 | message: "You must consent to privacy in order to proceed",
854 | }),
855 | });
856 |
857 | export const CreateAppointmentSchema = z.object({
858 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
859 | schedule: z.coerce.date(),
860 | reason: z
861 | .string()
862 | .min(2, "Reason must be at least 2 characters")
863 | .max(500, "Reason must be at most 500 characters"),
864 | note: z.string().optional(),
865 | cancellationReason: z.string().optional(),
866 | });
867 |
868 | export const ScheduleAppointmentSchema = z.object({
869 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
870 | schedule: z.coerce.date(),
871 | reason: z.string().optional(),
872 | note: z.string().optional(),
873 | cancellationReason: z.string().optional(),
874 | });
875 |
876 | export const CancelAppointmentSchema = z.object({
877 | primaryPhysician: z.string().min(2, "Select at least one doctor"),
878 | schedule: z.coerce.date(),
879 | reason: z.string().optional(),
880 | note: z.string().optional(),
881 | cancellationReason: z
882 | .string()
883 | .min(2, "Reason must be at least 2 characters")
884 | .max(500, "Reason must be at most 500 characters"),
885 | });
886 |
887 | export function getAppointmentSchema(type: string) {
888 | switch (type) {
889 | case "create":
890 | return CreateAppointmentSchema;
891 | case "cancel":
892 | return CancelAppointmentSchema;
893 | default:
894 | return ScheduleAppointmentSchema;
895 | }
896 | }
897 | ```
898 |
899 |
900 |
901 |
902 | constants/index.ts
903 |
904 | ```typescript
905 | export const GenderOptions = ["Male", "Female", "Other"];
906 |
907 | export const PatientFormDefaultValues = {
908 | firstName: "",
909 | lastName: "",
910 | email: "",
911 | phone: "",
912 | birthDate: new Date(Date.now()),
913 | gender: "Male" as Gender,
914 | address: "",
915 | occupation: "",
916 | emergencyContactName: "",
917 | emergencyContactNumber: "",
918 | primaryPhysician: "",
919 | insuranceProvider: "",
920 | insurancePolicyNumber: "",
921 | allergies: "",
922 | currentMedication: "",
923 | familyMedicalHistory: "",
924 | pastMedicalHistory: "",
925 | identificationType: "Birth Certificate",
926 | identificationNumber: "",
927 | identificationDocument: [],
928 | treatmentConsent: false,
929 | disclosureConsent: false,
930 | privacyConsent: false,
931 | };
932 |
933 | export const IdentificationTypes = [
934 | "Birth Certificate",
935 | "Driver's License",
936 | "Medical Insurance Card/Policy",
937 | "Military ID Card",
938 | "National Identity Card",
939 | "Passport",
940 | "Resident Alien Card (Green Card)",
941 | "Social Security Card",
942 | "State ID Card",
943 | "Student ID Card",
944 | "Voter ID Card",
945 | ];
946 |
947 | export const Doctors = [
948 | {
949 | image: "/assets/images/dr-green.png",
950 | name: "John Green",
951 | },
952 | {
953 | image: "/assets/images/dr-cameron.png",
954 | name: "Leila Cameron",
955 | },
956 | {
957 | image: "/assets/images/dr-livingston.png",
958 | name: "David Livingston",
959 | },
960 | {
961 | image: "/assets/images/dr-peter.png",
962 | name: "Evan Peter",
963 | },
964 | {
965 | image: "/assets/images/dr-powell.png",
966 | name: "Jane Powell",
967 | },
968 | {
969 | image: "/assets/images/dr-remirez.png",
970 | name: "Alex Ramirez",
971 | },
972 | {
973 | image: "/assets/images/dr-lee.png",
974 | name: "Jasmine Lee",
975 | },
976 | {
977 | image: "/assets/images/dr-cruz.png",
978 | name: "Alyana Cruz",
979 | },
980 | {
981 | image: "/assets/images/dr-sharma.png",
982 | name: "Hardik Sharma",
983 | },
984 | ];
985 |
986 | export const StatusIcon = {
987 | scheduled: "/assets/icons/check.svg",
988 | pending: "/assets/icons/pending.svg",
989 | cancelled: "/assets/icons/cancelled.svg",
990 | };
991 | ```
992 |
993 |
994 |
995 | ## 🔗 Assets
996 |
997 | Public assets used in the project can be found [here](https://drive.google.com/file/d/1yGvWFeSaH1_-aiQ1gejT23lqz5979RKB/view?usp=sharing)
998 |
999 | ## 🚀 More
1000 |
1001 | **Advance your skills with Next.js 14 Pro Course**
1002 |
1003 | Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!
1004 |
1005 |
1006 |
1007 |
1008 |
1009 |
1010 |
1011 |
1012 | **Accelerate your professional journey with the Expert Training program**
1013 |
1014 | And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!
1015 |
1016 |
1017 |
1018 |
1019 |
1020 | #
1021 |
--------------------------------------------------------------------------------