Add read-only Noya admin panel with assignments and UX polish.
Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+70
-36
@@ -1,7 +1,9 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AdminRoute } from '@/components/AdminRoute';
|
||||
import { AdminLayout } from '@/components/admin/AdminLayout';
|
||||
import { AppHeader } from '@/components/AppHeader';
|
||||
import { HomeRedirect } from '@/components/HomeRedirect';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -14,36 +16,27 @@ const FinishedPage = lazy(() => import('@/pages/Finished/FinishedPage'));
|
||||
const LoginPage = lazy(() => import('@/pages/Auth/Login/LoginPage'));
|
||||
const SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage'));
|
||||
const ProfileCompletionPage = lazy(() => import('@/pages/Profile/ProfileCompletionPage'));
|
||||
const AssignmentsPage = lazy(() => import('@/pages/Assignments/AssignmentsPage'));
|
||||
const AssignmentDetailPage = lazy(() => import('@/pages/Assignments/AssignmentDetailPage'));
|
||||
const AdminUsersPage = lazy(() => import('@/pages/Admin/AdminUsersPage'));
|
||||
const AdminUserDetailPage = lazy(() => import('@/pages/Admin/AdminUserDetailPage'));
|
||||
|
||||
const PageLoader = () => <PageSkeleton />;
|
||||
|
||||
function AppRoutes() {
|
||||
/** Mobile PWA chrome: phone shell + journey header. */
|
||||
function PwaLayout() {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute allowIncompleteProfile>
|
||||
<ProfileCompletionPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<ProtectedRoute><HomeRedirect /></ProtectedRoute>} />
|
||||
<Route path="/chat" element={<ProtectedRoute><ChatPage /></ProtectedRoute>} />
|
||||
<Route path="/assessment/:phase" element={<ProtectedRoute><AssessmentPage /></ProtectedRoute>} />
|
||||
<Route path="/finished" element={<ProtectedRoute><FinishedPage /></ProtectedRoute>} />
|
||||
<Route path="/course-status" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
|
||||
<Route path="/account" element={<Navigate to="/course-status" replace />} />
|
||||
<Route path="/request" element={<Navigate to="/" replace />} />
|
||||
<Route path="/program" element={<Navigate to="/" replace />} />
|
||||
<Route path="/program/:sessionId" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<div className="app-desktop-stage">
|
||||
<div className="app-mobile-shell text-foreground">
|
||||
<AppHeader />
|
||||
<main className="app-main">
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,15 +46,56 @@ function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-desktop-stage">
|
||||
<div className="app-mobile-shell text-foreground">
|
||||
<AppHeader />
|
||||
<main className="app-main">
|
||||
<AppRoutes />
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Admin: full-width desktop shell (not the mobile PWA frame) */}
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminUsersPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users/:externalId"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminUserDetailPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Learner PWA */}
|
||||
<Route element={<PwaLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute allowIncompleteProfile>
|
||||
<ProfileCompletionPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<ProtectedRoute><HomeRedirect /></ProtectedRoute>} />
|
||||
<Route path="/chat" element={<ProtectedRoute><ChatPage /></ProtectedRoute>} />
|
||||
<Route path="/assessment/:phase" element={<ProtectedRoute><AssessmentPage /></ProtectedRoute>} />
|
||||
<Route path="/finished" element={<ProtectedRoute><FinishedPage /></ProtectedRoute>} />
|
||||
<Route path="/course-status" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
|
||||
<Route path="/assignments" element={<ProtectedRoute><AssignmentsPage /></ProtectedRoute>} />
|
||||
<Route path="/assignments/:id" element={<ProtectedRoute><AssignmentDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/account" element={<Navigate to="/course-status" replace />} />
|
||||
<Route path="/request" element={<Navigate to="/" replace />} />
|
||||
<Route path="/program" element={<Navigate to="/" replace />} />
|
||||
<Route path="/program/:sessionId" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type BackendUser = {
|
||||
email: string | null;
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
role: 'user' | 'admin';
|
||||
};
|
||||
|
||||
export type GuestSession = {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { authInitialCheckDoneAtom } from '@/atoms/auth';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { ROUTES } from '@/utils/constants';
|
||||
import { PageSkeleton } from '@/components/skeletons';
|
||||
|
||||
type AdminRouteProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Frontend-only gate. Backend still enforces requireAdmin on every /admin API.
|
||||
*/
|
||||
export function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { user, isAuthenticated, guestSessionHydrated, isGuest } = useAuth();
|
||||
const [authInitialCheckDone] = useAtom(authInitialCheckDoneAtom);
|
||||
const location = useLocation();
|
||||
|
||||
if (!authInitialCheckDone || !guestSessionHydrated) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || isGuest || !user) {
|
||||
return <Navigate to={ROUTES.LOGIN} state={{ from: { pathname: location.pathname } }} replace />;
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">{children}</div>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { APP_NAME } from '@/utils/constants';
|
||||
import { BrandLogo } from '@/components/BrandLogo';
|
||||
import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react';
|
||||
import { ClipboardList, LogOut, Moon, NotebookPen, Sun, User } from 'lucide-react';
|
||||
import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -127,6 +127,26 @@ export function AppHeader() {
|
||||
<ClipboardList className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
وضعیت دوره
|
||||
</Link>
|
||||
<Link
|
||||
to="/assignments"
|
||||
role="menuitem"
|
||||
className="header-menu-item"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
تکالیف من
|
||||
</Link>
|
||||
{user?.role === 'admin' ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
role="menuitem"
|
||||
className="header-menu-item"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
پنل ادمین
|
||||
</Link>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { APP_NAME } from '@/utils/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Full-viewport admin shell — not the mobile PWA frame.
|
||||
* Journey AppHeader / app-mobile-shell are intentionally absent.
|
||||
*/
|
||||
export function AdminLayout() {
|
||||
const { user, signOut } = useAuth();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
setConfirmLogout(false);
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-app-shell flex h-dvh min-h-0 flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||||
<header className="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{APP_NAME}
|
||||
<span className="mx-2 text-slate-300 dark:text-slate-600">|</span>
|
||||
<span className="text-slate-600 dark:text-slate-300">پنل ادمین</span>
|
||||
</p>
|
||||
{user?.email ? (
|
||||
<p className="truncate text-[11px] text-slate-500" dir="ltr">
|
||||
{user.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-700',
|
||||
'hover:bg-slate-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
)}
|
||||
>
|
||||
بازگشت به اپ
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 dark:border-slate-600 dark:text-slate-300"
|
||||
aria-label={isDark ? 'حالت روشن' : 'حالت تاریک'}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmLogout(true)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs text-slate-700 dark:border-slate-600 dark:text-slate-200"
|
||||
aria-label="خروج از حساب"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{confirmLogout ? (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-slate-900/40 p-4 sm:items-center">
|
||||
<div className="w-full max-w-sm space-y-4 rounded-xl border border-slate-200 bg-white p-5 shadow-lg dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 className="text-base font-semibold">خروج از پنل ادمین</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
از حساب کاربری خارج میشوید؟
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmLogout(false)}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm dark:border-slate-600"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex-1 rounded-lg bg-slate-900 px-3 py-2 text-sm text-white dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Shared admin page chrome — keep slate panel look (existing admin UI). */
|
||||
export function AdminPageShell({
|
||||
children,
|
||||
className,
|
||||
maxWidthClassName = 'max-w-5xl',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
maxWidthClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 dark:bg-slate-900',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('mx-auto w-full space-y-6', maxWidthClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-50">{title}</h1>
|
||||
{subtitle ? (
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">{subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSection({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
actions,
|
||||
}: {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{title ? (
|
||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-50">{title}</h2>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminInfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-slate-100 py-2 text-sm last:border-0 dark:border-slate-700">
|
||||
<span className="text-slate-500 dark:text-slate-400">{label}</span>
|
||||
<span className="max-w-full break-words text-slate-900 dark:text-slate-50">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminStatusPill({
|
||||
ok,
|
||||
label,
|
||||
}: {
|
||||
ok: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
|
||||
ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminLoadingBlock({
|
||||
label = 'در حال بارگذاری…',
|
||||
className,
|
||||
rows = 3,
|
||||
}: {
|
||||
label?: string;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('space-y-3 py-2', className)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">{label}</p>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-10 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminStatsSkeleton({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 md:grid-cols-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
aria-label="در حال بارگذاری آمار"
|
||||
>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="mt-2 h-7 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ErrorTone = 'error' | 'warning';
|
||||
|
||||
export function AdminErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
retryLabel = 'تلاش مجدد',
|
||||
tone = 'error',
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
tone?: ErrorTone;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-4 text-sm',
|
||||
tone === 'warning'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'
|
||||
: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p>{message}</p>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-lg border border-current/25 px-3 py-1.5 text-xs font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-2 px-4 py-8 text-center',
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</p>
|
||||
{description ? (
|
||||
<p className="max-w-sm text-xs text-slate-500 dark:text-slate-400">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="pt-1">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSecondaryButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-700 transition-opacity disabled:pointer-events-none disabled:opacity-40 dark:border-slate-600 dark:text-slate-200',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPrimaryButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-lg bg-slate-900 px-4 py-2 text-sm text-white dark:bg-slate-100 dark:text-slate-900',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400',
|
||||
'disabled:pointer-events-none disabled:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminBackLink({
|
||||
to,
|
||||
label = 'بازگشت به فهرست',
|
||||
}: {
|
||||
to: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
'rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-700 dark:border-slate-600 dark:text-slate-200',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAdminDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAdminPercent(value: number | null | undefined): string {
|
||||
if (value == null) return '—';
|
||||
return `${value.toLocaleString('fa-IR')}٪`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BackendError } from '@/services/backend';
|
||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
||||
|
||||
export function getAdminErrorMessage(
|
||||
error: unknown,
|
||||
fallback = 'دریافت دادههای ادمین ناموفق بود.',
|
||||
): string {
|
||||
if (error instanceof BackendError) {
|
||||
if (error.status === 403 || error.code === 'FORBIDDEN') {
|
||||
return 'دسترسی غیرمجاز به این بخش.';
|
||||
}
|
||||
if (error.status === 404 || error.code === 'NOT_FOUND') {
|
||||
return 'مورد درخواستی یافت نشد.';
|
||||
}
|
||||
}
|
||||
return getBackendErrorMessage(error, fallback);
|
||||
}
|
||||
|
||||
export function isAdminForbiddenError(error: unknown): boolean {
|
||||
return error instanceof BackendError && (error.status === 403 || error.code === 'FORBIDDEN');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
AdminPageShell,
|
||||
AdminPageHeader,
|
||||
AdminSection,
|
||||
AdminInfoRow,
|
||||
AdminStatusPill,
|
||||
AdminLoadingBlock,
|
||||
AdminStatsSkeleton,
|
||||
AdminErrorState,
|
||||
AdminEmptyState,
|
||||
AdminSecondaryButton,
|
||||
AdminPrimaryButton,
|
||||
AdminBackLink,
|
||||
formatAdminDate,
|
||||
formatAdminPercent,
|
||||
} from './AdminUi';
|
||||
export { getAdminErrorMessage, isAdminForbiddenError } from './adminErrors';
|
||||
export { AdminLayout } from './AdminLayout';
|
||||
@@ -0,0 +1,206 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { AssignmentFormField } from '@/services/assignmentService';
|
||||
|
||||
type Props = {
|
||||
fields: AssignmentFormField[] | null;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function isVisible(field: AssignmentFormField, values: Record<string, unknown>): boolean {
|
||||
if (!field.visibleWhen) return true;
|
||||
return values[field.visibleWhen.field] === field.visibleWhen.equals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-driven assignment fields (shared by one-time draft and daily entry forms).
|
||||
*/
|
||||
export const AssignmentFormFields = ({ fields, values, onChange, disabled }: Props) => {
|
||||
if (!fields) {
|
||||
return (
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
rows={6}
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={String(values.text ?? '')}
|
||||
onChange={(e) => onChange('text', e.target.value)}
|
||||
placeholder="پاسخ خود را بنویسید..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => {
|
||||
if (!isVisible(field, values)) return null;
|
||||
|
||||
if (field.type === 'checkbox') {
|
||||
const checked = Boolean(values[field.key]);
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(field.key, !checked)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-right transition-colors',
|
||||
checked
|
||||
? 'border-sky-500 bg-sky-50 dark:border-sky-400 dark:bg-sky-950/50'
|
||||
: 'border-slate-200 bg-white dark:border-slate-600 dark:bg-slate-800',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border',
|
||||
checked
|
||||
? 'border-sky-600 bg-sky-600 text-white'
|
||||
: 'border-slate-300 dark:border-slate-500',
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{field.label}</span>
|
||||
{field.hint ? (
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{field.hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
const items = Array.isArray(values[field.key])
|
||||
? (values[field.key] as Record<string, unknown>[])
|
||||
: [{}];
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
{field.required ? <span className="mr-1 text-red-500">*</span> : null}
|
||||
</p>
|
||||
{field.hint ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{field.hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${field.key}-${index}`}
|
||||
className="space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-600"
|
||||
>
|
||||
{(field.itemFields ?? []).map((sub) => (
|
||||
<div key={sub.key}>
|
||||
{sub.type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={Boolean(item[sub.key])}
|
||||
onChange={(e) => {
|
||||
const next = [...items];
|
||||
next[index] = { ...item, [sub.key]: e.target.checked };
|
||||
onChange(field.key, next);
|
||||
}}
|
||||
/>
|
||||
{sub.label}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
{sub.label}
|
||||
{sub.required ? <span className="mr-1 text-red-500">*</span> : null}
|
||||
</label>
|
||||
{sub.type === 'textarea' ? (
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={String(item[sub.key] ?? '')}
|
||||
onChange={(e) => {
|
||||
const next = [...items];
|
||||
next[index] = { ...item, [sub.key]: e.target.value };
|
||||
onChange(field.key, next);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={String(item[sub.key] ?? '')}
|
||||
onChange={(e) => {
|
||||
const next = [...items];
|
||||
next[index] = { ...item, [sub.key]: e.target.value };
|
||||
onChange(field.key, next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length > 1 && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600"
|
||||
onClick={() => onChange(field.key, items.filter((_, i) => i !== index))}
|
||||
>
|
||||
حذف این مورد
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(field.key, [...items, {}])}
|
||||
>
|
||||
افزودن مورد دیگر
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
{field.required ? <span className="mr-1 text-red-500">*</span> : null}
|
||||
</label>
|
||||
{field.hint ? <p className="text-xs text-muted-foreground">{field.hint}</p> : null}
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
rows={3}
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={String(values[field.key] ?? '')}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder="اینجا بنویس..."
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={String(values[field.key] ?? '')}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder="اینجا بنویس..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ type MessageBubbleProps = {
|
||||
type ContentBlock =
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'heading'; text: string }
|
||||
| { type: 'list'; ordered: boolean; items: string[] }
|
||||
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[] }
|
||||
| { type: 'table'; headers: string[]; rows: string[][] };
|
||||
|
||||
const stripImageMarkdown = (text: string): string =>
|
||||
@@ -131,12 +131,21 @@ const parseTableRow = (line: string): string[] =>
|
||||
const isTableLine = (line: string): boolean =>
|
||||
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
|
||||
|
||||
const isOrderedItem = (line: string): boolean => /^\s*(?:\d+|[۰-۹]+)[.)]\s+\S/.test(line);
|
||||
/** Western, Persian, or Arabic-Indic digits + . or ) */
|
||||
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))[.)]\s+(\S.*)$/;
|
||||
|
||||
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
|
||||
|
||||
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
|
||||
|
||||
const parseOrderedItem = (line: string): { marker: string; text: string } | null => {
|
||||
const match = line.match(ORDERED_ITEM_RE);
|
||||
if (!match) return null;
|
||||
return { marker: match[1] ?? '', text: match[2] ?? '' };
|
||||
};
|
||||
|
||||
const stripListMarker = (line: string): string =>
|
||||
line.replace(/^\s*(?:(?:\d+|[۰-۹]+)[.)]|[-•*])\s+/, '');
|
||||
line.replace(/^\s*(?:(?:\d+|[۰-۹]+|[٠-٩]+)[.)]|[-•*])\s+/, '');
|
||||
|
||||
/** Markdown ## / # headings (stripped before display). */
|
||||
const isHeadingLine = (line: string): boolean => /^#{1,6}\s*\S/.test(line.trim());
|
||||
@@ -149,9 +158,12 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
||||
const blocks: ContentBlock[] = [];
|
||||
let i = 0;
|
||||
|
||||
// Each non-empty line becomes its own paragraph (ChatGPT-like airy breaks).
|
||||
const flushParagraph = (buffer: string[]) => {
|
||||
const joined = buffer.join('\n').trim();
|
||||
if (joined) blocks.push({ type: 'p', text: joined });
|
||||
for (const line of buffer) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) blocks.push({ type: 'p', text: trimmed });
|
||||
}
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
@@ -188,14 +200,25 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
||||
if (isOrderedItem(line) || isBulletItem(line)) {
|
||||
const ordered = isOrderedItem(line);
|
||||
const items: string[] = [];
|
||||
const markers: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i] ?? '';
|
||||
if (!current.trim()) break;
|
||||
if (ordered ? !isOrderedItem(current) : !isBulletItem(current)) break;
|
||||
items.push(stripListMarker(current));
|
||||
if (ordered) {
|
||||
const parsed = parseOrderedItem(current);
|
||||
if (!parsed) break;
|
||||
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
|
||||
markers.push(parsed.marker);
|
||||
items.push(parsed.text);
|
||||
} else {
|
||||
items.push(stripListMarker(current));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'list', ordered, items });
|
||||
blocks.push(
|
||||
ordered ? { type: 'list', ordered: true, items, markers } : { type: 'list', ordered: false, items }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -252,12 +275,11 @@ export const MessageBubble = ({
|
||||
const showCursor = shouldStream && isStreaming;
|
||||
const isActivelyTyping = shouldStream && isStreaming;
|
||||
|
||||
// Parse structure while streaming too (ChatGPT-like spacing as text appears).
|
||||
const blocks = isAssistant
|
||||
? isActivelyTyping
|
||||
? body.trim()
|
||||
? [{ type: 'p' as const, text: body }]
|
||||
: []
|
||||
: parseBlocks(body)
|
||||
? body.trim()
|
||||
? parseBlocks(body)
|
||||
: []
|
||||
: body.trim()
|
||||
? [{ type: 'p' as const, text: body.trim() }]
|
||||
: [];
|
||||
@@ -296,7 +318,7 @@ export const MessageBubble = ({
|
||||
className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}
|
||||
>
|
||||
{blocks.length > 0 || showCursor ? (
|
||||
<div className={cn(isAssistant ? 'space-y-3' : 'space-y-2')}>
|
||||
<div className={cn(isAssistant ? 'space-y-4' : 'space-y-2')}>
|
||||
{(blocks.length > 0 ? blocks : [{ type: 'p' as const, text: '' }]).map((block, index) => {
|
||||
const isLast = index === Math.max(blocks.length - 1, 0);
|
||||
const cursor = showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
|
||||
@@ -316,13 +338,33 @@ export const MessageBubble = ({
|
||||
<ListTag
|
||||
className={cn(
|
||||
'space-y-1.5 text-base leading-7 text-foreground',
|
||||
block.ordered ? 'list-decimal' : 'list-disc'
|
||||
block.ordered ? 'list-none pe-0 ps-0' : 'list-disc'
|
||||
)}
|
||||
>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li key={`${chatMessage.id}-li-${index}-${itemIndex}`} className="chat-prose">
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
|
||||
<li
|
||||
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
|
||||
className={cn(
|
||||
'chat-prose',
|
||||
block.ordered && 'flex items-start gap-2'
|
||||
)}
|
||||
>
|
||||
{block.ordered ? (
|
||||
<>
|
||||
<span className="chat-num shrink-0 font-semibold" dir="ltr">
|
||||
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}.
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>
|
||||
@@ -402,10 +444,8 @@ export const MessageBubble = ({
|
||||
}
|
||||
|
||||
if (block.type === 'p') {
|
||||
const proseClass = cn(
|
||||
'chat-prose break-words text-base leading-7 text-foreground',
|
||||
isActivelyTyping && 'whitespace-pre-wrap'
|
||||
);
|
||||
const proseClass =
|
||||
'chat-prose break-words text-base leading-7 text-foreground';
|
||||
|
||||
if (isActivelyTyping) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ContentBlock, ProgramSession } from '@/types/contentBlocks';
|
||||
import { getHomeworkForSession, type SessionHomework } from './sessionHomework';
|
||||
|
||||
export type ProgramSessionWithHomework = ProgramSession & {
|
||||
homework: SessionHomework | null;
|
||||
};
|
||||
|
||||
function padSession(order: number): string {
|
||||
return String(order).padStart(2, '0');
|
||||
}
|
||||
|
||||
function hasHomeworkSection(blocks: ContentBlock[]): boolean {
|
||||
return blocks.some(
|
||||
(block) =>
|
||||
(block.type === 'heading' && block.text.includes('تکلیف')) ||
|
||||
(block.type === 'exercise' && Boolean(block.title?.includes('تکلیف'))),
|
||||
);
|
||||
}
|
||||
|
||||
function buildHomeworkBlocks(sessionOrder: number, homework: SessionHomework): ContentBlock[] {
|
||||
const pad = padSession(sessionOrder);
|
||||
return [
|
||||
{ id: `s${pad}-d-hw`, type: 'divider' },
|
||||
{ id: `s${pad}-h-hw`, type: 'heading', text: 'تکلیف خانگی', level: 2 },
|
||||
{
|
||||
id: `s${pad}-e-hw`,
|
||||
type: 'exercise',
|
||||
title: homework.title,
|
||||
prompt: homework.prompt,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Attach homework metadata and (when missing) end-of-session homework blocks. */
|
||||
export function withSessionHomework(session: ProgramSession): ProgramSessionWithHomework {
|
||||
const homework = getHomeworkForSession(session.order);
|
||||
if (!homework) {
|
||||
return { ...session, homework: null };
|
||||
}
|
||||
|
||||
if (hasHomeworkSection(session.blocks)) {
|
||||
return { ...session, homework };
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
homework,
|
||||
blocks: [...session.blocks, ...buildHomeworkBlocks(session.order, homework)],
|
||||
};
|
||||
}
|
||||
|
||||
export function attachHomeworkToSessions(sessions: ProgramSession[]): ProgramSessionWithHomework[] {
|
||||
return sessions.map(withSessionHomework);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Homework (تکلیف خانگی) assigned at the end of sessions 2–10.
|
||||
* Session 1 has none (held together with session 2).
|
||||
* Session 11 is final and assigns none.
|
||||
*
|
||||
* Soft check-in: prior homework is prompted but does not block the next session.
|
||||
*/
|
||||
|
||||
export type SessionHomework = {
|
||||
title: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export const SESSION_HOMEWORK: Readonly<Record<number, SessionHomework>> = {
|
||||
2: {
|
||||
title: 'تمرین شادی در زندگی روزمره',
|
||||
prompt:
|
||||
'تا جلسه بعد، حداقل یک فعالیت نشاطآور مورد علاقهات را انجام بده (یا یک کار تکراری را با آرامش و بدون عجله انجام بده). یادداشت کن چه کردی و بعد از آن چه حسی داشتی.',
|
||||
},
|
||||
3: {
|
||||
title: 'معنا و هدف شخصی',
|
||||
prompt:
|
||||
'تا جلسه بعد، یک هدف کوچک اما معنادار برای روزها یا هفتهی پیش رو بنویس و یک قدم عملی برای نزدیک شدن به آن بردار. در گزارش بگو هدفت چه بود و چه قدمی برداشتی.',
|
||||
},
|
||||
4: {
|
||||
title: 'صبر جمیل در یک موقعیت سخت',
|
||||
prompt:
|
||||
'تا جلسه بعد، یک موقعیت سخت واقعی را انتخاب کن و تمرین کن در آن صبر جمیل پیشه کنی (بدون ناسپاسی، شکوه و ناامیدی). بنویس موقعیت چه بود و چگونه صبر کردی.',
|
||||
},
|
||||
5: {
|
||||
title: 'تمرین گذشت',
|
||||
prompt:
|
||||
'تا جلسه بعد، به کسی فکر کن که از او رنجش یا کینه داری. اثر این حالت را روی خودت بررسی کن و اگر آمادهای، یک گام کوچک بهسوی گذشت یا سبککردن دل بردار. گزارش بده چه کردی و چه احساسی داشتی.',
|
||||
},
|
||||
6: {
|
||||
title: 'شوخطبعی آگاهانه',
|
||||
prompt:
|
||||
'تا جلسه بعد، هر روز حداقل یک کار سرگرمکننده/جالب انجام بده یا با زبان و واژهها بازی کن (جوک، جملهی دوپهلو، یا موقعیت خندهدار بیآزار). چند مورد را یادداشت کن.',
|
||||
},
|
||||
7: {
|
||||
title: 'شمارش نعمتهای خدا',
|
||||
prompt:
|
||||
'تا جلسه بعد، نعمتهای خدا را بشمار و شکر آنها را به جا آور. چند نعمت معمولاً عادی (مثل آب دهان، دستها، رطوبت چشمها، پدیدههای طبیعت) را فهرست کن و برای هر کدام یک شکر زبانی، قلبی یا عملی بنویس.',
|
||||
},
|
||||
8: {
|
||||
title: 'سپاسگزاری از دیگران',
|
||||
prompt:
|
||||
'تا جلسه بعد، یک نفر را که به تو نیکی کرده به یاد بیاور؛ کار خوب او را بنویس، ارزش آن را توضیح بده، و حداقل یکی از انواع قدردانی را انجام بده (دعا، جبران، یا تشکر زبانی).',
|
||||
},
|
||||
9: {
|
||||
title: 'تمرین امید و خوشبینی',
|
||||
prompt:
|
||||
'تا جلسه بعد، چند جنبهی مثبت زندگیات را بنویس و نقشها/نعمتهای پابرجای خودت را در برابر دشواری فعلیات در دو ستون قرار بده. گزارش کن چه چیزهایی دیدی و چه حسی داشتی.',
|
||||
},
|
||||
10: {
|
||||
title: 'اعمال محبتآمیز',
|
||||
prompt:
|
||||
'تا جلسه بعد، چند عمل مهربانانه بدون چشمداشت انجام بده و آنها را یادداشت کن (چه کردی، برای چه کسی، و بدون انتظار چه پاسخی).',
|
||||
},
|
||||
};
|
||||
|
||||
/** Sessions that assign homework at the end */
|
||||
export function getHomeworkForSession(sessionOrder: number): SessionHomework | null {
|
||||
return SESSION_HOMEWORK[sessionOrder] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting session `sessionOrder` requires a report for the previous session's homework
|
||||
* when that previous session assigned homework (i.e. sessions 3–11).
|
||||
*/
|
||||
export function getHomeworkReportRequiredFor(sessionOrder: number): number | null {
|
||||
if (sessionOrder < 3) {
|
||||
return null;
|
||||
}
|
||||
const previousOrder = sessionOrder - 1;
|
||||
return getHomeworkForSession(previousOrder) ? previousOrder : null;
|
||||
}
|
||||
@@ -7,14 +7,16 @@
|
||||
*/
|
||||
|
||||
import type { ContentBlock, ProgramSession } from '@/types/contentBlocks';
|
||||
import { attachHomeworkToSessions } from './attachHomework';
|
||||
|
||||
export type { ProgramSession, ContentBlock };
|
||||
|
||||
/**
|
||||
* Session list — ordered by `order` ascending.
|
||||
* Add, remove, or reorder entries here.
|
||||
* Homework sections for sessions 2–10 are attached below.
|
||||
*/
|
||||
export const PROGRAM_SESSIONS: ProgramSession[] = [
|
||||
const RAW_PROGRAM_SESSIONS: ProgramSession[] = [
|
||||
{
|
||||
id: 'session-01',
|
||||
order: 1,
|
||||
@@ -1662,6 +1664,8 @@ export const PROGRAM_SESSIONS: ProgramSession[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PROGRAM_SESSIONS = attachHomeworkToSessions(RAW_PROGRAM_SESSIONS);
|
||||
|
||||
/** Total number of sessions */
|
||||
export const PROGRAM_SESSION_COUNT = PROGRAM_SESSIONS.length;
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ export function useAuth() {
|
||||
email: data.email,
|
||||
isGuest: data.isGuest,
|
||||
profileComplete: data.profileComplete,
|
||||
role: data.role ?? 'user',
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -12,11 +12,9 @@ import type { ProgramSession } from '@/data/program/sessions';
|
||||
import {
|
||||
buildAiCoachSessionContext,
|
||||
getWaitingForNextSession,
|
||||
type AiCoachSessionContext
|
||||
} from '@/lib/aiCoach/sessionContext';
|
||||
import {
|
||||
getCurrentEducationalSession,
|
||||
type AiCoachSessionContext,
|
||||
} from '@/lib/aiCoach/sessionContext';
|
||||
import { getCurrentEducationalSession } from '@/lib/aiCoach/sessionContext';
|
||||
import {
|
||||
getProgramProgress,
|
||||
isProgramCompleted,
|
||||
@@ -41,40 +39,75 @@ export interface UseCurrentSessionResult {
|
||||
isProgramComplete: boolean;
|
||||
/** Backend-synced progress snapshot (cached for UI bars) */
|
||||
programProgress: ProgramProgress | null;
|
||||
/** True until the first backend (or cache) sync finishes */
|
||||
progressLoading: boolean;
|
||||
/** Cooldown before the next session unlocks */
|
||||
waitingForNextSession: WaitingForNextSession | null;
|
||||
/** Re-read progress (e.g. after a future AI-driven completeSession) */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(
|
||||
userId: string,
|
||||
progress: ProgramProgress,
|
||||
sessions: Array<ProgramSession & { unlocked?: boolean; completed?: boolean }>,
|
||||
): ProgramSession | null {
|
||||
if (progress.completedAt || progress.completedSessions.length >= PROGRAM_SESSION_COUNT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byOrder = progress.currentSessionOrder
|
||||
? sessions.find((session) => session.order === progress.currentSessionOrder)
|
||||
: undefined;
|
||||
|
||||
if (byOrder && byOrder.unlocked !== false && !progress.completedSessions.includes(byOrder.order)) {
|
||||
return byOrder;
|
||||
}
|
||||
|
||||
const nextUnlocked = sessions.find(
|
||||
(session) =>
|
||||
session.unlocked && !progress.completedSessions.includes(session.order),
|
||||
);
|
||||
if (nextUnlocked) {
|
||||
return nextUnlocked;
|
||||
}
|
||||
|
||||
return getCurrentEducationalSession(userId);
|
||||
}
|
||||
|
||||
export function useCurrentSession(userId: string | null | undefined): UseCurrentSessionResult {
|
||||
const [programProgress, setProgramProgress] = useState<ProgramProgress | null>(null);
|
||||
const [backendCurrentSession, setBackendCurrentSession] = useState<ProgramSession | null>(null);
|
||||
const [progressLoading, setProgressLoading] = useState(Boolean(userId));
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setProgramProgress(null);
|
||||
setBackendCurrentSession(null);
|
||||
setProgressLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgressLoading(true);
|
||||
try {
|
||||
const [progress, sessions] = await Promise.all([
|
||||
syncProgramProgress(userId),
|
||||
syncProgramSessions(userId),
|
||||
]);
|
||||
setProgramProgress(progress);
|
||||
const current =
|
||||
(sessions as Array<ProgramSession & { unlocked?: boolean }>).find(
|
||||
(session) =>
|
||||
session.unlocked &&
|
||||
!progress.completedSessions.includes(session.order)
|
||||
) ?? getCurrentEducationalSession(userId);
|
||||
setBackendCurrentSession(current);
|
||||
setBackendCurrentSession(
|
||||
resolveCurrentSession(
|
||||
userId,
|
||||
progress,
|
||||
sessions as Array<ProgramSession & { unlocked?: boolean; completed?: boolean }>,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
const cached = getProgramProgress(userId);
|
||||
setProgramProgress(cached);
|
||||
setBackendCurrentSession(getCurrentEducationalSession(userId));
|
||||
} finally {
|
||||
setProgressLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
@@ -104,7 +137,7 @@ export function useCurrentSession(userId: string | null | undefined): UseCurrent
|
||||
userId &&
|
||||
(Boolean(programProgress?.completedAt) ||
|
||||
(programProgress?.completedSessions.length ?? 0) >= PROGRAM_SESSION_COUNT ||
|
||||
isProgramCompleted(userId))
|
||||
isProgramCompleted(userId)),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -112,7 +145,8 @@ export function useCurrentSession(userId: string | null | undefined): UseCurrent
|
||||
coachContext,
|
||||
isProgramComplete,
|
||||
programProgress,
|
||||
progressLoading,
|
||||
waitingForNextSession,
|
||||
refresh
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import type { ContentBlock } from '@/types/contentBlocks';
|
||||
import type { AiCoachSessionContext } from '@/lib/aiCoach/sessionContext';
|
||||
import { PROGRAM_SESSIONS } from '@/data/program/sessions';
|
||||
import { SESSION_UNLOCK_INTERVAL_MS } from '@/lib/sessionUnlock';
|
||||
|
||||
const stripHtml = (html: string): string =>
|
||||
html
|
||||
@@ -167,14 +166,14 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str
|
||||
/** Hidden user cue for session opening — never rendered in the Chat UI */
|
||||
export function buildCoachSessionStartCue(sessionOrder: number): string {
|
||||
if (sessionOrder <= 1) {
|
||||
return '[SESSION_START] Begin Session 1 now as the instructional leader. In Persian: brief greeting; introduce yourself as نویا ONCE; introduce «بسته آموزشی مثبتنگر ایرانی ـ اسلامی» and list the ۱۱ session titles ONCE; then list Session 1 contents; then teach the first section. Put every section title on its own line; number titles the first time (۱. عنوان) — NEVER write # or @. Occasional emoji OK, sparingly. Standard Persian punctuation. Learners are teenagers: NEVER ask about همسر. NEVER mention پیشآزمون. Do not ask the user to choose topics. Do not ask how you can help. Do not over-question. Do not invent missing details or assessments.';
|
||||
return '[SESSION_START] Begin Session 1 now as the instructional leader. In informal colloquial Persian (تو، خودمونی، respectful): brief greeting; introduce yourself as نویا ONCE; introduce «بسته آموزشی مثبتنگر ایرانی ـ اسلامی» and list the ۱۱ session titles ONCE; THEN briefly explain homework: there are ۹ تکلیف (after sessions 2–10); after finishing each of those sessions they should do that session’s homework; homework is reviewed and they must fill the fields in the app section «تکالیف من». Keep the homework explanation short (a few lines). Then list Session 1 contents; then teach the first section. Put every section title on its own line; number titles the first time (۱. عنوان) — NEVER write # or @. Occasional emoji OK, sparingly. Standard Persian punctuation. Learners are teenagers: NEVER ask about همسر. NEVER mention پیشآزمون. Do not ask the user to choose topics. Do not ask how you can help. Do not over-question. Do not invent missing details or assessments.';
|
||||
}
|
||||
|
||||
if (sessionOrder >= PROGRAM_SESSIONS.length) {
|
||||
return '[SESSION_START] Begin Session 11 (FINAL) now. In Persian: short greeting; say this is the last session and you will gradually review previous sessions in three parts; start Part 1 ONLY (summarize sessions 1–4 briefly). NEVER duplicate the session title or repeat the same phrase twice (e.g. do NOT say «مرور و جمعبندی مطالب» twice). Do NOT dump all session summaries in one message. NEVER write # or @. NEVER mention پیشآزمون.';
|
||||
return '[SESSION_START] Begin Session 11 (FINAL) now. In informal colloquial Persian (تو، خودمونی، respectful): short greeting; say this is the last session and you will gradually review previous sessions in three parts; start Part 1 ONLY (summarize sessions 1–4 briefly). NEVER duplicate the session title or repeat the same phrase twice (e.g. do NOT say «مرور و جمعبندی مطالب» twice). Do NOT dump all session summaries in one message. NEVER write # or @. NEVER mention پیشآزمون.';
|
||||
}
|
||||
|
||||
return `[SESSION_START] Begin Session ${sessionOrder} now. CRITICAL: This is NOT Session 1. FORBIDDEN in this opening (and forever in this session): re-introducing the full educational package; saying the program has ۱۱ sessions; listing all session titles (توجیه و آشناسازی، شادی، معنا و هدف، …); saying «من نویا هستم». Allowed: short greeting without identity, name THIS session title, optionally list ONLY THIS session's section roadmap, then teach the first section immediately. Put titles on their own line; number titles the first time for THIS session's sections. Standard Persian punctuation. NEVER mention پیشآزمون. Do not dump the full lesson. Do not invent content.`;
|
||||
return `[SESSION_START] Begin Session ${sessionOrder} now. CRITICAL: This is NOT Session 1. In informal colloquial Persian (تو، خودمونی، respectful). FORBIDDEN in this opening (and forever in this session): re-introducing the full educational package; saying the program has ۱۱ sessions; listing all session titles (توجیه و آشناسازی، شادی، معنا و هدف، …); saying «من نویا هستم». Allowed: short greeting without identity, name THIS session title, optionally list ONLY THIS session's section roadmap, then teach the first section immediately. Put titles on their own line; number titles the first time for THIS session's sections. Standard Persian punctuation. NEVER mention پیشآزمون. Do not dump the full lesson. Do not invent content.`;
|
||||
}
|
||||
|
||||
/** @deprecated Prefer buildCoachSessionStartCue(order). Kept for Session-1-oriented tests. */
|
||||
@@ -202,6 +201,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory):
|
||||
• The program consists of multiple guided sessions (${context.totalSessions} sessions)
|
||||
• Briefly list the session titles from PROGRAM OVERVIEW below (names only — do not teach other sessions now)
|
||||
• that learning happens through conversation and interaction along a guided path
|
||||
• HOMEWORK OVERVIEW (mandatory, short, once — right after listing the ۱۱ sessions):
|
||||
– There are ۹ تکلیف in this program (linked to sessions 2–10; sessions 1 and 11 have no homework)
|
||||
– After finishing each of those sessions, the learner should do that session’s homework
|
||||
– Homework is reviewed; they must fill the fields in the app section «تکالیف من»
|
||||
– Keep this explanation warm and brief in informal Persian — do not invent specific homework titles beyond this overview
|
||||
- Introduce yourself as «نویا» at most ONCE in this first greeting. After that, never repeat identity lines.
|
||||
- Then introduce Session 1 «${context.title}», list THIS session's contents from SESSION OUTLINE, and begin teaching the first section.
|
||||
- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیشآزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow done BEFORE chat and is NOT part of the educational conversation.
|
||||
@@ -256,11 +260,12 @@ PROGRAM OVERVIEW: OMITTED for Session ${context.order}.
|
||||
SESSION OPENING (Session 1 — when you receive a SESSION_START cue):
|
||||
1. Brief greeting; introduce yourself as نویا once.
|
||||
2. Introduce «بسته آموزشی مثبتنگر ایرانی ـ اسلامی» and that it has ${context.totalSessions} sessions; list session titles by name (ONCE in the whole program).
|
||||
3. Introduce «${context.title}».
|
||||
4. List Session 1 contents from SESSION OUTLINE.
|
||||
5. Teach the first course section with a little color — not dry recitation.
|
||||
6. At most ONE focused engagement question when appropriate.
|
||||
7. Do NOT mention reviewing a previous session.
|
||||
3. Briefly explain homework (ONCE): ۹ تکلیف after sessions 2–10; do each homework after finishing that session; homework is reviewed and fields must be filled in «تکالیف من».
|
||||
4. Introduce «${context.title}».
|
||||
5. List Session 1 contents from SESSION OUTLINE.
|
||||
6. Teach the first course section with a little color — not dry recitation.
|
||||
7. At most ONE focused engagement question when appropriate.
|
||||
8. Do NOT mention reviewing a previous session.
|
||||
`
|
||||
: context.isFinalSession
|
||||
? `
|
||||
@@ -384,14 +389,17 @@ CURRENT SESSION (source of truth — do not invent content outside this):
|
||||
|
||||
SESSION SCHEDULING (mandatory):
|
||||
- Sessions unlock sequentially.
|
||||
${
|
||||
SESSION_UNLOCK_INTERVAL_MS <= 0
|
||||
? `- After session N is completed and the app saves progress, session N+1 is available immediately. Do NOT tell the learner to wait one hour or any other delay.`
|
||||
: `- After session N is completed, session N+1 unlocks after ${Math.round(SESSION_UNLOCK_INTERVAL_MS / (60 * 60 * 1000))} hour(s) of real wall-clock time from the completion timestamp.`
|
||||
}
|
||||
- Sessions 1 and 2 are available together (session 2 unlocks immediately after session 1).
|
||||
- Sessions 3–10 each unlock 24 hour(s) after the previous session is completed.
|
||||
- Session 11 (final) unlocks immediately after session 10 — no waiting period.
|
||||
- Homework (تکلیف خانگی) is part of sessions 2–10 only. Sessions 1 and 11 have NO homework.
|
||||
- At the end of sessions 2–10, teach the homework section warmly in informal Persian: what to do, why it helps, and invite them to practice until the next session.
|
||||
- You MAY briefly remind them that they can write their homework report later in the app section «تکالیف من» — keep it light, one short line, not a lecture.
|
||||
- Do NOT block teaching to collect a homework report inside chat. Do NOT refuse to continue the lesson because homework is incomplete.
|
||||
- Do NOT invent homework that is not in the current session blocks / homework content.
|
||||
- The CURRENT SESSION above is unlocked for the learner NOW. When it is present, you MUST teach it — do NOT tell the learner to wait for a future scheduled time.
|
||||
- Do NOT invent weekly schedules, daily appointments, or waiting periods that are not in the rules above.
|
||||
- If the learner asks to jump to a locked future session: explain briefly that sessions open in order${SESSION_UNLOCK_INTERVAL_MS <= 0 ? '' : ', after the configured wait from the previous session'}, then continue the current unlocked session.
|
||||
- If the learner asks to jump to a locked future session: explain briefly that sessions open in order (with the 24-hour rule for sessions 3–10), then continue the current unlocked session.
|
||||
- Never refuse to teach the current unlocked session because a later session exists.
|
||||
- When SESSION COMPLETION SIGNAL criteria are met: you MUST emit the hidden completion block. Never say the session is finished without emitting that block. Never claim the next session is blocked by a timer if the current session is still the active unlocked session.
|
||||
|
||||
@@ -469,7 +477,7 @@ NEGATIVE / UNKNOWN ANSWERS:
|
||||
1. Do NOT interrogate further with multiple follow-up questions.
|
||||
2. Briefly explain the key point the question was intended to surface (grounded in current session blocks only).
|
||||
3. Keep the explanation concise; add a short clarification if necessary.
|
||||
4. Move to the next relevant teaching section naturally (e.g. «حالا برویم سراغ بخش بعدی…»).
|
||||
4. Move to the next relevant teaching section naturally (e.g. «حالا بریم سراغ بخش بعدی…»).
|
||||
|
||||
POSITIVE / CORRECT ANSWERS:
|
||||
- Briefly acknowledge useful or correct answers.
|
||||
@@ -477,7 +485,7 @@ POSITIVE / CORRECT ANSWERS:
|
||||
- Continue the lesson — do NOT spend several messages praising the user.
|
||||
|
||||
SECTION-END CHECKPOINTS:
|
||||
- When a meaningful teaching section is finished, you MAY briefly ask: «در مورد مطالبی که تا اینجا گفتیم، سؤالی دارید؟»
|
||||
- When a meaningful teaching section is finished, you MAY briefly ask: «تا اینجا چیزی که گفتم، سؤالی داری؟»
|
||||
- Use this at natural section boundaries only — NOT after every paragraph.
|
||||
- If the user says no: continue naturally to the next section.
|
||||
- If the user asks a question: answer using current session content, then continue the lesson.
|
||||
@@ -485,7 +493,7 @@ SECTION-END CHECKPOINTS:
|
||||
|
||||
INSTRUCTIONAL CONTROL:
|
||||
- You control the instructional sequence for this session.
|
||||
- Do NOT ask the user to choose between lesson topics (e.g. «ترجیح میدهید روی A تمرکز کنیم یا B؟»).
|
||||
- Do NOT ask the user to choose between lesson topics (e.g. «میخوای روی A تمرکز کنیم یا B؟»).
|
||||
- After the user answers, give concise feedback or clarification grounded in the session blocks, then proactively move to the next relevant block/section yourself.
|
||||
|
||||
TEACHING PACING (every turn after opening):
|
||||
@@ -555,14 +563,19 @@ CONVERSATIONAL STYLE:
|
||||
- Preserve course terminology when the blocks use it — then say it in simple spoken words
|
||||
|
||||
MESSAGE FORMATTING (user-facing Persian — readability only, never a content change):
|
||||
- Write full paragraphs that read cleanly when justified (جاستیفای). Prefer complete sentences; avoid one-word lines that are not titles.
|
||||
- Keep paragraphs short and easy to read. Blank line between paragraphs.
|
||||
- Write like ChatGPT: airy, scannable layout — very short paragraphs, lots of visual breaks, never a dense wall of text.
|
||||
- Prefer 1–2 short sentences per paragraph (rarely 3). Put a blank line between EVERY paragraph.
|
||||
- After a heading, list, or colon answer: always leave a blank line before the next block.
|
||||
- Break by idea: when a thought ends (period/؟), start a new paragraph instead of continuing the same block.
|
||||
- Never glue several topics into one long block. After a heading or list, leave breathing room.
|
||||
- HEADINGS (mandatory):
|
||||
• Every section title MUST be on its OWN separate line — never mid-paragraph.
|
||||
• The FIRST time you introduce a section title in this session, number it: ۱. عنوان / ۲. عنوان (or 1. 2.).
|
||||
• Numbers MUST stay sequential and correct for THIS session's teaching order (do not restart at ۱ for every new message; continue from the last section number you already used in this session).
|
||||
• Later mentions of the same title: no number needed; still keep the title on its own line if you restate it as a heading.
|
||||
• Write headings as plain text only, e.g. ۱. عنوان بخش — NEVER use Markdown # / ## / ###.
|
||||
• After a heading, put body text on the next line(s) — never glue title and body on one line.
|
||||
• FORBIDDEN: burying a section title after a colon in the same paragraph (e.g. «بریم سراغ بخش آخر: راههای افزایش شادی»). Put the numbered title on the next line instead.
|
||||
- For several related points: use a bullet list (- or •) or a numbered list.
|
||||
- For a process or steps: use ۱. ۲. ۳. (or 1. 2. 3.).
|
||||
- TABLES (occasional variety):
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { ContentBlock } from '@/types/contentBlocks';
|
||||
import type { AiCoachSessionContext } from '@/lib/aiCoach/sessionContext';
|
||||
import { isSessionCompleted } from '@/services/programService';
|
||||
import { getHomeworkForSession } from '@/data/program/sessionHomework';
|
||||
|
||||
/** Marker format — provider-agnostic plain-text envelope */
|
||||
export const COACH_SESSION_COMPLETE_OPEN = '[COACH_SESSION_COMPLETE]';
|
||||
@@ -70,6 +71,68 @@ export const extractSessionReflectionLabels = (blocks: ContentBlock[]): string[]
|
||||
const isInAppAssessmentLabel = (label: string): boolean =>
|
||||
IN_APP_ASSESSMENT_KEYWORDS.some((kw) => fuzzyIncludes(label, kw));
|
||||
|
||||
/** End-of-session homework is assigned after completion — must not block finishing the session. */
|
||||
const isEndOfSessionHomeworkLabel = (label: string, sessionOrder: number): boolean => {
|
||||
if (normalize(label) === normalize('تکلیف خانگی')) return true;
|
||||
const homework = getHomeworkForSession(sessionOrder);
|
||||
if (!homework) return false;
|
||||
return fuzzyIncludes(label, homework.title) || fuzzyIncludes(homework.title, label);
|
||||
};
|
||||
|
||||
const requiredExerciseLabels = (context: AiCoachSessionContext): string[] =>
|
||||
extractSessionExerciseLabels(context.blocks).filter(
|
||||
(label) =>
|
||||
Boolean(label) &&
|
||||
!isInAppAssessmentLabel(label) &&
|
||||
!isEndOfSessionHomeworkLabel(label, context.order),
|
||||
);
|
||||
|
||||
const requiredHeadingLabels = (context: AiCoachSessionContext): string[] =>
|
||||
extractSessionHeadings(context.blocks).filter(
|
||||
(label) => Boolean(label) && !isEndOfSessionHomeworkLabel(label, context.order),
|
||||
);
|
||||
|
||||
export function fillMissingCoverageLabels(
|
||||
signal: CoachSessionCompletionSignal,
|
||||
context: AiCoachSessionContext,
|
||||
): CoachSessionCompletionSignal {
|
||||
const completedExercises = [
|
||||
...(Array.isArray(signal.completedExercises) ? signal.completedExercises : []),
|
||||
];
|
||||
for (const label of requiredExerciseLabels(context)) {
|
||||
if (!listMatchesAny(completedExercises, label)) {
|
||||
completedExercises.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const reflectionLabels = extractSessionReflectionLabels(context.blocks).filter(Boolean);
|
||||
const completedReflections = [
|
||||
...(Array.isArray(signal.completedReflections) ? signal.completedReflections : []),
|
||||
];
|
||||
for (const label of reflectionLabels) {
|
||||
if (!listMatchesAny(completedReflections, label)) {
|
||||
completedReflections.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...signal,
|
||||
completedExercises,
|
||||
completedReflections,
|
||||
};
|
||||
}
|
||||
|
||||
function isOnlyMissingCoverageErrors(errors: string[]): boolean {
|
||||
return (
|
||||
errors.length > 0 &&
|
||||
errors.every(
|
||||
(error) =>
|
||||
error.startsWith('Missing completed exercise:') ||
|
||||
error.startsWith('Missing completed reflection:'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse raw assistant text → user-visible content + optional signal */
|
||||
export function parseCoachCompletionFromAssistantText(raw: string): CoachCompletionProcessResult {
|
||||
const openIdx = raw.indexOf(COACH_SESSION_COMPLETE_OPEN);
|
||||
@@ -156,8 +219,8 @@ export function validateCoachSessionCompletionSignal(
|
||||
errors.push('Session is already marked complete in program progress');
|
||||
}
|
||||
|
||||
const headings = extractSessionHeadings(context.blocks);
|
||||
const exerciseLabels = extractSessionExerciseLabels(context.blocks);
|
||||
const headings = requiredHeadingLabels(context);
|
||||
const exerciseLabels = requiredExerciseLabels(context);
|
||||
const reflectionLabels = extractSessionReflectionLabels(context.blocks);
|
||||
|
||||
const minTopicsRequired = Math.max(1, Math.ceil(headings.length * 0.6));
|
||||
@@ -172,10 +235,6 @@ export function validateCoachSessionCompletionSignal(
|
||||
|
||||
if (isStringArray(s.completedExercises)) {
|
||||
for (const label of exerciseLabels) {
|
||||
if (!label) continue;
|
||||
if (isInAppAssessmentLabel(label)) {
|
||||
continue;
|
||||
}
|
||||
if (!listMatchesAny(s.completedExercises, label)) {
|
||||
errors.push(`Missing completed exercise: ${label}`);
|
||||
}
|
||||
@@ -317,6 +376,17 @@ export function processCoachAssistantResponse(
|
||||
|
||||
let validation = validateCoachSessionCompletionSignal(signal, context, options);
|
||||
|
||||
if (
|
||||
!validation.valid &&
|
||||
!alreadyCompleted &&
|
||||
options.userTurnCount >= 2 &&
|
||||
signal.userConfirmed === true &&
|
||||
isOnlyMissingCoverageErrors(validation.errors)
|
||||
) {
|
||||
signal = fillMissingCoverageLabels(signal, context);
|
||||
validation = validateCoachSessionCompletionSignal(signal, context, options);
|
||||
}
|
||||
|
||||
// Full catalog recovery only after a clear post-test handoff in the visible message.
|
||||
if (
|
||||
!validation.valid &&
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 to disable cooldown (dev/testing). */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 0;
|
||||
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
||||
|
||||
/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type SessionCompletionTimes = Record<number, string>;
|
||||
|
||||
/**
|
||||
* Unlock timing:
|
||||
* - Session 1: always available (no previous)
|
||||
* - Session 2: immediately after session 1 (held together)
|
||||
* - Sessions 3–10: 24h after previous session completion
|
||||
* - Session 11 (last): immediately after session 10
|
||||
*/
|
||||
export function sessionUsesUnlockInterval(sessionOrder: number): boolean {
|
||||
if (sessionOrder <= 2) {
|
||||
return false;
|
||||
}
|
||||
if (sessionOrder >= PROGRAM_SESSION_COUNT) {
|
||||
return false;
|
||||
}
|
||||
return SESSION_UNLOCK_INTERVAL_MS > 0;
|
||||
}
|
||||
|
||||
export function getPreviousSessionUnlockTime(
|
||||
sessionOrder: number,
|
||||
completedAtByOrder: SessionCompletionTimes,
|
||||
@@ -11,6 +30,10 @@ export function getPreviousSessionUnlockTime(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionUsesUnlockInterval(sessionOrder)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousCompletedAt = completedAtByOrder[sessionOrder - 1];
|
||||
if (!previousCompletedAt) {
|
||||
return null;
|
||||
@@ -49,7 +72,7 @@ export function isSessionUnlockedForProgress(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SESSION_UNLOCK_INTERVAL_MS <= 0) {
|
||||
if (!sessionUsesUnlockInterval(sessionOrder)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ type StepState = 'open' | 'locked' | 'done';
|
||||
export default function AccountPage() {
|
||||
const { effectiveUserId, isGuest, guestSessionHydrated, user } = useAuth();
|
||||
const { progress, loading, permissions } = useLearningJourney(effectiveUserId ?? undefined);
|
||||
const { currentSession, isProgramComplete, programProgress } = useCurrentSession(effectiveUserId);
|
||||
const { currentSession, isProgramComplete, programProgress, progressLoading } =
|
||||
useCurrentSession(effectiveUserId);
|
||||
const navigate = useNavigate();
|
||||
const usernameLabel = isGuest ? 'مهمان' : user?.email?.trim() || null;
|
||||
|
||||
if (!guestSessionHydrated || loading) {
|
||||
if (!guestSessionHydrated || loading || progressLoading) {
|
||||
return <CourseStatusSkeleton />;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,9 @@ export default function AccountPage() {
|
||||
|
||||
const displaySessionOrder =
|
||||
currentSession?.order ??
|
||||
(programDone ? PROGRAM_SESSION_COUNT : Math.max(effectiveProgramProgress.currentSessionOrder, 1));
|
||||
(programDone
|
||||
? PROGRAM_SESSION_COUNT
|
||||
: Math.max(effectiveProgramProgress.currentSessionOrder || 1, 1));
|
||||
|
||||
const preState: StepState = preDone ? 'done' : 'open';
|
||||
const trainingState: StepState = !permissions.canOpenTraining
|
||||
@@ -70,7 +73,7 @@ export default function AccountPage() {
|
||||
: 'locked';
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-y-auto bg-slate-50 px-4 py-6 transition-colors duration-300 dark:bg-slate-900">
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 transition-colors duration-300 dark:bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-md space-y-4">
|
||||
<AnimationWrapper immediate className="space-y-1 pb-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">وضعیت دوره</h1>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useState, startTransition } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AdminEmptyState,
|
||||
AdminErrorState,
|
||||
AdminLoadingBlock,
|
||||
AdminPageHeader,
|
||||
AdminPageShell,
|
||||
AdminPrimaryButton,
|
||||
AdminSecondaryButton,
|
||||
AdminSection,
|
||||
AdminStatsSkeleton,
|
||||
AdminStatusPill,
|
||||
formatAdminDate,
|
||||
getAdminErrorMessage,
|
||||
isAdminForbiddenError,
|
||||
} from '@/components/admin';
|
||||
import {
|
||||
fetchAdminStats,
|
||||
fetchAdminUsers,
|
||||
type AdminStats,
|
||||
type AdminUserListItem,
|
||||
type AdminUsersQuery,
|
||||
} from '@/services/adminService';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
registered: 'ثبتنام',
|
||||
pre_test_done: 'پیشآزمون',
|
||||
training_in_progress: 'در حال آموزش',
|
||||
training_completed: 'پایان آموزش',
|
||||
post_test_done: 'پسآزمون',
|
||||
completed: 'تکمیل دوره',
|
||||
};
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-800">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-slate-900 dark:text-slate-50">
|
||||
{value.toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUserListItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<AdminUsersQuery['sortBy']>('createdAt');
|
||||
const [sortDir, setSortDir] = useState<AdminUsersQuery['sortDir']>('desc');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setForbidden(false);
|
||||
try {
|
||||
const [nextStats, list] = await Promise.all([
|
||||
fetchAdminStats(),
|
||||
fetchAdminUsers({
|
||||
q: query || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortDir,
|
||||
}),
|
||||
]);
|
||||
startTransition(() => {
|
||||
setStats(nextStats);
|
||||
setUsers(list.items);
|
||||
setTotal(list.pagination.total);
|
||||
setTotalPages(list.pagination.totalPages);
|
||||
setHasLoadedOnce(true);
|
||||
});
|
||||
} catch (err) {
|
||||
setForbidden(isAdminForbiddenError(err));
|
||||
setError(getAdminErrorMessage(err, 'دریافت دادههای ادمین ناموفق بود.'));
|
||||
setStats(null);
|
||||
setUsers([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, query, sortBy, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const submitSearch = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setQuery(searchInput.trim());
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchInput('');
|
||||
setQuery('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasActiveSearch = Boolean(query);
|
||||
|
||||
return (
|
||||
<AdminPageShell>
|
||||
<AdminPageHeader
|
||||
title="پنل ادمین"
|
||||
subtitle="آمار کلی و فهرست کاربران آزمایشی"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AdminErrorState
|
||||
message={error}
|
||||
tone={forbidden ? 'warning' : 'error'}
|
||||
onRetry={() => void load()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loading && !hasLoadedOnce ? (
|
||||
<AdminStatsSkeleton />
|
||||
) : stats ? (
|
||||
<section className="grid grid-cols-2 gap-3 md:grid-cols-4" aria-label="آمار کلی">
|
||||
<StatCard label="کل کاربران" value={stats.totalUsers} />
|
||||
<StatCard label="پیشآزمون" value={stats.pretestCompletedUsers} />
|
||||
<StatCard label="پسآزمون" value={stats.posttestCompletedUsers} />
|
||||
<StatCard label="در حال آموزش" value={stats.trainingInProgressUsers} />
|
||||
<StatCard label="پایان آموزش" value={stats.trainingCompletedUsers} />
|
||||
<StatCard label="تکمیل دوره" value={stats.courseCompletedUsers} />
|
||||
<StatCard label="پاسخ آزمونها" value={stats.totalAssessmentAttempts} />
|
||||
<StatCard label="گفتگوها" value={stats.totalChatSessions} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<AdminSection title="فهرست کاربران">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<form onSubmit={submitSearch} className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="جستوجو با ایمیل، کد پژوهشی یا نام"
|
||||
className="w-full min-w-0 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
aria-label="جستوجوی کاربران"
|
||||
/>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<AdminPrimaryButton type="submit" disabled={loading} aria-busy={loading}>
|
||||
جستوجو
|
||||
</AdminPrimaryButton>
|
||||
{hasActiveSearch ? (
|
||||
<AdminSecondaryButton type="button" onClick={clearSearch} disabled={loading}>
|
||||
پاککردن جستوجو
|
||||
</AdminSecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setSortBy(e.target.value as AdminUsersQuery['sortBy']);
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
aria-label="مرتبسازی بر اساس"
|
||||
>
|
||||
<option value="createdAt">تاریخ ثبتنام</option>
|
||||
<option value="lastActivityAt">آخرین فعالیت</option>
|
||||
<option value="role">نقش</option>
|
||||
<option value="externalId">کد پژوهشی</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortDir}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setSortDir(e.target.value as AdminUsersQuery['sortDir']);
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
aria-label="جهت مرتبسازی"
|
||||
>
|
||||
<option value="desc">نزولی</option>
|
||||
<option value="asc">صعودی</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500" aria-live="polite">
|
||||
{loading
|
||||
? 'در حال بهروزرسانی فهرست…'
|
||||
: `${total.toLocaleString('fa-IR')} کاربر · صفحه ${page.toLocaleString('fa-IR')}${
|
||||
totalPages > 0 ? ` از ${totalPages.toLocaleString('fa-IR')}` : ''
|
||||
}`}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingBlock label="در حال بارگذاری کاربران…" rows={4} />
|
||||
) : users.length === 0 ? (
|
||||
<AdminEmptyState
|
||||
title={hasActiveSearch ? 'نتیجهای برای این جستوجو نیست' : 'کاربری ثبت نشده است'}
|
||||
description={
|
||||
hasActiveSearch
|
||||
? 'عبارت جستوجو را تغییر دهید یا فیلتر را پاک کنید.'
|
||||
: 'هنوز کاربری در سامانه ثبت نشده است.'
|
||||
}
|
||||
action={
|
||||
hasActiveSearch ? (
|
||||
<AdminSecondaryButton type="button" onClick={clearSearch}>
|
||||
پاککردن جستوجو
|
||||
</AdminSecondaryButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="space-y-2 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/admin/users/${user.id}`}
|
||||
className="font-medium text-slate-900 underline-offset-2 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:text-slate-50"
|
||||
>
|
||||
{user.name || user.email || 'بدون نام'}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-slate-500" dir="ltr" title={user.id}>
|
||||
{user.email ?? '—'} · {user.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<AdminStatusPill ok={user.role === 'admin'} label={user.role} />
|
||||
<AdminStatusPill
|
||||
ok={!user.isGuest}
|
||||
label={user.isGuest ? 'مهمان' : 'ثبتنامی'}
|
||||
/>
|
||||
<AdminStatusPill
|
||||
ok={user.profileComplete}
|
||||
label={user.profileComplete ? 'پروفایل کامل' : 'پروفایل ناقص'}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
to={`/admin/users/${user.id}`}
|
||||
className={cn(
|
||||
'rounded-lg border border-slate-200 px-2.5 py-1 text-xs text-slate-700 dark:border-slate-600 dark:text-slate-200',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
)}
|
||||
>
|
||||
جزئیات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<AdminStatusPill ok={user.pretestCompleted} label="پیشآزمون" />
|
||||
<AdminStatusPill ok={user.trainingCompleted} label="آموزش" />
|
||||
<AdminStatusPill ok={user.posttestCompleted} label="پسآزمون" />
|
||||
<AdminStatusPill ok={user.courseCompleted} label="دوره" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
وضعیت:{' '}
|
||||
{user.currentStep ? STEP_LABELS[user.currentStep] ?? user.currentStep : '—'}
|
||||
{' · '}
|
||||
جلسات: {user.completedSessionCount}/{user.programSessionCount}
|
||||
{user.age != null ? ` · سن: ${user.age}` : ''}
|
||||
{' · '}
|
||||
ثبتنام: {formatAdminDate(user.createdAt)}
|
||||
{' · '}
|
||||
فعالیت: {formatAdminDate(user.lastActivityAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<AdminSecondaryButton
|
||||
type="button"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="صفحه قبلی کاربران"
|
||||
>
|
||||
قبلی
|
||||
</AdminSecondaryButton>
|
||||
<AdminSecondaryButton
|
||||
type="button"
|
||||
disabled={totalPages === 0 || page >= totalPages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label="صفحه بعدی کاربران"
|
||||
>
|
||||
بعدی
|
||||
</AdminSecondaryButton>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -15,9 +16,13 @@ import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AssessmentSkeleton } from '@/components/skeletons';
|
||||
import { LoadingOverlay } from '@/components/ui/loading-overlay';
|
||||
import { BrandLogo } from '@/components/BrandLogo';
|
||||
import { APP_NAME } from '@/utils/constants';
|
||||
|
||||
const FADE_MS = 220;
|
||||
|
||||
const PRE_INTRO_STEPS = 3;
|
||||
|
||||
export default function AssessmentPage() {
|
||||
const { phase } = useParams<{ phase: AttemptPhase }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -32,6 +37,7 @@ export default function AssessmentPage() {
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const [showIntro, setShowIntro] = useState(true);
|
||||
const [introStep, setIntroStep] = useState(0);
|
||||
const [contentVisible, setContentVisible] = useState(true);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
@@ -215,62 +221,216 @@ export default function AssessmentPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const introTitle = isPreTest ? 'پیشآزمون' : 'پسآزمون';
|
||||
const introBody = isPreTest ? (
|
||||
<>
|
||||
<p>
|
||||
قبل از شروع مسیر یادگیری، لازم است وضعیت سرمایه روانشناختی شما را بسنجیم. این پرسشنامه کوتاه
|
||||
(PCQ-24) به ما کمک میکند آموزش را متناسب با شرایط شما پیش ببریم.
|
||||
</p>
|
||||
<p className="text-sm text-slate-300/90">
|
||||
پاسخ درست یا غلط وجود ندارد؛ گزینهای را انتخاب کنید که بیشتر با تجربه واقعی شما همخوانی دارد.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
پس از پایان آموزش، پرسشنامه سرمایه روانشناختی (PCQ-24) را دوباره تکمیل کنید تا پیشرفت شما مشخص
|
||||
شود.
|
||||
</p>
|
||||
);
|
||||
const phaseLabel = isPreTest ? 'پیشآزمون' : 'پسآزمون';
|
||||
const isLastIntroStep = !isPreTest || introStep >= PRE_INTRO_STEPS - 1;
|
||||
|
||||
const goIntroNext = () => {
|
||||
if (isPreTest && introStep < PRE_INTRO_STEPS - 1) {
|
||||
setIntroStep((s) => s + 1);
|
||||
return;
|
||||
}
|
||||
setShowIntro(false);
|
||||
};
|
||||
|
||||
const goIntroBack = () => {
|
||||
if (introStep > 0) setIntroStep((s) => s - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 flex-1 overflow-hidden p-4">
|
||||
<div className="ambient-glow relative h-full min-h-0 flex-1 overflow-hidden p-4">
|
||||
<LoadingOverlay
|
||||
open={isRedirecting}
|
||||
message={successMessage ?? 'در حال انتقال…'}
|
||||
/>
|
||||
{/* Intro overlay — dark veil on text area; bottom almost visible */}
|
||||
|
||||
{showIntro && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex flex-col p-5 pt-6"
|
||||
className="absolute inset-0 z-40 flex items-stretch justify-center p-3 sm:p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="assessment-intro-title"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.97) 0%, rgba(0,0,0,0.95) 70%, rgba(0,0,0,0.88) 85%, rgba(0,0,0,0.35) 92%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 bg-background/55 backdrop-blur-md dark:bg-background/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="animate-in fade-in relative z-10 flex w-full flex-col items-stretch gap-5 text-right duration-500">
|
||||
<h2
|
||||
id="assessment-intro-title"
|
||||
className="text-xl font-semibold text-white drop-shadow-lg"
|
||||
>
|
||||
{isPreTest ? 'راهنمای پیشآزمون' : 'راهنمای پسآزمون'}
|
||||
</h2>
|
||||
<div className="space-y-4 text-sm leading-7 text-slate-100">{introBody}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
className="mt-2 w-full"
|
||||
onClick={() => setShowIntro(false)}
|
||||
>
|
||||
متوجه شدم
|
||||
</Button>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 ambient-glow opacity-70"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="animate-in fade-in relative z-10 flex h-full w-full max-w-lg flex-col duration-500">
|
||||
<div className="glass-panel relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="relative z-10 flex shrink-0 flex-col items-center gap-2 px-5 pb-2 pt-5 text-center">
|
||||
<BrandLogo imgClassName="h-10" />
|
||||
<p className="text-xs font-medium text-cyan-800 dark:text-neon-cyan">
|
||||
{isPreTest ? `خوش آمدی به ${APP_NAME}` : 'پسآزمون'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-5 pb-4 pt-1 text-right">
|
||||
{isPreTest ? (
|
||||
<>
|
||||
{introStep === 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
id="assessment-intro-title"
|
||||
className="text-xl font-bold tracking-tight text-foreground"
|
||||
>
|
||||
سلام! 👋
|
||||
</h2>
|
||||
<div className="space-y-3 text-sm leading-7 text-body">
|
||||
<p>
|
||||
خوش اومدی به <span className="font-semibold text-foreground">{APP_NAME}</span>.
|
||||
</p>
|
||||
<p>
|
||||
قراره با هم یک مسیر جذاب و متفاوت رو شروع کنیم؛ مسیری که هم قراره چیزهای
|
||||
جدیدی یاد بگیری و هم فرصت داشته باشی خودت رو بهتر بشناسی.
|
||||
</p>
|
||||
<p>
|
||||
این مسیر خیلی طولانی نیست و فعالیتها و تمرینها کوتاه و متنوع طراحی شدن تا
|
||||
در کنارشون هم یاد بگیری و هم بهت خوش بگذره.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{introStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
id="assessment-intro-title"
|
||||
className="text-lg font-bold tracking-tight text-foreground"
|
||||
>
|
||||
توی این مسیر چه اتفاقی میافته؟
|
||||
</h2>
|
||||
<ol className="space-y-3.5 text-sm leading-7 text-body">
|
||||
<li className="rounded-xl border border-border/60 bg-background/40 px-3.5 py-3 dark:bg-slate-900/30">
|
||||
<p className="mb-1 font-semibold text-foreground">۱. پیشآزمون</p>
|
||||
<p>
|
||||
اول چند سؤال کوتاه ازت میپرسیم تا با تجربهها و شرایط فعلیت بیشتر آشنا
|
||||
بشیم. جواب درست یا غلطی وجود نداره؛ مهم اینه که پاسخهات تا جای ممکن به
|
||||
تجربه واقعی خودت نزدیک باشن.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-xl border border-border/60 bg-background/40 px-3.5 py-3 dark:bg-slate-900/30">
|
||||
<p className="mb-1 font-semibold text-foreground">۲. یادگیری و تمرین</p>
|
||||
<p>
|
||||
بعد وارد بخشهای مختلف مسیر میشی. هر بخش روی یک موضوع یا مهارت مشخص
|
||||
تمرکز داره و با محتوای آموزشی، فعالیتها و تمرینهای کوتاه پیش میره.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-xl border border-border/60 bg-background/40 px-3.5 py-3 dark:bg-slate-900/30">
|
||||
<p className="mb-1 font-semibold text-foreground">۳. تجربه و تمرین بیشتر</p>
|
||||
<p>
|
||||
در طول مسیر، با موقعیتها و فعالیتهای مختلف روبهرو میشی و میتونی
|
||||
چیزهایی که یاد گرفتی رو در عمل امتحان کنی.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-xl border border-border/60 bg-background/40 px-3.5 py-3 dark:bg-slate-900/30">
|
||||
<p className="mb-1 font-semibold text-foreground">۴. پسآزمون</p>
|
||||
<p>
|
||||
در پایان مسیر، دوباره چند سؤال رو پاسخ میدی تا ببینیم بعد از این تجربه،
|
||||
چه تغییری در پاسخها و نتایجت ایجاد شده.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{introStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
id="assessment-intro-title"
|
||||
className="text-lg font-bold tracking-tight text-foreground"
|
||||
>
|
||||
یک چیز دیگه...
|
||||
</h2>
|
||||
<div className="space-y-3 text-sm leading-7 text-body">
|
||||
<p>
|
||||
کار کردن تو با {APP_NAME} فقط برای خودت نیست. تجربه و بازخوردت به ما کمک
|
||||
میکنه بفهمیم کدوم بخشهای مسیر مفیدتر و جذابتر بودن و چطور میتونیم{' '}
|
||||
{APP_NAME} رو بهتر کنیم.
|
||||
</p>
|
||||
<p>
|
||||
پس با خیال راحت پیش برو، تجربه واقعی خودت رو با ما در میون بذار و از مسیر
|
||||
لذت ببر.
|
||||
</p>
|
||||
<p className="pt-1 text-base font-semibold text-foreground">
|
||||
آمادهای شروع کنیم؟ 🚀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
id="assessment-intro-title"
|
||||
className="text-lg font-bold tracking-tight text-foreground"
|
||||
>
|
||||
راهنمای پسآزمون
|
||||
</h2>
|
||||
<div className="space-y-3 text-sm leading-7 text-body">
|
||||
<p>
|
||||
مسیر رو تموم کردی. حالا چند سؤال کوتاه دوباره ازت میپرسیم تا ببینیم بعد از
|
||||
این تجربه، چه تغییری در پاسخهات ایجاد شده.
|
||||
</p>
|
||||
<p>
|
||||
مثل قبل، جواب درست یا غلطی وجود نداره؛ مهم تجربه واقعی خودته.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 shrink-0 space-y-3 border-t border-white/25 px-5 py-4 dark:border-white/10">
|
||||
{isPreTest ? (
|
||||
<div className="flex items-center justify-center gap-1.5" aria-hidden>
|
||||
{Array.from({ length: PRE_INTRO_STEPS }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i === introStep
|
||||
? 'w-5 bg-neon-cyan'
|
||||
: 'w-1.5 bg-muted-foreground/35'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
isPreTest && introStep > 0 ? 'grid-cols-2' : 'grid-cols-1'
|
||||
)}
|
||||
>
|
||||
{isPreTest && introStep > 0 ? (
|
||||
<Button type="button" variant="glass" onClick={goIntroBack}>
|
||||
قبلی
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!isLastIntroStep ? (
|
||||
<Button type="button" variant="neon" className="w-full" onClick={goIntroNext}>
|
||||
بعدی
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
size="lg"
|
||||
className="w-full gap-2"
|
||||
onClick={goIntroNext}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{`شروع ${phaseLabel}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -278,21 +438,21 @@ export default function AssessmentPage() {
|
||||
{/* Questions surface */}
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex h-full w-full max-w-lg flex-col gap-3',
|
||||
'relative z-10 mx-auto flex h-full w-full max-w-lg flex-col gap-3',
|
||||
showIntro && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-body">
|
||||
<span className="font-medium text-cyan-800 dark:text-neon-cyan">{introTitle}</span>
|
||||
<span className="font-medium text-cyan-800 dark:text-neon-cyan">{phaseLabel}</span>
|
||||
<span className="font-medium tabular-nums text-foreground">{`سوال ${currentIndex + 1} از ${totalQuestions}`}</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="glass-panel flex min-h-0 flex-1 flex-col overflow-hidden p-4">
|
||||
<div className="glass-panel relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden p-4">
|
||||
{/* Fixed question zone — number + text stay pinned (no vertical reflow) */}
|
||||
<div className="relative h-[9.5rem] shrink-0 overflow-hidden pt-1">
|
||||
<div className="relative z-10 h-[9.5rem] shrink-0 overflow-hidden pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-0 transition-opacity duration-[220ms] ease-out',
|
||||
@@ -311,7 +471,7 @@ export default function AssessmentPage() {
|
||||
{/* Fixed answers grid — same slot for every question */}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 grid shrink-0 grid-cols-1 gap-2 transition-opacity duration-[220ms] ease-out',
|
||||
'relative z-10 mt-3 grid shrink-0 grid-cols-1 gap-2 transition-opacity duration-[220ms] ease-out',
|
||||
contentVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
@@ -325,11 +485,11 @@ export default function AssessmentPage() {
|
||||
onClick={() => onSelect(currentQuestion.id, option.value)}
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center justify-start rounded-xl border px-4 text-right text-sm transition-all duration-200',
|
||||
'shadow-[0_2px_4px_rgba(15,23,42,0.05)] focus-visible:outline-none',
|
||||
'shadow-[0_2px_4px_rgba(15,23,42,0.05)] focus-visible:outline-none backdrop-blur-md',
|
||||
'focus-visible:border-[#26d4ca] focus-visible:shadow-[0_0_0_1px_rgba(38,212,202,0.35),0_3px_8px_rgba(15,23,42,0.08)]',
|
||||
selected
|
||||
? 'border-[#26d4ca] bg-[rgb(38_212_202/0.12)] text-foreground shadow-[0_0_0_1px_rgba(38,212,202,0.25),0_3px_8px_rgba(15,23,42,0.08)] dark:border-neon-cyan dark:bg-neon-cyan/15 dark:text-white dark:shadow-glow-sm'
|
||||
: 'border-slate-200 bg-white/80 text-body hover:border-[#26d4ca]/70 hover:bg-white hover:text-foreground dark:border-neon-cyan/20 dark:bg-slate-900/35 dark:hover:border-neon-cyan/55 dark:hover:bg-slate-800/45 dark:hover:text-white'
|
||||
? 'border-[#26d4ca] bg-[rgb(38_212_202/0.18)] text-foreground shadow-[0_0_0_1px_rgba(38,212,202,0.25),0_3px_8px_rgba(15,23,42,0.08)] dark:border-neon-cyan dark:bg-neon-cyan/15 dark:text-white dark:shadow-glow-sm'
|
||||
: 'border-white/45 bg-white/35 text-body hover:border-[#26d4ca]/70 hover:bg-white/55 hover:text-foreground dark:border-white/12 dark:bg-slate-900/35 dark:hover:border-neon-cyan/55 dark:hover:bg-slate-800/45 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -339,7 +499,12 @@ export default function AssessmentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-2 gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'grid shrink-0 gap-2',
|
||||
isLastQuestion ? 'grid-cols-2' : 'grid-cols-1'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
@@ -357,17 +522,7 @@ export default function AssessmentPage() {
|
||||
>
|
||||
{submitting ? 'در حال ثبت...' : 'ثبت آزمون'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
className="w-full"
|
||||
disabled={!selectedValue || isAnimating || submitting || isRedirecting || showIntro}
|
||||
onClick={() => advanceWithFade(1)}
|
||||
>
|
||||
سوال بعدی
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { AssignmentFormFields } from '@/components/assignments/AssignmentFormFields';
|
||||
import {
|
||||
assignmentDayStatusLabel,
|
||||
assignmentStatusLabel,
|
||||
assignmentTypeLabel,
|
||||
createAssignmentEntry,
|
||||
fetchAssignment,
|
||||
isMultiDayAssignment,
|
||||
saveAssignmentDraft,
|
||||
startAssignment,
|
||||
submitAssignmentReport,
|
||||
updateAssignmentEntry,
|
||||
type AssignmentDayStatus,
|
||||
type AssignmentFormField,
|
||||
type AssignmentReportStatus,
|
||||
type UserAssignment,
|
||||
} from '@/services/assignmentService';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getFetchErrorMessage } from '@/lib/fetchError';
|
||||
import { PageSkeleton } from '@/components/skeletons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowRight, Check, Loader2 } from 'lucide-react';
|
||||
|
||||
function emptyDraftFromSchema(schema: AssignmentFormField[] | null): Record<string, unknown> {
|
||||
if (!schema) return { text: '' };
|
||||
const draft: Record<string, unknown> = {};
|
||||
for (const field of schema) {
|
||||
if (field.type === 'list') {
|
||||
draft[field.key] = [{}];
|
||||
} else if (field.type === 'checkbox') {
|
||||
draft[field.key] = false;
|
||||
} else {
|
||||
draft[field.key] = '';
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function progressLabel(assignment: UserAssignment): string | null {
|
||||
const target = assignment.definition.expectedDurationDays;
|
||||
if (!target) return `${assignment.doneDayCount} روز انجامشده`;
|
||||
return `${assignment.doneDayCount} روز از ${target} روز هدف`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment detail — one-time vs multi-day flows diverge deliberately.
|
||||
* Status is system-driven; experience notes are optional and soft.
|
||||
*/
|
||||
export default function AssignmentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { effectiveUserId, guestSessionHydrated, isGuest } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [assignment, setAssignment] = useState<UserAssignment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||
const [entryData, setEntryData] = useState<Record<string, unknown>>({});
|
||||
const [entryNote, setEntryNote] = useState('');
|
||||
const [dayStatus, setDayStatus] = useState<AssignmentDayStatus>('done');
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [experienceNote, setExperienceNote] = useState('');
|
||||
const [experienceStatus, setExperienceStatus] =
|
||||
useState<AssignmentReportStatus>('difficult');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const row = await fetchAssignment(id);
|
||||
setAssignment(row);
|
||||
const schema = row.definition.formSchema;
|
||||
setDraft(
|
||||
(row.draftData as Record<string, unknown> | null) ?? emptyDraftFromSchema(schema),
|
||||
);
|
||||
const today = row.serverToday;
|
||||
const todayEntry = row.dailyEntries?.find((e) => e.entryDate === today);
|
||||
if (todayEntry) {
|
||||
setEditingEntryId(todayEntry.id);
|
||||
setEntryData(todayEntry.responseData);
|
||||
setEntryNote(todayEntry.note ?? '');
|
||||
setDayStatus(todayEntry.dayStatus);
|
||||
} else {
|
||||
setEditingEntryId(null);
|
||||
setEntryData(emptyDraftFromSchema(schema));
|
||||
setEntryNote('');
|
||||
setDayStatus('done');
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: getFetchErrorMessage(err, 'بارگذاری تکلیف ناموفق بود'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/assignments', { replace: true });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, navigate, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveUserId || isGuest) return;
|
||||
void load();
|
||||
}, [effectiveUserId, isGuest, load]);
|
||||
|
||||
if (!guestSessionHydrated || loading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!effectiveUserId) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (isGuest || !assignment) {
|
||||
return <Navigate to="/assignments" replace />;
|
||||
}
|
||||
|
||||
const isMultiDay = isMultiDayAssignment(assignment.definition.assignmentType);
|
||||
const isOneTime = assignment.definition.assignmentType === 'ONE_TIME';
|
||||
const schema = assignment.definition.formSchema;
|
||||
const completed = assignment.status === 'COMPLETED';
|
||||
const serverToday = assignment.serverToday;
|
||||
|
||||
const updateDraftField = (key: string, value: unknown) => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateEntryField = (key: string, value: unknown) => {
|
||||
setEntryData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSaveDraft = async (completeIfValid: boolean) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await startAssignment(assignment.id);
|
||||
const updated = await saveAssignmentDraft(assignment.id, draft, { completeIfValid });
|
||||
setAssignment(updated);
|
||||
toast({
|
||||
title: updated.status === 'COMPLETED' ? 'پاسخ ثبت و تکلیف تکمیل شد' : 'پیشنویس ذخیره شد',
|
||||
description:
|
||||
updated.status === 'COMPLETED'
|
||||
? undefined
|
||||
: completeIfValid
|
||||
? undefined
|
||||
: 'برای تکمیل، فیلدهای الزامی را پر کن و «ثبت پاسخ» را بزن.',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: getFetchErrorMessage(err, 'ذخیره پاسخ ناموفق بود'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEntry = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await startAssignment(assignment.id);
|
||||
const payload = {
|
||||
dayStatus,
|
||||
responseData: dayStatus === 'done' ? entryData : {},
|
||||
note: entryNote || null,
|
||||
};
|
||||
if (editingEntryId) {
|
||||
await updateAssignmentEntry(assignment.id, editingEntryId, payload);
|
||||
} else {
|
||||
await createAssignmentEntry(assignment.id, {
|
||||
...payload,
|
||||
entryDate: serverToday,
|
||||
});
|
||||
}
|
||||
await load();
|
||||
toast({ title: 'ثبت امروز ذخیره شد' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: getFetchErrorMessage(err, 'ثبت روزانه ناموفق بود'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExperienceNote = async () => {
|
||||
const text = experienceNote.trim();
|
||||
if (text.length < 3) {
|
||||
toast({
|
||||
title: 'یادداشت کوتاه است',
|
||||
description: 'اگر میخواهی مانعی را ثبت کنی، چند کلمه بنویس.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await submitAssignmentReport(assignment.id, text, experienceStatus);
|
||||
setAssignment(result.assignment);
|
||||
setExperienceNote('');
|
||||
toast({ title: 'یادداشت تجربه ثبت شد' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: getFetchErrorMessage(err, 'ثبت یادداشت ناموفق بود'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dayOptions: Array<{ value: AssignmentDayStatus; label: string }> = [
|
||||
{ value: 'done', label: 'بله، انجام دادم' },
|
||||
{ value: 'skipped', label: 'امروز انجام ندادم' },
|
||||
{ value: 'difficult', label: 'امروز برایم دشوار بود' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 dark:bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-md space-y-5">
|
||||
<div>
|
||||
<Link
|
||||
to="/assignments"
|
||||
className="mb-3 inline-flex items-center gap-1 text-sm text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت به تکالیف
|
||||
</Link>
|
||||
<p className="text-[11px] font-medium text-sky-700 dark:text-sky-300">
|
||||
جلسه {assignment.definition.sessionOrder}
|
||||
</p>
|
||||
<h1 className="text-xl font-bold text-foreground">{assignment.definition.title}</h1>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
|
||||
{assignmentTypeLabel(assignment.definition.assignmentType)}
|
||||
</span>
|
||||
<span className="rounded-md bg-sky-50 px-2 py-0.5 text-sky-800 dark:bg-sky-950 dark:text-sky-200">
|
||||
{assignmentStatusLabel(assignment.status)}
|
||||
</span>
|
||||
{isMultiDay && <span>{progressLabel(assignment)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-sky-200/80 bg-sky-50/70 p-4 dark:border-sky-900 dark:bg-sky-950/40">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">راهنمای تمرین</h2>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-body">
|
||||
{assignment.definition.instructions}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{completed && isOneTime && (
|
||||
<section className="space-y-3 rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 dark:border-emerald-900 dark:bg-emerald-950/30">
|
||||
<h2 className="text-sm font-semibold text-foreground">پاسخ ثبتشده</h2>
|
||||
<AssignmentFormFields fields={schema} values={draft} onChange={() => {}} disabled />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isOneTime && !completed && (
|
||||
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 className="text-sm font-semibold text-foreground">پاسخ تمرین</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
فیلدهای ستارهدار لازماند. میتوانی پیشنویس ذخیره کنی و بعداً ادامه بدهی.
|
||||
</p>
|
||||
<AssignmentFormFields fields={schema} values={draft} onChange={updateDraftField} />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveDraft(false)}
|
||||
disabled={saving}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ذخیره پیشنویس'}
|
||||
</Button>
|
||||
<Button onClick={() => void handleSaveDraft(true)} disabled={saving} className="w-full">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ثبت پاسخ و تکمیل'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isMultiDay && (
|
||||
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">پیشرفت کلی</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{progressLabel(assignment)}</p>
|
||||
</div>
|
||||
{assignment.definition.minValidDays != null ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
تکمیل پس از {assignment.definition.minValidDays} روز انجامشده
|
||||
</p>
|
||||
) : (
|
||||
<p className="max-w-[12rem] text-left text-[11px] leading-4 text-muted-foreground">
|
||||
تکمیل خودکار فعلاً فعال نیست
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{assignment.completionBlockReason && !completed ? (
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
{assignment.completionBlockReason}
|
||||
</p>
|
||||
) : null}
|
||||
{completed ? (
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-300">این تمرین تکمیل شده است.</p>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isMultiDay && !completed && (
|
||||
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
ثبت امروز ({serverToday})
|
||||
{editingEntryId ? ' — ویرایش' : ''}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">آیا امروز این تمرین را انجام دادی؟</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{dayOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDayStatus(opt.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-right text-sm transition-colors',
|
||||
dayStatus === opt.value
|
||||
? 'border-sky-500 bg-sky-50 dark:border-sky-400 dark:bg-sky-950'
|
||||
: 'border-slate-200 dark:border-slate-600',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 items-center justify-center rounded border',
|
||||
dayStatus === opt.value
|
||||
? 'border-sky-600 bg-sky-600 text-white'
|
||||
: 'border-slate-300 dark:border-slate-500',
|
||||
)}
|
||||
>
|
||||
{dayStatus === opt.value ? <Check className="h-2.5 w-2.5" /> : null}
|
||||
</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dayStatus === 'done' ? (
|
||||
<>
|
||||
<AssignmentFormFields
|
||||
fields={schema}
|
||||
values={entryData}
|
||||
onChange={updateEntryField}
|
||||
/>
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
placeholder="یادداشت روزانه (اختیاری)"
|
||||
value={entryNote}
|
||||
onChange={(e) => setEntryNote(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
placeholder="اگر مایلید، دلیل یا مانع را کوتاه بنویسید (اختیاری)"
|
||||
value={entryNote}
|
||||
onChange={(e) => setEntryNote(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={() => void handleSaveEntry()} disabled={saving} className="w-full">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ذخیره ثبت امروز'}
|
||||
</Button>
|
||||
|
||||
{(assignment.dailyEntries?.length ?? 0) > 0 && (
|
||||
<div className="space-y-2 border-t border-slate-100 pt-3 dark:border-slate-700">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">تاریخچه روزها</h3>
|
||||
{assignment.dailyEntries?.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="block w-full rounded-lg border border-slate-100 px-3 py-2 text-right text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-900"
|
||||
onClick={() => {
|
||||
setEditingEntryId(entry.id);
|
||||
setEntryData(entry.responseData);
|
||||
setEntryNote(entry.note ?? '');
|
||||
setDayStatus(entry.dayStatus);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-foreground">{entry.entryDate}</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
{assignmentDayStatusLabel(entry.dayStatus)}
|
||||
{entry.note ? ` · ${entry.note}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!completed && (
|
||||
<section className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<h2 className="text-sm font-semibold text-foreground">یادداشت تجربه (اختیاری)</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
اگر انجام تمرین برایت دشوار بود یا میخواهی مانعی را ثبت کنی — جدا از پاسخ اصلی تمرین.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: 'difficult' as const, label: 'انجام آن برایم دشوار بود' },
|
||||
{ value: 'in_progress' as const, label: 'هنوز درگیر تمرین هستم' },
|
||||
{ value: 'not_started' as const, label: 'هنوز شروع نکردهام' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setExperienceStatus(opt.value)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-right text-sm',
|
||||
experienceStatus === opt.value
|
||||
? 'border-slate-400 bg-slate-50 dark:border-slate-500 dark:bg-slate-900'
|
||||
: 'border-slate-200 dark:border-slate-600',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
value={experienceNote}
|
||||
onChange={(e) => setExperienceNote(e.target.value)}
|
||||
placeholder="توضیح کوتاه (اختیاری اما توصیهشده)"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExperienceNote()}
|
||||
disabled={saving}
|
||||
className="w-full"
|
||||
>
|
||||
ثبت یادداشت تجربه
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import {
|
||||
assignmentStatusLabel,
|
||||
assignmentTypeLabel,
|
||||
ASSIGNMENTS_REFRESH_EVENT,
|
||||
fetchAssignmentCatalog,
|
||||
type AssignmentCatalogItem,
|
||||
} from '@/services/assignmentService';
|
||||
import { AnimationWrapper, StaggerItem, StaggeredList } from '@/components/motion';
|
||||
import { PageSkeleton } from '@/components/skeletons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronLeft, Lock, NotebookPen } from 'lucide-react';
|
||||
|
||||
function formatActivity(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('fa-IR');
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* «تکالیف من» — ۹ تکلیف جلسهای؛ با زدن هر کدام به صفحه همان تمرین میروی.
|
||||
*/
|
||||
export default function AssignmentsPage() {
|
||||
const { effectiveUserId, guestSessionHydrated, isGuest } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<AssignmentCatalogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await fetchAssignmentCatalog();
|
||||
setItems(rows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'بارگذاری تکالیف ناموفق بود');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveUserId || isGuest) return;
|
||||
void load();
|
||||
}, [effectiveUserId, isGuest, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveUserId || isGuest) return;
|
||||
const onRefresh = () => {
|
||||
void load();
|
||||
};
|
||||
window.addEventListener(ASSIGNMENTS_REFRESH_EVENT, onRefresh);
|
||||
return () => window.removeEventListener(ASSIGNMENTS_REFRESH_EVENT, onRefresh);
|
||||
}, [effectiveUserId, isGuest, load]);
|
||||
|
||||
const unlockedCount = useMemo(() => items.filter((i) => i.unlocked).length, [items]);
|
||||
|
||||
if (!guestSessionHydrated) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!effectiveUserId) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (isGuest) {
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center px-4 text-center">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
برای ذخیره و پیگیری تکالیف باید وارد حساب کاربری شوید.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/login')}>ورود</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 transition-colors duration-300 dark:bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-md space-y-4">
|
||||
<AnimationWrapper immediate className="space-y-1 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<NotebookPen className="h-5 w-5 text-sky-600 dark:text-sky-400" />
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">تکالیف من</h1>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-body">
|
||||
برای هر جلسه یک بخش جداگانه هست. روی تکلیف بزن تا دستورالعمل را ببینی و فیلدهای گزارش
|
||||
را پر کنی.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unlockedCount} از {items.length || 9} تکلیف باز شده
|
||||
</p>
|
||||
</AnimationWrapper>
|
||||
|
||||
{loading ? (
|
||||
<PageSkeleton />
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
<Button variant="link" className="mt-1 px-0" onClick={() => void load()}>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
|
||||
هنوز تکلیفی تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<StaggeredList itemCount={items.length} className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const ua = item.userAssignment;
|
||||
const unlocked = item.unlocked && ua;
|
||||
const completed = ua?.status === 'COMPLETED';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-[11px] font-medium text-sky-700 dark:text-sky-300">
|
||||
جلسه {item.definition.sessionOrder}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{item.definition.title}
|
||||
</h2>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
<ChevronLeft className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{item.definition.instructions}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
|
||||
{assignmentTypeLabel(item.definition.assignmentType)}
|
||||
</span>
|
||||
{unlocked && ua ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5',
|
||||
completed
|
||||
? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200'
|
||||
: 'bg-sky-50 text-sky-800 dark:bg-sky-950 dark:text-sky-200',
|
||||
)}
|
||||
>
|
||||
{completed ? <Check className="h-3 w-3" /> : null}
|
||||
{assignmentStatusLabel(ua.status)}
|
||||
</span>
|
||||
<span>آخرین فعالیت: {formatActivity(ua.lastActivityAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
|
||||
{item.lockReason ?? 'پس از پایان جلسه مربوطه باز میشود'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StaggerItem key={item.definition.id}>
|
||||
{unlocked ? (
|
||||
<Link
|
||||
to={`/assignments/${ua.id}`}
|
||||
className="block rounded-xl border border-slate-200/80 bg-white p-4 shadow-sm transition hover:border-sky-300 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 p-4 opacity-80 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</StaggerItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-x-hidden overflow-y-auto p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
@@ -107,20 +107,20 @@ export default function LoginPage() {
|
||||
</button>
|
||||
|
||||
<AnimationWrapper immediate interactive={false} className="relative z-10 w-full max-w-sm">
|
||||
<Card className="relative w-full overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardHeader className="relative space-y-3">
|
||||
<Card className="relative z-10 w-full overflow-hidden">
|
||||
<CardHeader className="relative z-10 space-y-3">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<BrandLogo imgClassName="h-12" />
|
||||
</div>
|
||||
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">ورود</CardTitle>
|
||||
<CardDescription className="text-center text-body">وارد شوید</CardDescription>
|
||||
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">
|
||||
خوش اومدی!
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-body">
|
||||
وارد حسابت شو و ادامه مسیرت رو دنبال کن.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative space-y-4">
|
||||
<CardContent className="relative z-10 space-y-4">
|
||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-body">
|
||||
@@ -182,11 +182,14 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-body">
|
||||
حساب کاربری ندارید؟{' '}
|
||||
<Link to="/signup" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
||||
ثبت نام
|
||||
</Link>
|
||||
<div className="space-y-1 text-center text-sm text-body">
|
||||
<p>هنوز حساب نداری؟</p>
|
||||
<p>
|
||||
<Link to="/signup" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
||||
حسابت رو بساز
|
||||
</Link>{' '}
|
||||
و مسیرت رو با نویا شروع کن.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
||||
import { Loader2, Moon, Sun, Eye, EyeOff } from 'lucide-react';
|
||||
import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants';
|
||||
import { BrandLogo } from '@/components/BrandLogo';
|
||||
|
||||
export default function SignupPage() {
|
||||
@@ -71,7 +70,7 @@ export default function SignupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-x-hidden overflow-y-auto p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
@@ -82,21 +81,19 @@ export default function SignupPage() {
|
||||
</button>
|
||||
|
||||
<Card className="relative z-10 w-full max-w-sm overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardHeader className="relative space-y-3">
|
||||
<CardHeader className="relative z-10 space-y-3">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<BrandLogo imgClassName="h-12" />
|
||||
</div>
|
||||
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">ثبت نام</CardTitle>
|
||||
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">
|
||||
بیا شروع کنیم!
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-body">
|
||||
ایجاد حساب کاربری در {APP_NAME} — {APP_DESCRIPTION}
|
||||
حسابت رو بساز و وارد مسیر نویا شو.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative space-y-4">
|
||||
<CardContent className="relative z-10 space-y-4">
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-body">ایمیل</Label>
|
||||
@@ -164,19 +161,22 @@ export default function SignupPage() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ثبت نام...
|
||||
در حال ثبتنام...
|
||||
</>
|
||||
) : (
|
||||
'ثبت نام'
|
||||
'ثبتنام'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-body">
|
||||
حساب کاربری دارید؟{' '}
|
||||
<Link to="/login" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
||||
ورود
|
||||
</Link>
|
||||
<div className="space-y-1 text-center text-sm text-body">
|
||||
<p>قبلاً ثبتنام کردی؟</p>
|
||||
<p>
|
||||
<Link to="/login" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
||||
وارد شو
|
||||
</Link>{' '}
|
||||
و ادامه مسیرت رو دنبال کن.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -106,9 +106,18 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const unlockedHomework = updated.unlockedAssignments?.filter((a) => a.unlocked) ?? [];
|
||||
const waiting = Boolean(updated.nextSessionUnlocksAt && updated.waitingForSessionOrder);
|
||||
const homeworkHint =
|
||||
unlockedHomework.length > 0
|
||||
? `تکلیف جلسه ${payload.sessionOrder} الان در «تکالیف من» باز شد.`
|
||||
: null;
|
||||
|
||||
toast({
|
||||
title: 'جلسه تکمیل شد',
|
||||
description: 'جلسه بعدی برای شما باز شد.',
|
||||
description: waiting
|
||||
? [homeworkHint, 'جلسه بعدی پس از بازهٔ انتظار باز میشود.'].filter(Boolean).join(' ')
|
||||
: [homeworkHint, 'میتوانی به جلسه بعدی بروی.'].filter(Boolean).join(' '),
|
||||
});
|
||||
},
|
||||
[effectiveUserId, markTrainingDone, navigate, refreshCurrentSession, reloadProgress, toast]
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function FinishedPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-y-auto px-4 py-10">
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-10">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-8 text-center">
|
||||
<div className="avatar-glow">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm dark:bg-navy dark:text-neon-emerald dark:shadow-none">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ROUTES } from '@/utils/constants';
|
||||
import { BrandLogo } from '@/components/BrandLogo';
|
||||
|
||||
const NAME_MAX_LENGTH = 100;
|
||||
const PROFILE_AGE_MIN = 12;
|
||||
|
||||
const toAsciiDigits = (value: string) =>
|
||||
value.replace(/[۰-۹٠-٩]/g, (digit) => {
|
||||
@@ -64,6 +65,8 @@ export default function ProfileCompletionPage() {
|
||||
|
||||
if (parsedAge === null) {
|
||||
nextErrors.age = 'سن باید یک عدد صحیح باشد';
|
||||
} else if (parsedAge < PROFILE_AGE_MIN) {
|
||||
nextErrors.age = `حداقل سن ${PROFILE_AGE_MIN} سال است`;
|
||||
}
|
||||
|
||||
setFieldError(nextErrors);
|
||||
@@ -93,14 +96,10 @@ export default function ProfileCompletionPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
|
||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-x-hidden overflow-y-auto p-4">
|
||||
<AnimationWrapper immediate interactive={false} className="relative z-10 w-full max-w-sm">
|
||||
<Card className="relative w-full overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardHeader className="relative space-y-3">
|
||||
<Card className="relative z-10 w-full overflow-hidden">
|
||||
<CardHeader className="relative z-10 space-y-3">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<BrandLogo imgClassName="h-12" />
|
||||
</div>
|
||||
@@ -112,7 +111,7 @@ export default function ProfileCompletionPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative space-y-4">
|
||||
<CardContent className="relative z-10 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name" className="text-body">
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { api, BackendError, clearSessionToken, getSessionToken } from '@/services/backend';
|
||||
|
||||
export type AdminStats = {
|
||||
totalUsers: number;
|
||||
guestUsers: number;
|
||||
registeredUsers: number;
|
||||
pretestCompletedUsers: number;
|
||||
posttestCompletedUsers: number;
|
||||
trainingInProgressUsers: number;
|
||||
trainingCompletedUsers: number;
|
||||
courseCompletedUsers: number;
|
||||
totalAssessmentAttempts: number;
|
||||
totalChatSessions: number;
|
||||
totalChatMessages: number;
|
||||
programSessionCount: number;
|
||||
};
|
||||
|
||||
export type AdminUserListItem = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: 'user' | 'admin';
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
name: string | null;
|
||||
age: number | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string | null;
|
||||
currentStep: string | null;
|
||||
pretestCompleted: boolean;
|
||||
posttestCompleted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
completedSessionCount: number;
|
||||
programSessionCount: number;
|
||||
};
|
||||
|
||||
export type AdminUserListResult = {
|
||||
items: AdminUserListItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminUsersQuery = {
|
||||
q?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'createdAt' | 'role' | 'externalId' | 'lastActivityAt';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
function toQueryString(params: AdminUsersQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.q) search.set('q', params.q);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminStats(): Promise<AdminStats> {
|
||||
return api.get<AdminStats>('/admin/stats');
|
||||
}
|
||||
|
||||
export function fetchAdminUsers(query: AdminUsersQuery = {}): Promise<AdminUserListResult> {
|
||||
return api.get<AdminUserListResult>(`/admin/users${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export type AdminAssignmentSummary = {
|
||||
total: number;
|
||||
notStarted: number;
|
||||
opened: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
};
|
||||
|
||||
export type AdminUserDetail = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: 'user' | 'admin';
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
name: string | null;
|
||||
age: number | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string | null;
|
||||
currentStep: string | null;
|
||||
permissions: {
|
||||
canTakePreTest: boolean;
|
||||
canOpenTraining: boolean;
|
||||
canTakePostTest: boolean;
|
||||
isCompleted: boolean;
|
||||
} | null;
|
||||
pretestCompleted: boolean;
|
||||
posttestCompleted: boolean;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
completedSessionCount: number;
|
||||
programSessionCount: number;
|
||||
preTestCompletedAt: string | null;
|
||||
trainingStartedAt: string | null;
|
||||
trainingCompletedAt: string | null;
|
||||
postTestCompletedAt: string | null;
|
||||
hasPretestAttempt: boolean;
|
||||
hasPosttestAttempt: boolean;
|
||||
chatSessionCount: number;
|
||||
assignments: AdminAssignmentSummary;
|
||||
};
|
||||
|
||||
export function fetchAdminUserDetail(externalId: string): Promise<AdminUserDetail> {
|
||||
return api.get<AdminUserDetail>(`/admin/users/${encodeURIComponent(externalId)}`);
|
||||
}
|
||||
|
||||
export type AdminUserOverview = {
|
||||
user: { id: string; email: string | null };
|
||||
assignments: {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
notStarted: number;
|
||||
reported: number;
|
||||
opened: number;
|
||||
progressPercent: number | null;
|
||||
lastActivityAt: string | null;
|
||||
};
|
||||
learningProgress: {
|
||||
currentStep: string | null;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
lastActivityAt: string | null;
|
||||
};
|
||||
assessments: {
|
||||
completedCount: number;
|
||||
expectedCount: number;
|
||||
remainingCount: number;
|
||||
hasPretest: boolean;
|
||||
hasPosttest: boolean;
|
||||
pretestCompletedAt: string | null;
|
||||
posttestCompletedAt: string | null;
|
||||
lastAssessmentAt: string | null;
|
||||
overallStatus: 'none' | 'partial' | 'complete';
|
||||
};
|
||||
recentActivities: Array<{
|
||||
type:
|
||||
| 'session_completed'
|
||||
| 'assignment_activated'
|
||||
| 'assignment_completed'
|
||||
| 'assessment_submitted'
|
||||
| 'chat_started';
|
||||
title: string;
|
||||
occurredAt: string;
|
||||
relatedId: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function fetchAdminUserOverview(externalId: string): Promise<AdminUserOverview> {
|
||||
return api.get<AdminUserOverview>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/overview`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssessmentScores = {
|
||||
total: number | null;
|
||||
efficacy: number | null;
|
||||
hope: number | null;
|
||||
resilience: number | null;
|
||||
optimism: number | null;
|
||||
};
|
||||
|
||||
export type AdminAssessmentAnswer = {
|
||||
questionId: string;
|
||||
questionNumber: number;
|
||||
rawValue: number;
|
||||
points: number;
|
||||
dimension: string;
|
||||
isReverseScored: boolean;
|
||||
};
|
||||
|
||||
export type AdminAssessmentPhaseView = {
|
||||
exists: boolean;
|
||||
phase: 'pre' | 'post';
|
||||
attemptId: string | null;
|
||||
status: string | null;
|
||||
submittedAt: string | null;
|
||||
scoredAt: string | null;
|
||||
scores: AdminAssessmentScores | null;
|
||||
answerCount: number;
|
||||
expectedAnswerCount: number;
|
||||
answers: AdminAssessmentAnswer[];
|
||||
};
|
||||
|
||||
export type AdminUserAssessments = {
|
||||
user: { id: string; email: string | null };
|
||||
pretest: AdminAssessmentPhaseView;
|
||||
posttest: AdminAssessmentPhaseView;
|
||||
};
|
||||
|
||||
export function fetchAdminUserAssessments(externalId: string): Promise<AdminUserAssessments> {
|
||||
return api.get<AdminUserAssessments>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assessments`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminChatSessionListItem = {
|
||||
id: string;
|
||||
sessionOrder: number;
|
||||
title: string;
|
||||
status: 'open' | 'ended';
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type AdminChatSessionsListResult = {
|
||||
user: { id: string; email: string | null };
|
||||
items: AdminChatSessionListItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminChatMessageView = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
export type AdminChatSessionDetail = {
|
||||
user: { id: string; email: string | null };
|
||||
session: AdminChatSessionListItem;
|
||||
messages: AdminChatMessageView[];
|
||||
};
|
||||
|
||||
export type AdminChatSessionsQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'startedAt' | 'updatedAt' | 'sessionOrder' | 'createdAt';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
function chatSessionsQueryString(params: AdminChatSessionsQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminUserChatSessions(
|
||||
externalId: string,
|
||||
query: AdminChatSessionsQuery = {},
|
||||
): Promise<AdminChatSessionsListResult> {
|
||||
return api.get<AdminChatSessionsListResult>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions${chatSessionsQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAdminUserChatSessionDetail(
|
||||
externalId: string,
|
||||
sessionId: string,
|
||||
): Promise<AdminChatSessionDetail> {
|
||||
return api.get<AdminChatSessionDetail>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssignmentListItem = {
|
||||
assignmentId: string;
|
||||
definitionId: string;
|
||||
definitionKey: string;
|
||||
definitionTitle: string;
|
||||
assignmentType: string;
|
||||
programId: null;
|
||||
programTitle: null;
|
||||
sessionId: string | null;
|
||||
sessionOrder: number;
|
||||
sessionTitle: string | null;
|
||||
status: string;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
doneDayCount: number;
|
||||
expectedDurationDays: number | null;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminAssignmentsSummary = {
|
||||
total: number;
|
||||
notStarted: number;
|
||||
opened: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminLearningProgress = {
|
||||
currentStep: string | null;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminUserAssignmentsResult = {
|
||||
user: { id: string; email: string | null };
|
||||
learningProgress: AdminLearningProgress;
|
||||
summary: AdminAssignmentsSummary;
|
||||
items: AdminAssignmentListItem[];
|
||||
};
|
||||
|
||||
export type AdminAssignmentsQuery = {
|
||||
sortBy?: 'activatedAt' | 'completedAt' | 'status' | 'sessionOrder';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
status?: 'NOT_STARTED' | 'ACTIVE' | 'IN_PROGRESS' | 'REPORTED' | 'COMPLETED';
|
||||
assignmentType?: 'ONE_TIME' | 'DAILY' | 'ONGOING';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
function assignmentsQueryString(params: AdminAssignmentsQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
if (params.status) search.set('status', params.status);
|
||||
if (params.assignmentType) search.set('assignmentType', params.assignmentType);
|
||||
if (params.search) search.set('search', params.search);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminUserAssignments(
|
||||
externalId: string,
|
||||
query: AdminAssignmentsQuery = {},
|
||||
): Promise<AdminUserAssignmentsResult> {
|
||||
return api.get<AdminUserAssignmentsResult>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assignments${assignmentsQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssignmentDetail = {
|
||||
user: { id: string; email: string | null };
|
||||
assignment: {
|
||||
id: string;
|
||||
definitionId: string;
|
||||
definitionKey: string;
|
||||
definitionTitle: string;
|
||||
assignmentType: string;
|
||||
status: string;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
doneDayCount: number;
|
||||
expectedDurationDays: number | null;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
session: {
|
||||
id: string | null;
|
||||
order: number;
|
||||
title: string;
|
||||
};
|
||||
learningProgress: {
|
||||
currentStep: string | null;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
activity: {
|
||||
firstActivityAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
completedActivityCount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function fetchAdminUserAssignmentDetail(
|
||||
externalId: string,
|
||||
assignmentId: string,
|
||||
): Promise<AdminAssignmentDetail> {
|
||||
return api.get<AdminAssignmentDetail>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assignments/${encodeURIComponent(assignmentId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
if (!plain?.[1]) return null;
|
||||
const name = plain[1].trim();
|
||||
if (!name || /[\\/\n\r"]/.test(name)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = 'noopener';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary XLSX download — bypasses JSON api.get helper.
|
||||
*/
|
||||
export async function downloadAdminUserAssignmentsExport(
|
||||
externalId: string,
|
||||
query: AdminAssignmentsQuery = {},
|
||||
): Promise<void> {
|
||||
const path = `/admin/users/${encodeURIComponent(externalId)}/assignments/export${assignmentsQueryString(query)}`;
|
||||
const baseUrl = (import.meta.env.VITE_BACKEND_URL || '/api/backend').replace(/\/$/, '');
|
||||
const token = getSessionToken();
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, { method: 'GET', headers });
|
||||
} catch {
|
||||
throw new BackendError(
|
||||
0,
|
||||
'NETWORK_ERROR',
|
||||
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let code = 'UNKNOWN';
|
||||
let message = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
code = body.error?.code ?? code;
|
||||
message = body.error?.message ?? message;
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
if (response.status === 401) {
|
||||
clearSessionToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
|
||||
}
|
||||
}
|
||||
throw new BackendError(response.status, code, message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const fromHeader = parseContentDispositionFilename(
|
||||
response.headers.get('Content-Disposition'),
|
||||
);
|
||||
const safeFallback = `noya-assignments-${externalId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64) || 'user'}.xlsx`;
|
||||
triggerBrowserDownload(blob, fromHeader ?? safeFallback);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { api, hasSessionToken } from '@/services/backend';
|
||||
|
||||
export type AssignmentType = 'ONE_TIME' | 'DAILY' | 'ONGOING';
|
||||
export type UserAssignmentStatus =
|
||||
| 'NOT_STARTED'
|
||||
| 'ACTIVE'
|
||||
| 'IN_PROGRESS'
|
||||
| 'REPORTED'
|
||||
| 'COMPLETED';
|
||||
export type AssignmentReportStatus = 'completed' | 'in_progress' | 'difficult' | 'not_started';
|
||||
export type AssignmentDayStatus = 'done' | 'skipped' | 'difficult';
|
||||
export type AssignmentListFilter = 'active' | 'pending_report' | 'completed' | 'all';
|
||||
|
||||
export type AssignmentFormField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'checkbox' | 'text' | 'textarea' | 'list';
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
visibleWhen?: { field: string; equals: unknown };
|
||||
itemFields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'checkbox';
|
||||
required?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AssignmentDefinition = {
|
||||
id: string;
|
||||
key: string;
|
||||
sessionOrder: number;
|
||||
title: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
assignmentType: AssignmentType;
|
||||
formSchema: AssignmentFormField[] | null;
|
||||
expectedDurationDays: number | null;
|
||||
suggestedEndDay: number | null;
|
||||
minValidDays: number | null;
|
||||
requiresReport: boolean;
|
||||
blocksNextSession: boolean;
|
||||
isFinalAssignment: boolean;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export type AssignmentReport = {
|
||||
id: string;
|
||||
reportText: string;
|
||||
reportStatus: AssignmentReportStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AssignmentDailyEntry = {
|
||||
id: string;
|
||||
entryDate: string;
|
||||
dayStatus: AssignmentDayStatus;
|
||||
responseData: Record<string, unknown>;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserAssignment = {
|
||||
id: string;
|
||||
status: UserAssignmentStatus;
|
||||
activatedAt: string;
|
||||
startedAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
completedAt: string | null;
|
||||
completionNote: string | null;
|
||||
draftData: Record<string, unknown> | null;
|
||||
reportCount: number;
|
||||
entryCount: number;
|
||||
doneDayCount: number;
|
||||
canComplete: boolean;
|
||||
completionBlockReason: string | null;
|
||||
serverToday: string;
|
||||
latestReport: AssignmentReport | null;
|
||||
definition: AssignmentDefinition;
|
||||
reports?: AssignmentReport[];
|
||||
dailyEntries?: AssignmentDailyEntry[];
|
||||
};
|
||||
|
||||
export type AssignmentCatalogItem = {
|
||||
definition: AssignmentDefinition;
|
||||
/** True only when the related educational session is completed in Backend/DB. */
|
||||
unlocked: boolean;
|
||||
/** Present when locked — explains the real gate (session completion), not cooldown. */
|
||||
lockReason: string | null;
|
||||
userAssignment: UserAssignment | null;
|
||||
};
|
||||
|
||||
/** Dispatched after a session completes so open assignment views can refetch. */
|
||||
export const ASSIGNMENTS_REFRESH_EVENT = 'novya:assignments-refresh';
|
||||
|
||||
export function notifyAssignmentsChanged(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(ASSIGNMENTS_REFRESH_EVENT));
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<UserAssignmentStatus, string> = {
|
||||
NOT_STARTED: 'شروعنشده',
|
||||
ACTIVE: 'فعال',
|
||||
IN_PROGRESS: 'در حال انجام',
|
||||
REPORTED: 'در حال پیگیری',
|
||||
COMPLETED: 'تکمیلشده',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<AssignmentType, string> = {
|
||||
ONE_TIME: 'یکباره',
|
||||
DAILY: 'روزانه',
|
||||
ONGOING: 'چندروزه',
|
||||
};
|
||||
|
||||
const DAY_STATUS_LABELS: Record<AssignmentDayStatus, string> = {
|
||||
done: 'انجام دادم',
|
||||
skipped: 'امروز انجام ندادم',
|
||||
difficult: 'امروز برایم دشوار بود',
|
||||
};
|
||||
|
||||
export function assignmentStatusLabel(status: UserAssignmentStatus): string {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
export function assignmentTypeLabel(type: AssignmentType): string {
|
||||
return TYPE_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
export function assignmentDayStatusLabel(status: AssignmentDayStatus): string {
|
||||
return DAY_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
export function isMultiDayAssignment(type: AssignmentType): boolean {
|
||||
return type === 'DAILY' || type === 'ONGOING';
|
||||
}
|
||||
|
||||
export async function fetchAssignments(filter: AssignmentListFilter = 'all'): Promise<UserAssignment[]> {
|
||||
if (!hasSessionToken()) return [];
|
||||
const data = await api.get<{ assignments: UserAssignment[]; filter: string }>(
|
||||
`/assignments?filter=${filter}`,
|
||||
);
|
||||
return data.assignments ?? [];
|
||||
}
|
||||
|
||||
export async function fetchAssignmentCatalog(): Promise<AssignmentCatalogItem[]> {
|
||||
if (!hasSessionToken()) return [];
|
||||
const data = await api.get<{ catalog: AssignmentCatalogItem[] }>('/assignments/catalog');
|
||||
return data.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function fetchAssignment(id: string): Promise<UserAssignment> {
|
||||
return api.get<UserAssignment>(`/assignments/${id}`);
|
||||
}
|
||||
|
||||
export async function startAssignment(id: string): Promise<UserAssignment> {
|
||||
return api.post<UserAssignment>(`/assignments/${id}/start`, {});
|
||||
}
|
||||
|
||||
export async function completeAssignment(
|
||||
id: string,
|
||||
completionNote?: string,
|
||||
): Promise<UserAssignment> {
|
||||
return api.post<UserAssignment>(`/assignments/${id}/complete`, {
|
||||
completionNote: completionNote ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveAssignmentDraft(
|
||||
id: string,
|
||||
draftData: Record<string, unknown>,
|
||||
options?: { completeIfValid?: boolean },
|
||||
): Promise<UserAssignment> {
|
||||
return api.patch<UserAssignment>(`/assignments/${id}/draft`, {
|
||||
draftData,
|
||||
completeIfValid: options?.completeIfValid ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitAssignmentReport(
|
||||
id: string,
|
||||
reportText: string,
|
||||
reportStatus: AssignmentReportStatus,
|
||||
): Promise<{ report: AssignmentReport; assignment: UserAssignment }> {
|
||||
return api.post(`/assignments/${id}/reports`, { reportText, reportStatus });
|
||||
}
|
||||
|
||||
export async function createAssignmentEntry(
|
||||
id: string,
|
||||
body: {
|
||||
entryDate?: string;
|
||||
dayStatus?: AssignmentDayStatus;
|
||||
responseData: Record<string, unknown>;
|
||||
note?: string | null;
|
||||
},
|
||||
): Promise<AssignmentDailyEntry> {
|
||||
return api.post(`/assignments/${id}/entries`, body);
|
||||
}
|
||||
|
||||
export async function updateAssignmentEntry(
|
||||
id: string,
|
||||
entryId: string,
|
||||
body: {
|
||||
dayStatus?: AssignmentDayStatus;
|
||||
responseData?: Record<string, unknown>;
|
||||
note?: string | null;
|
||||
},
|
||||
): Promise<AssignmentDailyEntry> {
|
||||
return api.patch(`/assignments/${id}/entries/${entryId}`, body);
|
||||
}
|
||||
@@ -5,16 +5,40 @@ import {
|
||||
type ProgramSession,
|
||||
} from '@/data/program/sessions';
|
||||
import { api, hasSessionToken } from '@/services/backend';
|
||||
import { notifyAssignmentsChanged } from '@/services/assignmentService';
|
||||
import {
|
||||
getNextLockedSessionUnlockTime,
|
||||
isSessionUnlockedForProgress,
|
||||
type SessionCompletionTimes,
|
||||
} from '@/lib/sessionUnlock';
|
||||
import {
|
||||
getHomeworkForSession,
|
||||
getHomeworkReportRequiredFor,
|
||||
} from '@/data/program/sessionHomework';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PendingHomeworkReport {
|
||||
forSessionOrder: number;
|
||||
homeworkSessionOrder: number;
|
||||
homework: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
};
|
||||
/** Soft check-in — never hard-gates the next session. */
|
||||
skippable?: boolean;
|
||||
}
|
||||
|
||||
/** Snapshot of assignments unlocked because educational session N was completed. */
|
||||
export type UnlockedAssignmentSnapshot = {
|
||||
definition: { sessionOrder: number; title: string; key: string };
|
||||
unlocked: boolean;
|
||||
lockReason: string | null;
|
||||
userAssignment: { id: string } | null;
|
||||
};
|
||||
|
||||
export interface ProgramProgress {
|
||||
userId: string;
|
||||
currentSessionOrder: number; // 0 = not started; 1-based order
|
||||
@@ -24,6 +48,9 @@ export interface ProgramProgress {
|
||||
completedAt: string | null;
|
||||
nextSessionUnlocksAt: string | null;
|
||||
waitingForSessionOrder: number | null;
|
||||
pendingHomeworkReport: PendingHomeworkReport | null;
|
||||
/** Assignments unlocked by the latest completeSession call (session N completed → those of N). */
|
||||
unlockedAssignments?: UnlockedAssignmentSnapshot[];
|
||||
}
|
||||
|
||||
export interface SessionCompletionSignal {
|
||||
@@ -88,8 +115,11 @@ const defaultProgress = (userId: string): ProgramProgress => ({
|
||||
completedAt: null,
|
||||
nextSessionUnlocksAt: null,
|
||||
waitingForSessionOrder: null,
|
||||
pendingHomeworkReport: null,
|
||||
});
|
||||
|
||||
type BackendUnlockedAssignment = UnlockedAssignmentSnapshot;
|
||||
|
||||
type BackendSessionProgress = {
|
||||
currentSessionOrder: number;
|
||||
completedSessions: number[];
|
||||
@@ -97,6 +127,8 @@ type BackendSessionProgress = {
|
||||
completedAt: string | null;
|
||||
nextSessionUnlocksAt?: string | null;
|
||||
waitingForSessionOrder?: number | null;
|
||||
pendingHomeworkReport?: PendingHomeworkReport | null;
|
||||
unlockedAssignments?: BackendUnlockedAssignment[];
|
||||
sessions?: {
|
||||
sessionOrder: number;
|
||||
completedAt: string | null;
|
||||
@@ -141,6 +173,8 @@ const toProgramProgress = (userId: string, data: BackendSessionProgress): Progra
|
||||
completedAt: data.completedAt,
|
||||
nextSessionUnlocksAt: data.nextSessionUnlocksAt ?? waiting?.unlocksAt.toISOString() ?? null,
|
||||
waitingForSessionOrder: data.waitingForSessionOrder ?? waiting?.sessionOrder ?? null,
|
||||
pendingHomeworkReport: data.pendingHomeworkReport ?? null,
|
||||
unlockedAssignments: data.unlockedAssignments ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -167,6 +201,7 @@ export function getProgramProgress(userId: string): ProgramProgress {
|
||||
sessionCompletedAt: raw.sessionCompletedAt ?? {},
|
||||
nextSessionUnlocksAt: raw.nextSessionUnlocksAt ?? null,
|
||||
waitingForSessionOrder: raw.waitingForSessionOrder ?? null,
|
||||
pendingHomeworkReport: raw.pendingHomeworkReport ?? null,
|
||||
};
|
||||
|
||||
const waiting = getNextLockedSessionUnlockTime(merged.completedSessions, merged.sessionCompletedAt);
|
||||
@@ -234,6 +269,15 @@ export function completeSession(userId: string, sessionOrder: number): ProgramPr
|
||||
|
||||
const completedAt = allDone ? completedAtIso : null;
|
||||
const waiting = getNextLockedSessionUnlockTime(completedSessions, sessionCompletedAt);
|
||||
const homeworkSessionOrder = allDone ? null : getHomeworkReportRequiredFor(nextSessionOrder);
|
||||
const pendingHomeworkReport =
|
||||
homeworkSessionOrder && getHomeworkForSession(homeworkSessionOrder)
|
||||
? {
|
||||
forSessionOrder: nextSessionOrder,
|
||||
homeworkSessionOrder,
|
||||
homework: getHomeworkForSession(homeworkSessionOrder)!,
|
||||
}
|
||||
: null;
|
||||
|
||||
const updated: ProgramProgress = {
|
||||
userId,
|
||||
@@ -244,6 +288,7 @@ export function completeSession(userId: string, sessionOrder: number): ProgramPr
|
||||
completedAt,
|
||||
nextSessionUnlocksAt: waiting?.unlocksAt.toISOString() ?? null,
|
||||
waitingForSessionOrder: waiting?.sessionOrder ?? null,
|
||||
pendingHomeworkReport,
|
||||
};
|
||||
|
||||
saveProgress(userId, updated);
|
||||
@@ -261,9 +306,32 @@ export async function completeSessionWithBackend(
|
||||
const data = await api.post<BackendSessionProgress>(`/sessions/${signal.sessionOrder}/complete`, signal);
|
||||
const mapped = toProgramProgress(userId, data);
|
||||
saveProgress(userId, mapped);
|
||||
notifyAssignmentsChanged();
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function submitHomeworkReport(
|
||||
userId: string,
|
||||
homeworkSessionOrder: number,
|
||||
reportText: string,
|
||||
): Promise<ProgramProgress> {
|
||||
if (!shouldUseBackend(userId)) {
|
||||
const current = getProgramProgress(userId);
|
||||
const updated: ProgramProgress = {
|
||||
...current,
|
||||
pendingHomeworkReport:
|
||||
current.pendingHomeworkReport?.homeworkSessionOrder === homeworkSessionOrder
|
||||
? null
|
||||
: current.pendingHomeworkReport,
|
||||
};
|
||||
saveProgress(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
await api.post(`/sessions/homework/${homeworkSessionOrder}`, { reportText });
|
||||
return syncProgramProgress(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific session is unlocked for a user.
|
||||
* Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed
|
||||
|
||||
+161
-88
@@ -35,8 +35,8 @@
|
||||
--neon-emerald: #10b981;
|
||||
--navy-deep: #0f172a;
|
||||
--navy: #1e293b;
|
||||
--glass-bg: rgba(255, 255, 255, 0.88);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.94);
|
||||
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.72);
|
||||
--body-text: #334155;
|
||||
--glow-blue: 0 0 16px rgba(0, 168, 232, 0.35);
|
||||
--glow-teal: 0 0 16px rgba(38, 212, 202, 0.4);
|
||||
@@ -75,8 +75,8 @@
|
||||
--neon-blue: #00a8e8;
|
||||
--neon-cyan: #0d9488;
|
||||
--neon-teal: #0f766e;
|
||||
--glass-bg: rgba(255, 255, 255, 0.88);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.94);
|
||||
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.72);
|
||||
--body-text: #334155;
|
||||
--glow-blue: 0 2px 8px rgba(0, 168, 232, 0.18);
|
||||
--glow-teal: 0 2px 8px rgba(38, 212, 202, 0.16);
|
||||
@@ -114,8 +114,8 @@
|
||||
--neon-blue: #00a8e8;
|
||||
--neon-cyan: #26d4ca;
|
||||
--neon-teal: #26d4ca;
|
||||
--glass-bg: rgba(30, 41, 59, 0.82);
|
||||
--glass-bg-strong: rgba(30, 41, 59, 0.92);
|
||||
--glass-bg: rgba(30, 41, 59, 0.52);
|
||||
--glass-bg-strong: rgba(30, 41, 59, 0.72);
|
||||
--body-text: #e2e8f0;
|
||||
--glow-blue: 0 0 18px rgba(0, 168, 232, 0.45), 0 0 36px rgba(0, 168, 232, 0.2);
|
||||
--glow-teal: 0 0 18px rgba(38, 212, 202, 0.5), 0 0 36px rgba(38, 212, 202, 0.22);
|
||||
@@ -160,7 +160,10 @@
|
||||
html {
|
||||
color-scheme: light;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -182,7 +185,9 @@
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -278,6 +283,7 @@
|
||||
.app-mobile-shell {
|
||||
@apply relative z-10 flex w-full max-w-[430px] flex-1 flex-col overflow-hidden;
|
||||
min-height: 0;
|
||||
max-width: min(100%, 430px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: #0f172a;
|
||||
border: 1px solid rgb(51 65 85 / 0.55);
|
||||
@@ -334,58 +340,54 @@
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
@apply relative rounded-2xl backdrop-blur-xl;
|
||||
@apply relative overflow-hidden rounded-2xl;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid rgb(226 232 240 / 0.9);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
/* Chat messages surface — high-intensity glass, no extra border */
|
||||
.glass-panel-chat {
|
||||
@apply relative h-full min-h-0 overflow-hidden rounded-none;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(42px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(42px) saturate(1.7);
|
||||
border: none;
|
||||
border: 1px solid rgb(255 255 255 / 0.45);
|
||||
backdrop-filter: blur(24px) saturate(1.45);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.45);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(47, 127, 212, 0.14),
|
||||
0 10px 28px rgba(52, 184, 150, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
0 8px 32px rgb(15 23 42 / 0.08),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
.glass-panel-chat::before {
|
||||
.glass-panel::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 100% 0%, rgba(56, 160, 230, 0.18), transparent 55%),
|
||||
radial-gradient(ellipse 70% 45% at 0% 100%, rgba(52, 184, 150, 0.16), transparent 50%);
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: 7rem;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgb(0 168 232 / 0.14),
|
||||
rgb(38 212 202 / 0.05),
|
||||
transparent
|
||||
);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
html.dark .glass-panel-chat {
|
||||
background: rgba(12, 20, 36, 0.22);
|
||||
/* Legacy alias — prefer .glass-panel (same visual language as login / assessment) */
|
||||
.glass-panel-chat {
|
||||
@apply relative h-full min-h-0 overflow-hidden rounded-2xl;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid rgb(255 255 255 / 0.45);
|
||||
backdrop-filter: blur(24px) saturate(1.45);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.45);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 140, 210, 0.16),
|
||||
0 10px 28px rgba(52, 184, 150, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
0 8px 32px rgb(15 23 42 / 0.08),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
html.light .glass-panel-chat {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(47, 127, 212, 0.12),
|
||||
0 10px 28px rgba(52, 184, 150, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Chat composer */
|
||||
/* Chat composer — single flat background (no nested input fill) */
|
||||
/* Chat composer — glass, aligned with login inputs */
|
||||
.composer-glass {
|
||||
@apply relative flex min-h-12 w-full items-end gap-2 rounded-2xl p-1.5;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid rgb(255 255 255 / 0.4);
|
||||
backdrop-filter: blur(18px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.35);
|
||||
box-shadow:
|
||||
0 4px 16px rgb(15 23 42 / 0.06),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
.composer-glass textarea.input-neon {
|
||||
@@ -400,37 +402,41 @@
|
||||
border-color: rgb(38 212 202 / 0.55);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(38 212 202 / 0.3),
|
||||
0 3px 8px rgb(15 23 42 / 0.08);
|
||||
0 4px 16px rgb(15 23 42 / 0.08),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
html.dark .composer-glass,
|
||||
html.dark .composer-glass:focus-within {
|
||||
background: #1e293b;
|
||||
border-color: rgb(51 65 85 / 0.9);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
|
||||
background: var(--glass-bg);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
box-shadow:
|
||||
0 4px 18px rgb(0 0 0 / 0.28),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
html.dark .composer-glass:focus-within {
|
||||
border-color: #26d4ca;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(38 212 202 / 0.45),
|
||||
0 3px 10px rgb(0 0 0 / 0.35),
|
||||
0 0 14px rgb(38 212 202 / 0.35),
|
||||
0 0 22px rgb(0 168 232 / 0.2);
|
||||
0 4px 18px rgb(0 0 0 / 0.35),
|
||||
0 0 14px rgb(38 212 202 / 0.3),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
html.light .composer-glass,
|
||||
html.light .composer-glass:focus-within {
|
||||
background: #ffffff;
|
||||
border-color: rgb(203 213 225 / 0.95);
|
||||
background: var(--glass-bg);
|
||||
border-color: rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
html.light .composer-glass:focus-within {
|
||||
border-color: #26d4ca;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(38 212 202 / 0.28),
|
||||
0 3px 8px rgb(15 23 42 / 0.08),
|
||||
0 0 8px rgb(38 212 202 / 0.16);
|
||||
0 4px 16px rgb(15 23 42 / 0.08),
|
||||
0 0 8px rgb(38 212 202 / 0.14),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.6);
|
||||
}
|
||||
|
||||
.composer-glass .input-neon,
|
||||
@@ -454,12 +460,14 @@
|
||||
}
|
||||
|
||||
.glass-panel-strong {
|
||||
@apply relative rounded-2xl backdrop-blur-md;
|
||||
@apply relative overflow-hidden rounded-2xl;
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid rgb(226 232 240 / 0.95);
|
||||
border: 1px solid rgb(255 255 255 / 0.4);
|
||||
backdrop-filter: blur(20px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.35);
|
||||
box-shadow:
|
||||
0 8px 24px rgb(15 23 42 / 0.08),
|
||||
0 1px 2px rgb(15 23 42 / 0.04);
|
||||
0 8px 28px rgb(15 23 42 / 0.1),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.neon-border {
|
||||
@@ -470,16 +478,27 @@
|
||||
|
||||
html.light .glass-panel,
|
||||
html.light .glass-panel-strong {
|
||||
border-color: rgb(226 232 240 / 0.95);
|
||||
border-color: rgb(255 255 255 / 0.65);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(15 23 42 / 0.04),
|
||||
0 4px 12px rgb(15 23 42 / 0.03);
|
||||
0 8px 28px rgb(15 23 42 / 0.07),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.7);
|
||||
}
|
||||
|
||||
html.dark .glass-panel,
|
||||
html.dark .glass-panel-strong {
|
||||
border-color: rgb(51 65 85 / 0.55);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
box-shadow:
|
||||
0 10px 32px rgb(0 0 0 / 0.35),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
html.dark .glass-panel::before {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgb(0 168 232 / 0.18),
|
||||
rgb(38 212 202 / 0.06),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
html.dark .neon-border {
|
||||
@@ -559,28 +578,34 @@
|
||||
|
||||
.btn-glass {
|
||||
@apply inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-300;
|
||||
background: rgb(241 245 249 / 0.9);
|
||||
border: 1px solid rgb(203 213 225 / 0.95);
|
||||
background: rgb(255 255 255 / 0.35);
|
||||
border: 1px solid rgb(255 255 255 / 0.5);
|
||||
color: var(--body-text);
|
||||
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 2px 8px rgb(15 23 42 / 0.05),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.btn-glass:hover:not(:disabled) {
|
||||
border-color: rgb(38 212 202 / 0.5);
|
||||
background: rgb(248 250 252);
|
||||
background: rgb(255 255 255 / 0.55);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 3px 8px rgb(15 23 42 / 0.07);
|
||||
box-shadow: 0 3px 10px rgb(15 23 42 / 0.07);
|
||||
}
|
||||
|
||||
html.dark .btn-glass {
|
||||
background: rgb(30 41 59 / 0.85);
|
||||
border-color: rgb(38 212 202 / 0.28);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
|
||||
background: rgb(30 41 59 / 0.45);
|
||||
border-color: rgb(255 255 255 / 0.14);
|
||||
box-shadow:
|
||||
0 2px 10px rgb(0 0 0 / 0.25),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
html.dark .btn-glass:hover:not(:disabled) {
|
||||
border-color: #26d4ca;
|
||||
background: rgb(51 65 85 / 0.75);
|
||||
background: rgb(51 65 85 / 0.55);
|
||||
box-shadow:
|
||||
0 3px 10px rgb(0 0 0 / 0.35),
|
||||
0 0 12px rgb(38 212 202 / 0.28),
|
||||
@@ -588,8 +613,8 @@
|
||||
}
|
||||
|
||||
html.light .btn-glass {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgb(203 213 225 / 0.95);
|
||||
background: rgb(255 255 255 / 0.4);
|
||||
border-color: rgb(255 255 255 / 0.6);
|
||||
}
|
||||
|
||||
.btn-glass:focus,
|
||||
@@ -652,10 +677,14 @@
|
||||
|
||||
.input-neon {
|
||||
@apply flex h-11 w-full items-center rounded-xl px-3 py-0 text-sm leading-relaxed transition-all duration-300;
|
||||
background: rgb(248 250 252);
|
||||
border: 1px solid rgb(203 213 225 / 0.9);
|
||||
background: rgb(255 255 255 / 0.45);
|
||||
border: 1px solid rgb(255 255 255 / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 2px 8px rgb(15 23 42 / 0.05),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
.input-neon::placeholder {
|
||||
@@ -663,16 +692,20 @@
|
||||
}
|
||||
|
||||
html.dark .input-neon {
|
||||
background: rgb(30 41 59 / 0.9);
|
||||
border-color: rgb(71 85 105 / 0.75);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
|
||||
background: rgb(15 23 42 / 0.35);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
box-shadow:
|
||||
0 2px 10px rgb(0 0 0 / 0.25),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
html.light .input-neon {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgb(203 213 225 / 0.95);
|
||||
background: rgb(255 255 255 / 0.5);
|
||||
border-color: rgb(255 255 255 / 0.65);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||
box-shadow:
|
||||
0 2px 8px rgb(15 23 42 / 0.05),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.7);
|
||||
}
|
||||
|
||||
html.light .input-neon::placeholder {
|
||||
@@ -743,15 +776,35 @@
|
||||
|
||||
.ambient-glow {
|
||||
position: relative;
|
||||
/* Pseudo glows extend past the box; clip X so mobile cannot pan sideways */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@supports not (overflow: clip) {
|
||||
.ambient-glow {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ambient-glow::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: -20% -10% auto;
|
||||
height: 55%;
|
||||
background: radial-gradient(ellipse at center, rgba(0, 102, 255, 0.25), transparent 70%);
|
||||
inset: -18% 0 auto;
|
||||
height: 58%;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 60% at 30% 40%, rgba(0, 168, 232, 0.22), transparent 70%),
|
||||
radial-gradient(ellipse 60% 50% at 78% 30%, rgba(38, 212, 202, 0.18), transparent 68%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ambient-glow::after {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: auto 0 -25%;
|
||||
height: 45%;
|
||||
background: radial-gradient(ellipse 65% 55% at 60% 70%, rgba(38, 212, 202, 0.12), transparent 72%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -786,7 +839,8 @@
|
||||
|
||||
.bubble-user {
|
||||
@apply inline-flex w-fit flex-col rounded-2xl py-2.5 text-base leading-7;
|
||||
max-width: calc(85% * 1.2);
|
||||
/* Cap at 100% — calc(85% * 1.2) was 102% and caused mobile sideways scroll */
|
||||
max-width: min(100%, calc(85% * 1.2));
|
||||
padding-inline: calc(0.875rem * 1.2);
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
backdrop-filter: blur(18px) saturate(1.35);
|
||||
@@ -854,6 +908,19 @@
|
||||
text-justify: inter-word;
|
||||
}
|
||||
|
||||
/* ChatGPT-like breathing room between blocks */
|
||||
.bubble-ai > div {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.bubble-ai p + p {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.bubble-ai h3 {
|
||||
@apply mt-1 mb-0.5;
|
||||
}
|
||||
|
||||
.bubble-user .chat-prose {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
@@ -882,6 +949,12 @@
|
||||
margin-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
|
||||
.bubble-ai ol.list-none {
|
||||
padding-inline-start: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.bubble-ai li::marker {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
|
||||
@@ -108,6 +108,7 @@ export type ContentBlock =
|
||||
/**
|
||||
* Session metadata + ordered content blocks.
|
||||
* Progress / unlock fields are NOT part of this model — they live in programService.
|
||||
* Optional homework is attached via attachHomework helpers (sessions 2–10).
|
||||
*/
|
||||
export type ProgramSession = {
|
||||
id: string;
|
||||
@@ -116,4 +117,8 @@ export type ProgramSession = {
|
||||
description: string;
|
||||
estimatedDurationMinutes: number;
|
||||
blocks: ContentBlock[];
|
||||
homework?: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ROUTES = {
|
||||
LOGIN: '/login',
|
||||
SIGNUP: '/signup',
|
||||
PROFILE: '/profile',
|
||||
ADMIN: '/admin',
|
||||
} as const;
|
||||
|
||||
// Request statuses
|
||||
|
||||
Reference in New Issue
Block a user