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:
ar.badiei
2026-09-21 08:00:24 +03:30
co-authored by Cursor
parent 8016b8f45e
commit c6243caa35
104 changed files with 15654 additions and 415 deletions
+70 -36
View File
@@ -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>
);
}
+1
View File
@@ -5,6 +5,7 @@ export type BackendUser = {
email: string | null;
isGuest: boolean;
profileComplete: boolean;
role: 'user' | 'admin';
};
export type GuestSession = {
+33
View File
@@ -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>;
}
+21 -1
View File
@@ -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"
+106
View File
@@ -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>
);
}
+305
View File
@@ -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')}٪`;
}
+21
View File
@@ -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');
}
+18
View File
@@ -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>
);
};
+61 -21
View File
@@ -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 (
+54
View File
@@ -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);
}
+77
View File
@@ -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;
}
+5 -1
View File
@@ -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;
+1
View File
@@ -186,6 +186,7 @@ export function useAuth() {
email: data.email,
isGuest: data.isGuest,
profileComplete: data.profileComplete,
role: data.role ?? 'user',
});
return data;
},
+47 -13
View File
@@ -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,
};
}
+33 -20
View File
@@ -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):
+76 -6
View File
@@ -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 &&
+26 -3
View File
@@ -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;
}
+7 -4
View File
@@ -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
+307
View File
@@ -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>
);
}
+216 -61
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+18 -15
View File
@@ -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>
+17 -17
View File
@@ -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>
+10 -1
View File
@@ -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]
+1 -1
View File
@@ -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">
+7 -8
View File
@@ -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">
+489
View File
@@ -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);
}
+210
View File
@@ -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);
}
+68
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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;
};
+1
View File
@@ -13,6 +13,7 @@ export const ROUTES = {
LOGIN: '/login',
SIGNUP: '/signup',
PROFILE: '/profile',
ADMIN: '/admin',
} as const;
// Request statuses