feat(frontend): Phase 2/3 hardening release

Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
  the project-manager PNG in a separate commit).

Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
  and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.

Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
  from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.

Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
  LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
  by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
  pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
  AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
  services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
  and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
  smoke tests, npm scripts test:e2e / test:e2e:install.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:32 +04:00
parent dcf5ea6941
commit e70a2854f4
75 changed files with 3727 additions and 551 deletions

View File

@@ -1,4 +1,12 @@
import { api } from "@/lib/api-client";
import type {
AICourseConfig,
AICourseTrack,
QualityGateResult,
IELTSValidationResult,
} from "@/types/ai-course";
export type { QualityGateResult, IELTSValidationResult };
export interface AiCourseCreateEnglishRequest {
cefr_level: string;
@@ -18,7 +26,11 @@ export interface AiCourseCreateResponse {
skill?: string;
}
export interface QualityGateResult {
/**
* Legacy flat shape returned by older backends. The canonical type is the
* structured {@link QualityGateResult} re-exported above.
*/
export interface QualityGateResultLegacy {
status: string;
readability_score: number;
cefr_alignment: boolean;
@@ -26,7 +38,7 @@ export interface QualityGateResult {
attempts: number;
}
export interface IELTSValidationResult {
export interface IELTSValidationResultLegacy {
type: string;
validation_results: Record<string, unknown>;
overall_passed: boolean;
@@ -40,10 +52,10 @@ export const aiCourseService = {
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
getCourse: (courseId: number) =>
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
api.get<AICourseConfig>(`/ai-course/${courseId}`),
getTracks: (courseId: number) =>
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
getQualityGate: (courseId: number) =>
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),

View File

@@ -0,0 +1,54 @@
import { api } from "@/lib/api-client";
import type { PaginatedResponse } from "@/types/common";
import type {
AIFeedback,
AIFeedbackStatus,
AIFeedbackSubjectType,
AIFeedbackSubmitInput,
AIFeedbackSummary,
} from "@/types/ai-feedback";
export const aiFeedbackService = {
async submit(input: AIFeedbackSubmitInput): Promise<AIFeedback> {
return api.post<AIFeedback>("/ai/feedback", input);
},
async summary(
subjectType: AIFeedbackSubjectType,
subjectId: number,
): Promise<AIFeedbackSummary> {
return api.get<AIFeedbackSummary>("/ai/feedback/summary", {
subject_type: subjectType,
subject_id: subjectId,
});
},
async list(
params: {
status?: AIFeedbackStatus;
rating?: "up" | "down";
subject_type?: AIFeedbackSubjectType;
prompt_key?: string;
page?: number;
size?: number;
} = {},
): Promise<PaginatedResponse<AIFeedback>> {
const res = await api.get<PaginatedResponse<AIFeedback>>("/ai/feedback", params);
const items = res.items ?? res.data ?? [];
return {
items,
total: res.total ?? items.length,
page: res.page ?? 1,
size: res.size ?? items.length,
pages: res.pages ?? 1,
};
},
async resolve(
id: number,
status: Exclude<AIFeedbackStatus, "open">,
notes?: string,
): Promise<AIFeedback> {
return api.post<AIFeedback>(`/ai/feedback/${id}/resolve`, { status, notes });
},
};

View File

@@ -0,0 +1,61 @@
import { api } from "@/lib/api-client";
import type { PaginatedResponse } from "@/types/common";
import type {
AIPrompt,
AIPromptCreateInput,
AIPromptRenderResponse,
AIPromptSummary,
} from "@/types/ai-prompt";
interface PromptListParams {
page?: number;
size?: number;
search?: string;
}
export const aiPromptService = {
async listKeys(
params: PromptListParams = {},
): Promise<PaginatedResponse<AIPromptSummary>> {
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
"/ai/prompts",
params,
);
const items = res.items ?? res.data ?? [];
return {
items,
total: res.total ?? items.length,
page: res.page ?? 1,
size: res.size ?? items.length,
pages: res.pages ?? 1,
};
},
async listVersions(key: string): Promise<AIPromptSummary[]> {
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
`/ai/prompts/${encodeURIComponent(key)}/versions`,
);
return res.items ?? res.data ?? [];
},
async get(id: number): Promise<AIPrompt> {
return api.get<AIPrompt>(`/ai/prompts/${id}`);
},
async create(input: AIPromptCreateInput): Promise<AIPrompt> {
return api.post<AIPrompt>("/ai/prompts", input);
},
async activate(id: number): Promise<AIPrompt> {
return api.post<AIPrompt>(`/ai/prompts/${id}/activate`, {});
},
async render(
id: number,
variables: Record<string, string>,
): Promise<AIPromptRenderResponse> {
return api.post<AIPromptRenderResponse>(`/ai/prompts/${id}/render`, {
variables,
});
},
};

View File

@@ -1,17 +1,36 @@
import { api, setToken, clearToken } from "@/lib/api-client";
import { api, setToken, clearToken, persistTokenBundle } from "@/lib/api-client";
import type { LoginRequest, LoginResponse, User, ResetPasswordRequest, CurrentUserResponse } from "@/types";
import type { ApiSuccessResponse } from "@/types";
export const authService = {
async login(data: LoginRequest): Promise<LoginResponse> {
// Odoo accepts `login` or `email` in JSON body
const res = await api.post<LoginResponse>("/login", { ...data, email: data.login });
setToken(res.token);
const res = await api.post<LoginResponse & {
access_token?: string;
refresh_token?: string;
expires_in?: number;
}>("/login", { ...data, email: data.login });
// Persist the full bundle so the transparent refresh loop in api-client
// has everything it needs. Falls back to the legacy single-token field
// for older backends that only return ``token``.
if (res.access_token || res.refresh_token) {
persistTokenBundle({
access_token: res.access_token,
token: res.token,
refresh_token: res.refresh_token,
expires_in: res.expires_in,
});
} else {
setToken(res.token);
}
return res;
},
async logout(): Promise<void> {
await api.post<void>("/logout").catch(() => {});
const refresh = localStorage.getItem("encoach_refresh_token") || undefined;
await api
.post<void>("/logout", refresh ? { refresh_token: refresh } : undefined)
.catch(() => {});
clearToken();
},

View File

@@ -0,0 +1,57 @@
import { api } from "@/lib/api-client";
import type { PaginatedResponse } from "@/types/common";
import type {
ExamReviewDetail,
ExamReviewListItem,
} from "@/types/exam-review";
export interface ExamReviewQueueParams {
page?: number;
size?: number;
search?: string;
status?: "draft" | "pending_review" | "published" | "archived";
subject_id?: number;
}
/**
* Admin-facing review queue. Everything here assumes the caller has an admin
* JWT — the server is authoritative; the frontend just surfaces errors.
*/
export const examReviewService = {
async queue(
params: ExamReviewQueueParams = {},
): Promise<PaginatedResponse<ExamReviewListItem>> {
const res = await api.get<PaginatedResponse<ExamReviewListItem>>(
"/exam/review/queue",
params,
);
const items = res.items ?? res.data ?? [];
return {
items,
total: res.total ?? items.length,
page: res.page ?? 1,
size: res.size ?? items.length,
pages: res.pages ?? 1,
};
},
async detail(examId: number): Promise<ExamReviewDetail> {
return api.get<ExamReviewDetail>(`/exam/review/${examId}`);
},
async approve(
examId: number,
notes?: string,
): Promise<ExamReviewListItem> {
return api.post<ExamReviewListItem>(
`/exam/review/${examId}/approve`,
notes ? { notes } : {},
);
},
async reject(examId: number, notes: string): Promise<ExamReviewListItem> {
return api.post<ExamReviewListItem>(`/exam/review/${examId}/reject`, {
notes,
});
},
};

View File

@@ -15,6 +15,6 @@ export const examSessionService = {
getStatus: (examId: number) =>
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
getResults: (examId: number) =>
api.get(`/exam/${examId}/results`),
getResults: <T = unknown>(examId: number) =>
api.get<T>(`/exam/${examId}/results`),
};

View File

@@ -0,0 +1,36 @@
import { api } from "@/lib/api-client";
export interface GdprExportPayload {
profile: Record<string, unknown>;
partner: Record<string, unknown>;
entity_memberships: unknown[];
exam_attempts: unknown[];
exam_answers: unknown[];
ai_feedback: unknown[];
ai_calls: unknown[];
tickets: unknown[];
coaching_sessions: unknown[];
exported_at: string;
export_format_version: string;
}
export interface GdprEraseResponse {
ok: boolean;
message: string;
summary: {
anonymised_partner_fields: string[];
deactivated_user: boolean;
deleted_feedback_count: number;
deleted_coaching_count: number;
retained_attempts_count: number;
};
}
export const gdprService = {
async exportMyData(): Promise<GdprExportPayload> {
return api.get<GdprExportPayload>("/gdpr/export");
},
async eraseMyAccount(): Promise<GdprEraseResponse> {
return api.post<GdprEraseResponse>("/gdpr/delete", { confirm: true });
},
};

View File

@@ -130,9 +130,10 @@ export const lmsService = {
},
async listCourses(params?: PaginationParams & { status?: string }): Promise<PaginatedResponse<Course>> {
const raw = await api.get<unknown>("/courses", params as Record<string, string | number | boolean | undefined>);
const raw = await api.get<unknown>("/courses", params);
const p = asPaginated<Record<string, unknown>>(raw);
return { ...p, items: p.items.map(mapCourseRaw) };
const items = p.items.map(mapCourseRaw);
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async getCourse(id: number): Promise<Course> {
@@ -183,9 +184,10 @@ export const lmsService = {
},
async listBatches(params?: PaginationParams): Promise<PaginatedResponse<Batch>> {
const raw = await api.get<unknown>("/batches", params as Record<string, string | number | boolean | undefined>);
const raw = await api.get<unknown>("/batches", params);
const p = asPaginated<Record<string, unknown>>(raw);
return { ...p, items: p.items.map(mapBatchRaw) };
const items = p.items.map(mapBatchRaw);
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async getBatch(id: number): Promise<Batch> {

View File

@@ -1,5 +1,5 @@
import { api } from "@/lib/api-client";
import type { ExamSession, ExamStat, StatisticalData } from "@/types";
import type { StatsExamSession as ExamSession, ExamStat, StatisticalData } from "@/types";
export const statsService = {
async getSessions(params?: Record<string, string | number | boolean | undefined>): Promise<ExamSession[]> {

View File

@@ -11,7 +11,8 @@ export const taxonomyService = {
async getSubject(id: number): Promise<Subject> {
const res = await api.get<{ data: Subject } | Subject>(`/subjects/${id}`);
return (res as { data: Subject }).data ?? res;
const wrapped = res as { data?: Subject };
return (wrapped.data ?? (res as Subject));
},
async createSubject(data: Partial<Subject>): Promise<Subject> {
@@ -28,7 +29,8 @@ export const taxonomyService = {
async getTaxonomyTree(subjectId: number): Promise<TaxonomyTree> {
const res = await api.get<{ data: TaxonomyTree } | TaxonomyTree>(`/subjects/${subjectId}/taxonomy`);
return (res as { data: TaxonomyTree }).data ?? res;
const wrapped = res as { data?: TaxonomyTree };
return (wrapped.data ?? (res as TaxonomyTree));
},
async importTaxonomy(subjectId: number, data: FormData): Promise<ApiSuccessResponse> {

View File

@@ -25,9 +25,10 @@ function mapPortalUser(raw: Record<string, unknown>): User {
export const usersService = {
async list(params: UserListParams): Promise<PaginatedResponse<User>> {
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
const raw = await api.get<unknown>("/users/list", params);
const p = asPaginated<Record<string, unknown>>(raw);
return { ...p, items: p.items.map(mapPortalUser) };
const items = p.items.map(mapPortalUser);
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async getById(id: number): Promise<User> {