The frontend was a Create React App scaffold (deprecated) where App.tsx was still the stock 'Learn React' boilerplate — the implemented login/register pages and stores were never mounted (no router). This swaps to Vite/TS5, fixes the broken API client, wires a real router + dashboard, and lands on a runnable app. Tooling swap (CRA -> Vite): * Dropped react-scripts, web-vitals, @types/jest; added vite 6 + @vitejs/plugin-react + vitest 3 + jsdom; bumped typescript 4.9 -> 5.6, @types/node 16 -> 22. * New vite.config.ts (with dev-server /api proxy -> backend, no CORS in dev), tsconfig.json (target es2022, moduleResolution bundler, vite/client types), tsconfig.node.json, vite-env.d.ts, root index.html. * .env / .env.example (VITE_API_URL + VITE_API_TARGET). Renamed index.tsx -> main.tsx (theme provider + CssBaseline). Deleted all CRA boilerplate (App.css, logo.svg, App.test.tsx, reportWebVitals.ts, react-app-env.d.ts, CRA index.html/logos/readme) + the orphaned empty web/src/ and mobile/ trees. * node_modules 479MB -> 265MB; lockfile 695KB -> 198KB. API client correctness (services/api.ts): * Env var process.env.REACT_APP_* -> import.meta.env.VITE_*; dropped the hardcoded http://solaria:8001/api (now /api via the dev proxy). * Fixed contract mismatches: getCurrentUser /auth/me -> /users/me; updateMedication PUT -> POST /:id; deleteMedication DELETE -> POST /:id/delete. * Added refreshToken() (/auth/refresh) and server-side logout() (/auth/logout). * Removed the /lab-results block (no backend route). * 401 interceptor now attempts ONE silent refresh (deduped) before bouncing to /login, instead of hard-redirecting on every 401. * CreateMedicationRequest type now matches the backend (required route + profile_id fields). Router + dashboard: * Real App.tsx with BrowserRouter: /login, /register (public), / (protected Dashboard), catch-all -> /. * New Dashboard.tsx: MUI AppBar + welcome card, loads the current user on mount, logout button. The shell feature UIs get built into next. * LoginPage/RegisterPage navigate to / (was /dashboard); store logout is now async; AuthTokens type includes refresh_token. * Minimal MUI theme.ts. Verified: npm run build clean (TS5 strict + Vite), dev server serves the app + proxies /api to the backend, vitest runs. Solaria round-trip confirmed all corrected endpoints (/users/me 200, /auth/refresh 200, /auth/logout 204, medication create 200).
385 lines
10 KiB
TypeScript
385 lines
10 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import { User, Medication, HealthStat, DrugInteraction } from '../types/api';
|
|
import apiService from '../services/api';
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
token: string | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
login: (email: string, password: string) => Promise<void>;
|
|
register: (username: string, email: string, password: string) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
clearError: () => void;
|
|
loadUser: () => Promise<void>;
|
|
}
|
|
|
|
interface MedicationState {
|
|
medications: Medication[];
|
|
selectedMedication: Medication | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
loadMedications: () => Promise<void>;
|
|
createMedication: (data: any) => Promise<void>;
|
|
updateMedication: (id: string, data: any) => Promise<void>;
|
|
deleteMedication: (id: string) => Promise<void>;
|
|
selectMedication: (medication: Medication | null) => void;
|
|
clearError: () => void;
|
|
}
|
|
|
|
interface HealthState {
|
|
stats: HealthStat[];
|
|
trends: any[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
loadStats: () => Promise<void>;
|
|
createStat: (data: any) => Promise<void>;
|
|
updateStat: (id: string, data: any) => Promise<void>;
|
|
deleteStat: (id: string) => Promise<void>;
|
|
loadTrends: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
interface InteractionState {
|
|
interactions: DrugInteraction[];
|
|
isChecking: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
checkInteractions: (medications: string[]) => Promise<void>;
|
|
checkNewMedication: (name: string, dosage: string) => Promise<void>;
|
|
clearInteractions: () => void;
|
|
clearError: () => void;
|
|
}
|
|
|
|
// Auth Store
|
|
export const useAuthStore = create<AuthState>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
login: async (email: string, password: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const response = await apiService.login(email, password);
|
|
set({
|
|
user: {
|
|
user_id: response.user_id,
|
|
username: response.username,
|
|
email: email,
|
|
role: 'patient',
|
|
},
|
|
token: response.token,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Login failed',
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
register: async (username: string, email: string, password: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const response = await apiService.register({
|
|
username,
|
|
email,
|
|
password,
|
|
role: 'patient',
|
|
});
|
|
set({
|
|
user: {
|
|
user_id: response.user_id,
|
|
username: response.username,
|
|
email: email,
|
|
role: 'patient',
|
|
},
|
|
token: response.token,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Registration failed',
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
logout: async () => {
|
|
await apiService.logout();
|
|
set({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
|
|
loadUser: async () => {
|
|
const token = get().token;
|
|
if (!token) return;
|
|
|
|
set({ isLoading: true });
|
|
try {
|
|
const user = await apiService.getCurrentUser();
|
|
set({
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'normogen-auth',
|
|
partialize: (state) => ({
|
|
token: state.token,
|
|
user: state.user,
|
|
isAuthenticated: state.isAuthenticated,
|
|
}),
|
|
}
|
|
)
|
|
)
|
|
);
|
|
|
|
// Medication Store
|
|
export const useMedicationStore = create<MedicationState>()(
|
|
devtools((set, get) => ({
|
|
medications: [],
|
|
selectedMedication: null,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
loadMedications: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const medications = await apiService.getMedications();
|
|
set({ medications, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load medications',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
createMedication: async (data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const medication = await apiService.createMedication(data);
|
|
set((state) => ({
|
|
medications: [...state.medications, medication],
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to create medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateMedication: async (id: string, data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const updated = await apiService.updateMedication(id, data);
|
|
set((state) => ({
|
|
medications: state.medications.map((med) =>
|
|
med.medication_id === id ? updated : med
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to update medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
deleteMedication: async (id: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
await apiService.deleteMedication(id);
|
|
set((state) => ({
|
|
medications: state.medications.filter(
|
|
(med) => med.medication_id !== id
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to delete medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
selectMedication: (medication) => {
|
|
set({ selectedMedication: medication });
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}))
|
|
);
|
|
|
|
// Health Store
|
|
export const useHealthStore = create<HealthState>()(
|
|
devtools((set, get) => ({
|
|
stats: [],
|
|
trends: [],
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
loadStats: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const stats = await apiService.getHealthStats();
|
|
set({ stats, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load health stats',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
createStat: async (data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const stat = await apiService.createHealthStat(data);
|
|
set((state) => ({
|
|
stats: [...state.stats, stat],
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to create stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateStat: async (id: string, data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const updated = await apiService.updateHealthStat(id, data);
|
|
set((state) => ({
|
|
stats: state.stats.map((stat) =>
|
|
stat.stat_id === id ? updated : stat
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to update stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
deleteStat: async (id: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
await apiService.deleteHealthStat(id);
|
|
set((state) => ({
|
|
stats: state.stats.filter((stat) => stat.stat_id !== id),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to delete stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
loadTrends: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const trends = await apiService.getHealthTrends();
|
|
set({ trends, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load trends',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}))
|
|
);
|
|
|
|
// Interaction Store (Phase 2.8)
|
|
export const useInteractionStore = create<InteractionState>()(
|
|
devtools((set, get) => ({
|
|
interactions: [],
|
|
isChecking: false,
|
|
error: null,
|
|
|
|
checkInteractions: async (medications: string[]) => {
|
|
set({ isChecking: true, error: null });
|
|
try {
|
|
const interactions = await apiService.checkInteractions(medications);
|
|
set({ interactions, isChecking: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to check interactions',
|
|
isChecking: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
checkNewMedication: async (name: string, dosage: string) => {
|
|
set({ isChecking: true, error: null });
|
|
try {
|
|
const interactions = await apiService.checkNewMedication(name, dosage);
|
|
set({ interactions, isChecking: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to check medication',
|
|
isChecking: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
clearInteractions: () => set({ interactions: [] }),
|
|
clearError: () => set({ error: null }),
|
|
}))
|
|
);
|