add a typed API layer for the Tadrisino backend

اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها
هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای
را تغییر دهد.

عمداً فقط فایلِ جدید اضافه شده و App.tsx دست نخورده است، چون کارِ در جریانِ
برنچِ updateApp روی همان فایل است و نباید با هم تصادف کنند. وصل کردنِ
صفحه‌ها به این لایه کارِ بعدی‌ست.

هیچ پکیجِ جدیدی اضافه نشد — فقط fetch استاندارد.

چیزهایی که در client.ts هست برای حالتِ خراب‌اند، نه حالتِ خوب:
  • timeout روی هر درخواست — وگرنه بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا
    مرورگر به سقفِ ۶ درخواستِ همزمان بخورد و اپ قفل شود.
  • backoff با jitter — بدونِ jitter همه‌ی کلاینت‌ها بعد از یک خطا همزمان
    دوباره می‌زنند و موجِ دوم از موجِ اول بدتر است.
  • تکرارِ خودکار فقط روی GET؛ POST فقط وقتی Idempotency-Key دارد (ثبتِ پاسخ،
    سفارش) تا دابل‌کلیکِ کاربر دو بار XP یا دو سفارش نسازد.
  • refresh تک‌نسخه‌ای — ۲۰ درخواستِ همزمانِ ۴۰۱ نباید ۲۰ بار refresh بزنند.
  • ادغامِ GETهای یکسانِ در جریان.
  • احترام به هدرِ Retry-After که بک‌اند موقعِ throttle می‌فرستد.

credentials: 'omit' عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ سرور
CORS_ALLOW_CREDENTIALS=False تنظیم شده.

.env هم به .gitignore اضافه شد؛ قبلاً فقط node_modules داخلش بود و آدرس/
تنظیماتِ محلی می‌توانست ناخواسته کامیت شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:46:05 +03:30
parent 3cca227337
commit be9f99d52a
7 changed files with 722 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/**
* بررسیِ اتصال به بک‌اند — ابزارِ توسعه، نه بخشی از محصول.
*
* برای امتحان کردن، یک خط در App.tsx (یا هر جای دیگر) کافی‌ست:
*
* import { ConnectionCheck } from '@/api/ConnectionCheck';
* <ConnectionCheck />
*
* هیچ فایلِ دیگری را عوض نمی‌کند و اگر بک‌اند بالا نباشد هم اپ را نمی‌خواباند.
*/
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { api, ApiError } from '@/api';
import type { Grade } from '@/api';
type State =
| { kind: 'loading' }
| { kind: 'ok'; grades: Grade[] }
| { kind: 'error'; message: string; hint: string };
function hintFor(error: unknown): string {
if (!(error instanceof ApiError)) return 'خطای پیش‌بینی‌نشده — کنسول را ببینید.';
if (error.isNetwork) {
return 'سرور جواب نداد. VITE_API_BASE_URL درست است؟ بک‌اند بالاست؟';
}
if (error.status === 502) {
return 'nginx بالاست ولی جنگو نه — سرویسِ بک‌اند روی سرور اجرا نشده.';
}
if (error.status === 0) return 'ارتباط برقرار نشد.';
if (error.isRateLimited) return 'سقفِ نرخ خورد؛ کلاینت خودش دوباره تلاش می‌کند.';
if (error.isUnauthorized) return 'نیاز به ورود دارد (این اندپوینت نباید داشته باشد).';
return `سرور ${error.status} داد.`;
}
export function ConnectionCheck() {
const [state, setState] = useState<State>({ kind: 'loading' });
useEffect(() => {
const controller = new AbortController();
api
.grades({ signal: controller.signal })
.then((grades) => setState({ kind: 'ok', grades }))
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setState({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
hint: hintFor(error),
});
});
return () => controller.abort();
}, []);
const box: CSSProperties = {
position: 'fixed', bottom: 12, right: 12, zIndex: 9999,
maxWidth: 320, padding: '10px 12px', borderRadius: 10,
font: '12px/1.6 system-ui, sans-serif', direction: 'rtl',
background: '#fff', border: '1px solid #e5e7eb',
boxShadow: '0 4px 16px rgba(0,0,0,.08)',
};
if (state.kind === 'loading') {
return <div style={box}>در حالِ بررسیِ اتصال به بک‌اند…</div>;
}
if (state.kind === 'error') {
return (
<div style={{ ...box, borderColor: '#fecaca' }}>
<b style={{ color: '#b91c1c' }}>اتصال برقرار نشد</b>
<div style={{ marginTop: 4 }}>{state.message}</div>
<div style={{ marginTop: 4, color: '#6b7280' }}>{state.hint}</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}
return (
<div style={{ ...box, borderColor: '#bbf7d0' }}>
<b style={{ color: '#15803d' }}>اتصال برقرار است</b>
<div style={{ marginTop: 4 }}>
{state.grades.length} پایه از سرور آمد
{state.grades.length > 0 && `: ${state.grades.map((g) => g.title).join('، ')}`}
</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}