forked from Tadrisino/MathApp_frontend
rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
86
src/state/askStore.ts
Normal file
86
src/state/askStore.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { UsedChunk } from '@/api';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
|
||||
/**
|
||||
* گفتگوی دستیار، بیرون از کامپوننت تا با عوض کردنِ تب پاک نشود.
|
||||
* شناسهی گفتگو در localStorage میماند و بعد از بستنِ اپ تاریخچه از سرور برمیگردد.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
localId: number;
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
messageId?: string;
|
||||
usedChunks?: UsedChunk[];
|
||||
followUps?: string[];
|
||||
feedback?: 'up' | 'down';
|
||||
/** پیامِ خطا بهجای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده میشود. */
|
||||
failedQuestion?: string;
|
||||
}
|
||||
|
||||
interface AskState {
|
||||
messages: ChatMessage[];
|
||||
conversationId: string | null;
|
||||
/** درسی که گفتگو دربارهی آن است؛ عوض شدنش یعنی گفتگوی تازه. */
|
||||
lessonId: number | null;
|
||||
historyLoaded: boolean;
|
||||
}
|
||||
|
||||
let state: AskState = {
|
||||
messages: [],
|
||||
conversationId: store.get(KEYS.conversationId),
|
||||
lessonId: null,
|
||||
historyLoaded: false,
|
||||
};
|
||||
let nextLocalId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(next: Partial<AskState>) {
|
||||
state = { ...state, ...next };
|
||||
if ('conversationId' in next) {
|
||||
if (state.conversationId) store.set(KEYS.conversationId, state.conversationId);
|
||||
else store.remove(KEYS.conversationId);
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export const askStore = {
|
||||
get: () => state,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
add(message: Omit<ChatMessage, 'localId'>): number {
|
||||
const localId = nextLocalId++;
|
||||
emit({ messages: [...state.messages, { ...message, localId }] });
|
||||
return localId;
|
||||
},
|
||||
update(localId: number, patch: Partial<ChatMessage>) {
|
||||
emit({ messages: state.messages.map((m) => (m.localId === localId ? { ...m, ...patch } : m)) });
|
||||
},
|
||||
remove(localId: number) {
|
||||
emit({ messages: state.messages.filter((m) => m.localId !== localId) });
|
||||
},
|
||||
setConversation(conversationId: string | null, lessonId: number | null = state.lessonId) {
|
||||
emit({ conversationId, lessonId });
|
||||
},
|
||||
setHistory(messages: Omit<ChatMessage, 'localId'>[]) {
|
||||
emit({ messages: messages.map((m) => ({ ...m, localId: nextLocalId++ })), historyLoaded: true });
|
||||
},
|
||||
markHistoryLoaded() {
|
||||
emit({ historyLoaded: true });
|
||||
},
|
||||
reset(lessonId: number | null = null) {
|
||||
emit({ messages: [], conversationId: null, lessonId, historyLoaded: true });
|
||||
},
|
||||
/** بعد از خروج از حساب، گفتگوی کاربرِ قبلی نباید بماند. */
|
||||
clear() {
|
||||
emit({ messages: [], conversationId: null, lessonId: null, historyLoaded: false });
|
||||
},
|
||||
};
|
||||
|
||||
export function useAskState(): AskState {
|
||||
return useSyncExternalStore(askStore.subscribe, askStore.get);
|
||||
}
|
||||
Reference in New Issue
Block a user