Clinical appointment portal for patients and administrative staff.
Built with React, Vite, TypeScript, TanStack Query, and Tailwind CSS.
ClinSync Frontend is a production-grade React SPA serving two distinct user contexts: a patient self-scheduling portal and a clinical administration dashboard. Both portals run under a unified codebase with role-based routing guards, shared server state via TanStack Query, and a service layer that supports both live API integration and high-fidelity mock mode.
Design principles applied throughout:
- Every network call goes through a typed service function — no
fetchoraxiosin component files - Every error state has a resolution path — no bare
catchblocks that swallow failures silently - Every loading state has a visual representation — no blank screens or layout shifts
- Every destructive action requires explicit confirmation — no accidental data mutations
Component / Page
│
▼
TanStack Query Hook Server state — caches, deduplicates, background-refetches
│
▼
Service Function Single branching point: mock data OR real API
│
├── VITE_USE_MOCKS=true ──► Typed mock array with simulated delay
│
└── VITE_USE_MOCKS=false ──► httpClient (Axios instance)
│
Request Interceptor
- Attach: Authorization: Bearer <token>
│
Response Interceptor
- 401: clearStorage() + redirect to /
- 409: normalize to user-friendly message
│
NestJS REST API → PostgreSQL
/ (public landing / login)
│
├── GuestGuard Redirects authenticated users to their dashboard
│ ├── / Login form
│ └── /register Patient registration
│
└── AuthGuard Redirects unauthenticated users to /
│
├── RoleGuard(PATIENT)
│ └── /patient/**
│ ├── /patient/dashboard
│ ├── /patient/book
│ ├── /patient/appointments
│ └── /patient/profile
│
└── RoleGuard(ADMIN, RECEPTIONIST)
└── /admin/**
├── /admin/dashboard
├── /admin/appointments
├── /admin/patients
├── /admin/doctors
├── /admin/areas
└── /admin/schedules
Both portals communicate in real time using the browser's native BroadcastChannel API (channel: clinsync_notifications). This approach requires no WebSocket infrastructure and works reliably across same-origin tabs.
[Patient Tab]
Patient completes booking
│
└──► channel.postMessage({ title, message, type, role: 'ADMIN' })
[Admin Tab — NotificationsPopover listener]
channel.onmessage fires
│
├── Notification saved to localStorage
├── Bell counter increments
└── sonner toast slides in (top-right)
[Admin Tab]
Admin validates / reschedules / cancels appointment
│
└──► channel.postMessage({ title, message, type, role: 'PATIENT' })
[Patient Tab — NotificationsPopover listener]
channel.onmessage fires
│
├── Notification saved to localStorage
├── Bell counter increments
└── sonner toast slides in (top-right)
The role field in the message payload ensures notifications are only rendered by the correct portal — admin messages are ignored by patient tabs and vice versa.
# .env.local — connect to live backend
VITE_API_URL=http://localhost:3000/api
VITE_USE_MOCKS=false
# .env.example — run without backend (default for new contributors)
VITE_API_URL=http://localhost:3000/api
VITE_USE_MOCKS=truenpm install
npm run dev # http://localhost:5173| Role | Password | |
|---|---|---|
| Admin | admin@clinsync.com |
12345678 |
| Receptionist | recepcion@clinsync.com |
12345678 |
| Patient | paciente@test.com |
12345678 |
src/
├── app/ # appConfig, router, queryClient
├── components/
│ └── ui/
│ ├── ConfirmModal.tsx # Accessible destructive-action dialog
│ ├── NotificationsPopover.tsx # Bell + BroadcastChannel listener
│ ├── SkeletonCard.tsx # SkeletonCard, SkeletonStatCard, PageLoader
│ └── Logo.tsx
├── features/
│ ├── auth/ # AuthProvider, useAuth, login/register services
│ ├── appointments/ # Hooks, service, mock data, type definitions
│ ├── areas/ # Medical area service with visual metadata mapper
│ ├── doctors/ # Doctor service and hooks
│ ├── patients/ # Patient profile service
│ ├── schedules/ # Schedule service and hooks
│ └── admin/ # Dashboard service and hooks
├── guards/
│ ├── AuthGuard.tsx # Blocks unauthenticated access
│ ├── GuestGuard.tsx # Blocks authenticated users from login/register
│ └── RoleGuard.tsx # Enforces PATIENT vs ADMIN/RECEPTIONIST boundaries
├── layouts/
│ ├── DashboardLayout.tsx # Patient sidebar and header
│ └── AdminLayout.tsx # Admin sidebar and header
├── pages/
│ ├── admin/ # AdminDashboard, AdminAppointment, AdminPatients, etc.
│ └── patient/ # BookAppointment, MyAppointments, etc.
└── services/
├── api/
│ ├── http-client.ts # Axios instance with request/response interceptors
│ ├── endpoints.ts # Centralized URL map — no hardcoded strings in pages
│ └── api-error.ts # Error normalization
└── storage/
└── token-storage.ts # clinsync_access_token + clinsync_auth_user
| Component | Purpose |
|---|---|
ConfirmModal |
Modal dialog for destructive actions. Supports ESC key, focus trap, danger/warning/primary variants, and a loading state that disables the confirm button |
SkeletonCard |
Animated placeholder for list and detail content while data loads |
SkeletonStatCard |
Dashboard-specific skeleton that matches the stat card layout |
PageLoader |
Full-page centered spinner used by AuthGuard during session restoration |
NotificationsPopover |
Bell icon with real-time BroadcastChannel listener, persistent localStorage history, and unread badge counter |
login() called
│
├── setStoredToken(token) → localStorage['clinsync_access_token']
└── setStoredUser(user) → localStorage['clinsync_auth_user']
App reloads
│
└── AuthProvider useEffect reads storage → restores session without API call
Token expires (backend returns 401)
│
└── httpClient response interceptor
├── clearAuthStorage() removes both keys
└── window.location.href = '/' forces re-login
logout() called
│
├── removeStoredToken()
└── removeStoredUser()
tsc -b 0 TypeScript errors (strict mode)
vite build 1887 modules transformed
dist/index.html 0.47 kB gzip: 0.30 kB
dist/assets/index.css 54.69 kB gzip: 10.20 kB
dist/assets/index.js 641.54 kB gzip: 171.68 kB
Built in 1.82 seconds
1. Select medical area (GET /areas)
2. Select available date/time slot (GET /areas/:id/schedules)
3. Review booking details
4. Confirm (POST /appointments)
│
├── Success: slot → OCCUPIED, appointment created, patient notified, admin broadcast sent
└── 409: slot already taken → toast.error with clear resolution message
Created by patient (CONFIRMED)
│
├── validate → VALIDATED_BY_RECEPTION (blocked if already terminal)
├── reschedule → RESCHEDULED (old slot released, new slot locked)
├── cancel → CANCELLED_BY_RECEPTION (reason required, slot released)
└── attendance → ATTENDED | NO_SHOW
Each admin action triggers a BroadcastChannel notification to the patient's active tab.
| Version | Planned |
|---|---|
| v1.1 | Patient profile editing with editable DNI, phone, and emergency contact |
| v1.2 | Paginated patient list with search in admin panel |
| v1.3 | Admin calendar view — visual grid of occupied and available slots by day |
| v2.0 | Replace BroadcastChannel with WebSocket for server-push events |
| v2.1 | Dark mode toggle |
| v2.2 | Progressive Web App (PWA) manifest for mobile installation |