feat(v3): restructure project + add complete frontend

- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 11a7265460
392 changed files with 62287 additions and 0 deletions

140
src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,140 @@
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL;
export function extractApiErrorMessage(data: unknown, status: number, statusText: string): string {
if (data && typeof data === "object" && data !== null && "error" in data) {
const msg = (data as { error: unknown }).error;
if (msg != null && String(msg).trim()) return String(msg);
}
return `${status} ${statusText}`;
}
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data: unknown,
) {
super(extractApiErrorMessage(data, status, statusText));
this.name = "ApiError";
}
}
function getToken(): string | null {
return localStorage.getItem("encoach_token");
}
export function setToken(token: string): void {
localStorage.setItem("encoach_token", token);
}
export function clearToken(): void {
localStorage.removeItem("encoach_token");
}
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (response.status === 401) {
const hadToken = !!getToken();
clearToken();
// Login failure is also 401 — do not hard-redirect when no session existed.
if (hadToken) {
window.location.href = "/login";
}
throw new ApiError(401, response.statusText, data);
}
if (!response.ok) {
throw new ApiError(response.status, response.statusText, data);
}
return data as T;
}
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
});
}
return url.toString();
}
export const api = {
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const res = await fetch(buildUrl(path, params), {
method: "GET",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
method: "POST",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async patch<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
method: "PATCH",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async put<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
method: "PUT",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async delete<T>(path: string): Promise<T> {
const res = await fetch(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async upload<T>(path: string, formData: FormData): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path), {
method: "POST",
headers,
body: formData,
});
return handleResponse<T>(res);
},
};

26
src/lib/odoo-api.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { PaginatedResponse } from "@/types";
/** Odoo list endpoints return `{ data: T[], total, page, size }`; SPA types use `items`. */
export function asPaginated<T>(raw: unknown): PaginatedResponse<T> {
const r = raw as {
data?: T[];
items?: T[];
total?: number;
page?: number;
size?: number;
};
const items = r.items ?? r.data ?? [];
const total = r.total ?? items.length;
const page = r.page ?? 1;
const size = r.size ?? (items.length || 20);
const pages = size > 0 ? Math.max(1, Math.ceil(total / size)) : 1;
return { items, total, page, size, pages };
}
/** Single-record wrappers: `{ data: T }` or bare `T`. */
export function asRecordData<T>(raw: unknown): T {
if (raw && typeof raw === "object" && "data" in raw && (raw as { data: unknown }).data !== undefined) {
return (raw as { data: T }).data;
}
return raw as T;
}

19
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,19 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./api-client";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 401) return false;
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 2;
},
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
});

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}