56 changed files with 465 additions and 3739 deletions
-8
View File
@@ -1,8 +0,0 @@
# Noya MySQL Production Environment Variables (Template)
# این مقادیر نمونه هستند؛ مقادیر واقعی را در سرور در فایل .env.mysql ایجاد کنید.
# هشدار: هرگز فایل واقعی دارای رمز عبور را در Git قرار ندهید.
MYSQL_ROOT_PASSWORD=change_this_to_strong_root_password_32char
MYSQL_DATABASE=noya_db
MYSQL_USER=noya_user
MYSQL_PASSWORD=change_this_to_strong_user_password_32char
-18
View File
@@ -1,18 +0,0 @@
# Noya Compose Production Settings (Template)
# این متغیرها برای docker-compose.prod.yml استفاده می‌شوند.
# پورت داخلی وب‌سرور نویا روی لوکال‌هاست سرور (127.0.0.1)
NOYA_WEB_PORT=38080
# تگ ایمیج‌ها (پیش‌فرض: latest)
NOYA_IMAGE_TAG=latest
# نسخه MySQL متناسب با CPU سرور جدید:
# اگر CPU از SSE4.2 پشتیبانی کند: mysql:8.0 یا mysql:8.4
# اگر CPU قدیمی باشد یا سرور اقتصادی/اشتراکی: mysql:5.7
NOYA_MYSQL_IMAGE=mysql:5.7
# پروکسی اختیاری صرفاً در صورتی که ساخت ایمیج روی سرور انجام شود و به پروکسی نیاز باشد (در حالت عادی خالی بگذارید)
BUILD_HTTP_PROXY=
BUILD_HTTPS_PROXY=
BUILD_ALL_PROXY=
-4
View File
@@ -37,10 +37,6 @@ backend/.env
sw.js
workbox-*.js
# Temporary test files
backend/tmp-*
tmp-*
+1 -1
View File
@@ -76,7 +76,7 @@ Set `VITE_BACKEND_URL` to the public API base URL **before** `npm run build`.
## 6. Checklist for the other deployer
- [ ] MySQL migrated + seeded (11 course sessions + 9 assignment definitions)
- [ ] MySQL migrated + seeded (11 course sessions)
- [ ] Backend `CORS_ORIGIN` matches the real frontend origin
- [ ] `OPENAI_API_KEY` set on backend
- [ ] Frontend built with correct `VITE_BACKEND_URL`
-253
View File
@@ -1,253 +0,0 @@
# Noya — Production Deployment Runbook (راهنمای جامع استقرار ایزوله)
این سند راهنمای عملیاتی و گام‌به‌گام برای استقرار ایمن و کاملاً ایزوله پروژه **Noya** روی سرور جدید است. این فرآیند به گونه‌ای طراحی شده که هیچ تداخلی با سایر پروژه‌ها، پورت‌ها، کانتینرها و دیتابیس‌های موجود روی سرور ایجاد نکند و تا زمان تأیید نهایی، هیچ نیازی به تغییر در رکوردهای DNS دامنه ندارد.
---
## ۱. پیش‌نیازها و ارزیابی سرور جدید (Server Preflight)
پیش از انتقال یا اجرای هر فایلی، دستورات زیر را روی سرور جدید اجرا کنید:
```bash
# ۱. بررسی فضای دیسک و حافظه رم (حداقل ۱۰ گیگابایت دیسک و ۲ گیگابایت رم آزاد پیشنهاد می‌شود)
df -h /
free -m
# ۲. بررسی معماری پردازنده و فلگ SSE4.2 (جهت تعیین نسخه MySQL)
lscpu | grep -E "Model name|Flags|sse4_2"
# ۳. بررسی پورت‌های در حال استفاده (جهت اطمینان از آزاد بودن پورت ۳۸۰۸۰ یا انتخاب پورت دیگر)
sudo ss -tulpn | grep 38080
# ۴. بررسی کانتینرها و شبکه‌های فعال داکر
docker ps --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}"
docker network ls
# ۵. بررسی وضعیت Reverse Proxy فعال سرور (Nginx، Caddy، Traefik یا NPM)
ps aux | grep -E 'nginx|caddy|traefik'
```
---
## ۲. روش ساخت (Build) و بسته‌بندی Imageها در سیستم محلی
با توجه به محدودیت‌های احتمالی دانلود پکیج‌ها و باینری‌های Prisma روی سرورهای واقع در ایران، **روش پیشنهادی و پایدار**، ساخت محلی Imageها و انتقال آن‌ها به صورت فایل فشرده است:
```bash
# در سیستم محلی (پوشه ریشه پروژه E:\P_W_A):
# الف) ساخت ایمیج فرانت‌اند برای معماری لینوکس x86_64
docker build --platform linux/amd64 -t noya-frontend:latest .
# ب) ساخت ایمیج بک‌اند برای معماری لینوکس x86_64
docker build --platform linux/amd64 -t noya-backend:latest ./backend
# ج) خروجی و فشرده‌سازی ایمیج‌ها به فرمت tar.gz
docker save noya-frontend:latest | gzip > noya-frontend.tar.gz
docker save noya-backend:latest | gzip > noya-backend.tar.gz
```
> **نکته:** در صورتی که پردازنده سرور جدید ARM64 باشد (مانند سرورهای Hetzner CAX)، در دستورات فوق مقدار `--platform linux/arm64` قرار دهید.
---
## ۳. انتقال فایل‌ها به سرور جدید
فولدر اختصاصی برای پروژه در مسیر استاندارد سرور (مثلاً `/opt/noya`) ایجاد کنید:
```bash
# روی سرور جدید:
sudo mkdir -p /opt/noya/backend
sudo chown -R $USER:$USER /opt/noya
```
سپس از سیستم محلی، فایل‌های موردنیاز را با `scp` منتقل کنید:
```bash
# انتقال ایمیج‌های ساخته‌شده:
scp noya-frontend.tar.gz noya-backend.tar.gz user@NEW_SERVER_IP:/opt/noya/
# انتقال فایل کامپوز و نمونه‌های کانفیگ:
scp docker-compose.prod.yml user@NEW_SERVER_IP:/opt/noya/
scp .env.production.example user@NEW_SERVER_IP:/opt/noya/.env
scp .env.mysql.example user@NEW_SERVER_IP:/opt/noya/.env.mysql
scp backend/.env.production.example user@NEW_SERVER_IP:/opt/noya/backend/.env
```
---
## ۴. بارگذاری ایمیج‌ها و تنظیم متغیرهای محیطی روی سرور
وارد سرور شوید و مراحل زیر را انجام دهید:
### ۴.۱. لود کردن ایمیج‌ها در Docker سرور
```bash
cd /opt/noya
docker load < noya-frontend.tar.gz
docker load < noya-backend.tar.gz
```
### ۴.۲. تنظیم فایل‌های محیطی محرمانه
فایل‌های `.env` را با مقادیر واقعی و امن ویرایش کنید:
1. **فایل دیتابیس (`/opt/noya/.env.mysql`):**
```ini
MYSQL_ROOT_PASSWORD=your_generated_secure_root_password_32char
MYSQL_DATABASE=noya_db
MYSQL_USER=noya_user
MYSQL_PASSWORD=your_generated_secure_user_password_32char
```
2. **فایل اصلی کامپوز (`/opt/noya/.env`):**
```ini
NOYA_WEB_PORT=38080
NOYA_IMAGE_TAG=latest
# در صورت پشتیبانی CPU از SSE4.2 می‌توانید mysql:8.0 بگذارید، در غیر این صورت mysql:5.7
NOYA_MYSQL_IMAGE=mysql:5.7
```
3. **فایل بک‌اند (`/opt/noya/backend/.env`):**
```ini
NODE_ENV=production
PORT=3001
HOST=0.0.0.0
# توجه به رمز عبور: اگر رمز حاوی کاراکترهای خاص است باید URL-encoded باشد.
# پیشنهاد: استفاده از رمز صرفاً الفبانومریک (A-Z, a-z, 0-9) با طول ۳۲ حرف.
DATABASE_URL=mysql://noya_user:your_generated_secure_user_password_32char@noya-mysql:3306/noya_db
LOG_LEVEL=info
CORS_ORIGIN=https://your-domain.com
TRUST_PROXY=true
AUTH_SESSION_TTL_HOURS=168
ADMIN_EMAILS=admin@your-domain.com
OPENAI_API_KEY=your_production_gapgpt_or_openai_api_key
OPENAI_BASE_URL=https://api.gapgpt.app/v1
OPENAI_MODEL=gpt-4o-mini
```
---
## ۵. راه‌اندازی سرویس‌ها
```bash
cd /opt/noya
docker compose -f docker-compose.prod.yml up -d
```
### بررسی وضعیت کانتینرها:
```bash
docker compose -f docker-compose.prod.yml ps
```
کانتینرهای زیر باید در حالت `Up` (و برای MySQL در وضعیت `healthy`) باشند:
- `noya-mysql`
- `noya-backend`
- `noya-frontend`
---
## ۶. تست و اعتبارسنجی پیش از تغییر DNS (Zero-Downtime Verification)
### ۶.۱. تست داخلی سرور (Localhost Health)
```bash
# بررسی سلامت وب فرانت‌اند
curl -I http://127.0.0.1:38080/health
# پاسخ باید HTTP 200 با متن healthy باشد
# بررسی سلامت بک‌اند و ارتباط موفق با MySQL (اجرای موفق مایگریشن‌ها و سید)
curl -s http://127.0.0.1:38080/api/backend/ready
# خروجی باید شامل status: "ready" و database: "ok" باشد:
# {"success":true,"data":{"status":"ready","checks":{"database":"ok"}}}
```
### ۶.۲. اتصال به Reverse Proxy موجود سرور
یک VirtualHost مستقل برای دامنه نویا به Nginx اصلی سرور اضافه کنید (مثلاً `/etc/nginx/sites-available/noya.conf`):
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:38080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
}
```
سپس فعال‌سازی و ریلود:
```bash
sudo ln -s /etc/nginx/sites-available/noya.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
### ۶.۳. تست با Override کلاینت (بدون دستکاری DNS)
در کامپیوتر خودتان، فایل hosts را باز کنید:
- ویندوز: `C:\Windows\System32\drivers\etc\hosts`
- لینوکس / مک: `/etc/hosts`
خط زیر را اضافه کنید:
```text
NEW_SERVER_IP your-domain.com
```
اکنون مرورگر خود را در حالت Private/Incognito باز کرده و آدرس `http://your-domain.com` را بررسی کنید:
1. ثبت نام کاربر تستی جدید.
2. ارسال پیام چت و دریافت پاسخ استریم هوش مصنوعی.
3. بررسی باز شدن جلسات آموزشی.
4. بررسی عدم بروز هرگونه خطای CORS یا دیتابیس در کنسول مرورگر (`F12`).
---
## ۷. مشاهده و بررسی لاگ‌ها (Log Inspection)
```bash
# لاگ زنده تمام سرویس‌های نویا
docker compose -f docker-compose.prod.yml logs -f --tail=100
# لاگ اختصاصی بک‌اند
docker compose -f docker-compose.prod.yml logs -f noya-backend
# لاگ اختصاصی دیتابیس
docker compose -f docker-compose.prod.yml logs -f noya-mysql
# لاگ اختصاصی فرانت‌اند
docker compose -f docker-compose.prod.yml logs -f noya-frontend
```
---
## ۸. فعال‌سازی نهایی دامنه (Final Cutover)
پس از تأیید کامل تست مرحله ۶:
1. صدور گواهی SSL (Let's Encrypt / Certbot) روی سرور جدید:
```bash
sudo certbot --nginx -d your-domain.com
```
2. حذف خط تستی اضافه شده به فایل `hosts` سیستم محلی شما.
3. ورود به پنل مدیریت دامنه (Cloudflare، ابرآروان یا کنترل‌پنل دامنه) و تغییر رکورد `A` به `NEW_SERVER_IP`.
---
## ۹. برنامه بازگشت اضطراری (Rollback Plan)
در صورت بروز هرگونه مشکل یا ناپایداری غیرمنتظره پس از اتصال:
1. **بازگشت فوری DNS:** رکورد `A` در پنل DNS به IP سرور قبلی بازگردانده شود (ظرف چند دقیقه اعمال می‌شود).
2. **توقف امن سرویس‌های نویا روی سرور جدید:**
```bash
cd /opt/noya
docker compose -f docker-compose.prod.yml stop
```
*(داده‌های ثبت‌شده در Volume `noya_mysql_data` بدون آسیب حفظ می‌مانند).*
3. **غیرفعال‌سازی کانفیگ در Reverse Proxy سرور جدید:**
```bash
sudo rm -f /etc/nginx/sites-enabled/noya.conf
sudo nginx -t && sudo systemctl reload nginx
```
با این کار، سرور جدید دقیقاً به وضعیت پاک و اولیه خود قبل از استقرار نویا بازمی‌گردد.
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
.env
.env.*
!.env.example
*.log
tmp-*
.tools
.backups
-58
View File
@@ -1,58 +0,0 @@
# Noya Backend Production Environment Variables (Template)
# این مقادیر نمونه هستند؛ مقادیر واقعی را روی سرور در فایل backend/.env تنظیم کنید.
# هشدار: هرگز اطلاعات محرمانه و کلیدهای اصلی را در Git قرار ندهید.
# ----------------------------------------------------
# Application Core
# ----------------------------------------------------
NODE_ENV=production
PORT=3001
HOST=0.0.0.0
API_VERSION=1.0.0
BUILD_TIMESTAMP=
# ----------------------------------------------------
# Database Connection (MySQL)
# ----------------------------------------------------
# قالب: mysql://USER:PASSWORD@noya-mysql:3306/DATABASE
# نکته بسیار مهم درباره کاراکترهای خاص در رمز عبور:
# اگر رمز عبور دارای کاراکترهایی مانند @, :, /, #, %, ?, & است،
# باید به صورت URL-encoded (درصد-کدگذاری شده) نوشته شود.
# به عنوان مثال: @ -> %40 و # -> %23 و / -> %2F
# توصیه بهتر: برای رمز عبور فقط از حروف انگلیسی و اعداد (A-Z, a-z, 0-9) با طول ۳۲ کاراکتر استفاده کنید تا نیازی به encode نباشد.
DATABASE_URL=mysql://noya_user:change_this_to_strong_user_password_32char@noya-mysql:3306/noya_db
# ----------------------------------------------------
# Logging
# ----------------------------------------------------
LOG_LEVEL=info
# ----------------------------------------------------
# Security & Proxy
# ----------------------------------------------------
# دامنه نهایی و جدید، بدون اسلش انتهایی (با کاما قابل تفکیک است)
CORS_ORIGIN=https://your-domain.com
TRUST_PROXY=true
# Rate Limiting
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=100
AUTH_RATE_LIMIT_MAX=20
# ----------------------------------------------------
# Authentication
# ----------------------------------------------------
# مدت اعتبار نشست ورود بر حسب ساعت (۱۶۸ ساعت = ۷ روز)
AUTH_SESSION_TTL_HOURS=168
# ایمیل‌های مجاز جهت دریافت خودکار نقش ادمین در اولین لاگین (با کاما تفکیک کنید)
ADMIN_EMAILS=admin@your-domain.com
# ----------------------------------------------------
# AI Service (GapGPT / OpenAI Compatible)
# ----------------------------------------------------
OPENAI_API_KEY=your_openai_or_gapgpt_api_key_here
OPENAI_BASE_URL=https://api.gapgpt.app/v1
OPENAI_MODEL=gpt-4o-mini
OPENAI_ORG_ID=
OPENAI_PROJECT_ID=
+2 -2
View File
@@ -33,7 +33,7 @@ npm run dev
API default: `http://localhost:3001`
PWA (CORS): `http://localhost:5173`
`prisma:seed` upserts the 11 course sessions and 9 assignment definitions. Server startup also upserts both as a safety net.
`prisma:seed` upserts the 11 course sessions. Server startup also upserts them as a safety net.
## Scripts
@@ -49,7 +49,7 @@ PWA (CORS): `http://localhost:5173`
| `npm run prisma:validate` | Validate schema |
| `npm run prisma:migrate:dev` | Dev migrations |
| `npm run prisma:migrate:deploy` | Apply migrations (prod) |
| `npm run prisma:seed` | Upsert 11 `course_sessions` + 9 assignment definitions |
| `npm run prisma:seed` | Upsert 11 `course_sessions` |
## Project Structure
+1 -2
View File
@@ -27,11 +27,10 @@ export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof ZodError) {
log.warn({ issues: err.issues }, 'validation error');
const firstMessage = err.issues[0]?.message?.trim();
return errorResponse(c, {
code: ERROR_CODES.VALIDATION_ERROR,
message: firstMessage || 'اطلاعات واردشده نامعتبر است',
message: 'Validation failed',
status: HTTP_STATUS.UNPROCESSABLE_ENTITY,
details: err.issues,
});
-120
View File
@@ -273,123 +273,3 @@ export async function generateAiResponse(
new ServiceUnavailableError('سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.')
);
}
export async function streamAiResponse(
messages: ChatCompletionMessage[],
onChunk: (delta: string) => Promise<void> | void,
signal?: AbortSignal,
): Promise<{ text: string; model: string; totalTokens: number | null; latencyMs: number }> {
if (!isAiConfigured()) {
throw new ServiceUnavailableError(
'سرویس هوش مصنوعی پیکربندی نشده است. OPENAI_API_KEY را در backend/.env تنظیم کنید.',
);
}
const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '');
const endpoint = `${baseUrl}/chat/completions`;
const started = Date.now();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
};
if (env.OPENAI_ORG_ID) {
headers['OpenAI-Organization'] = env.OPENAI_ORG_ID;
}
if (env.OPENAI_PROJECT_ID) {
headers['OpenAI-Project'] = env.OPENAI_PROJECT_ID;
}
const timeoutController = new AbortController();
const timeout = setTimeout(() => timeoutController.abort(), OPENAI_TIMEOUT_MS);
const abortListener = () => timeoutController.abort();
if (signal) {
signal.addEventListener('abort', abortListener, { once: true });
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: env.OPENAI_MODEL,
messages,
temperature: 0.3,
max_tokens: 2000,
stream: true,
}),
signal: timeoutController.signal,
});
if (!response.ok) {
const raw = await response.text();
const snippet = raw.slice(0, 300);
logger.warn({ status: response.status, body: snippet }, 'AI provider stream error');
throw statusToUserError(response.status, snippet);
}
if (!response.body) {
throw new ServiceUnavailableError('سرویس هوش مصنوعی پاسخ خالی برگرداند.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedText = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
if (trimmed === 'data: [DONE]') {
break;
}
if (trimmed.startsWith('data: ')) {
const dataStr = trimmed.slice(6);
try {
const data = JSON.parse(dataStr);
const delta = data.choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta.length > 0) {
accumulatedText += delta;
await onChunk(delta);
}
} catch {
// Ignore non-json or incomplete lines
}
}
}
}
} finally {
reader.releaseLock();
}
const latencyMs = Date.now() - started;
return {
text: accumulatedText,
model: env.OPENAI_MODEL,
totalTokens: null,
latencyMs,
};
} catch (err) {
if (signal?.aborted) {
throw err;
}
const classified = err instanceof ServiceUnavailableError ? err : classifyFetchError(err);
throw classified;
} finally {
clearTimeout(timeout);
if (signal) {
signal.removeEventListener('abort', abortListener);
}
}
}
@@ -485,7 +485,7 @@ export async function listAssignmentsForSession(userId: bigint, sessionOrder: nu
/** All session assignment slots (definitions 2–10) with optional user progress. */
export async function listAssignmentCatalog(userId: bigint) {
const seededCount = await seedAssignmentDefinitions();
await seedAssignmentDefinitions();
await backfillAssignmentsForCompletedSessions(userId);
const completedOrders = await getCompletedSessionOrderSet(userId);
@@ -494,14 +494,6 @@ export async function listAssignmentCatalog(userId: bigint) {
orderBy: [{ displayOrder: 'asc' }, { sessionOrder: 'asc' }],
});
if (definitions.length === 0) {
throw new ValidationError(
seededCount === 0
? 'تعاریف تکالیف در پایگاه‌داده موجود نیست. لطفاً migrate و seed را اجرا کنید.'
: 'بارگذاری فهرست تکالیف ناموفق بود',
);
}
const userRows = await prisma.userAssignment.findMany({
where: { userId },
include: {
@@ -514,35 +506,8 @@ export async function listAssignmentCatalog(userId: bigint) {
const byDefinitionId = new Map(userRows.map((r) => [r.definitionId.toString(), r]));
let activatedMissing = 0;
for (const definition of definitions) {
if (
isAssignmentUnlockedByCompletedSessions(definition.sessionOrder, completedOrders) &&
!byDefinitionId.has(definition.id.toString())
) {
activatedMissing += await activateAssignmentsForSession(userId, definition.sessionOrder, prisma, {
ensureSeeded: false,
});
}
}
const rows =
activatedMissing > 0
? await prisma.userAssignment.findMany({
where: { userId },
include: {
definition: true,
reports: { orderBy: { createdAt: 'desc' }, take: 1 },
dailyEntries: { orderBy: { entryDate: 'desc' } },
_count: { select: { reports: true, dailyEntries: true } },
},
})
: userRows;
const byId = new Map(rows.map((r) => [r.definitionId.toString(), r]));
return definitions.map((definition) => {
const ua = byId.get(definition.id.toString());
const ua = byDefinitionId.get(definition.id.toString());
const unlocked = isAssignmentUnlockedByCompletedSessions(
definition.sessionOrder,
completedOrders,
+4 -92
View File
@@ -1,5 +1,4 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { jsonValidator, queryValidator } from '../../shared/validation/index.js';
import { created, success } from '../../lib/response.js';
import { requireAuth } from '../../middleware/auth.js';
@@ -14,8 +13,6 @@ import {
listChatSessions,
sendChatMessage,
startChatSession,
streamChatMessage,
streamStartChatSession,
} from './chat.service.js';
export function createChatRoutes() {
@@ -48,100 +45,15 @@ export function createChatRoutes() {
async (c) => {
const authUser = c.get('authUser');
const { content } = c.req.valid('json');
const sessionId = BigInt(c.req.param('id'));
const isStream =
c.req.header('Accept')?.includes('text/event-stream') ||
c.req.query('stream') === 'true';
if (!isStream) {
const result = await sendChatMessage(authUser.id, sessionId, content);
return created(c, result);
}
return streamSSE(c, async (stream) => {
const controller = new AbortController();
stream.onAbort(() => {
controller.abort();
});
try {
const result = await streamChatMessage(
authUser.id,
sessionId,
content,
async (delta) => {
await stream.writeSSE({
event: 'chunk',
data: JSON.stringify({ text: delta }),
});
},
controller.signal,
);
await stream.writeSSE({
event: 'done',
data: JSON.stringify(result),
});
} catch (err) {
if (controller.signal.aborted) {
return;
}
const message = err instanceof Error ? err.message : 'خطا در ارتباط با هوش مصنوعی';
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message }),
});
}
});
const result = await sendChatMessage(authUser.id, BigInt(c.req.param('id')), content);
return created(c, result);
},
);
routes.post('/sessions/:id/start', requireAuth, async (c) => {
const authUser = c.get('authUser');
const sessionId = BigInt(c.req.param('id'));
const isStream =
c.req.header('Accept')?.includes('text/event-stream') ||
c.req.query('stream') === 'true';
if (!isStream) {
const result = await startChatSession(authUser.id, sessionId);
return success(c, result);
}
return streamSSE(c, async (stream) => {
const controller = new AbortController();
stream.onAbort(() => {
controller.abort();
});
try {
const result = await streamStartChatSession(
authUser.id,
sessionId,
async (delta) => {
await stream.writeSSE({
event: 'chunk',
data: JSON.stringify({ text: delta }),
});
},
controller.signal,
);
await stream.writeSSE({
event: 'done',
data: JSON.stringify(result),
});
} catch (err) {
if (controller.signal.aborted) {
return;
}
const message = err instanceof Error ? err.message : 'خطا در شروع جلسه گفتگو';
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message }),
});
}
});
const result = await startChatSession(authUser.id, BigInt(c.req.param('id')));
return success(c, result);
});
return routes;
+1 -297
View File
@@ -17,7 +17,7 @@ import {
import { assertHomeworkReportForSessionStart } from '../sessions/homework.service.js';
import { PROGRAM_SESSION_COUNT } from '../sessions/catalog.js';
import { buildCoachSystemPrompt, buildCoachSessionStartCue } from '../ai/coachPrompt.js';
import { generateAiResponse, streamAiResponse, getAiProviderLabel, isAiConfigured } from '../ai/openaiClient.js';
import { generateAiResponse, getAiProviderLabel, isAiConfigured } from '../ai/openaiClient.js';
import { processCoachAssistantResponse } from '../ai/sessionCompletion.js';
import { buildAiCoachSessionContext } from '../ai/sessionContext.js';
@@ -410,299 +410,3 @@ export async function sendChatMessage(userId: bigint, chatSessionId: bigint, con
throw err;
}
}
async function streamCoachTurn(params: {
userId: bigint;
chatSession: { id: bigint; sessionOrder: number };
hiddenUserContent?: string;
onVisibleChunk: (delta: string) => Promise<void> | void;
signal?: AbortSignal;
}) {
const coachOrder = await resolveEffectiveCoachOrder(params.userId, params.chatSession.sessionOrder);
const catalogSession = await requireCatalogSessionOrder(coachOrder);
const context = buildAiCoachSessionContext(catalogSession);
const history = await loadConversation(params.chatSession.id);
const alreadyCompleted = (await getCompletedOrders(params.userId)).includes(coachOrder);
const messages = [
{ role: 'system' as const, content: buildCoachSystemPrompt(context) },
...history.map((message) => ({
role: message.role as 'user' | 'assistant',
content: message.content,
})),
];
if (params.hiddenUserContent) {
messages.push({ role: 'user' as const, content: params.hiddenUserContent });
}
const userTurnCount = history.filter((message) => message.role === ChatRole.user).length;
const run = await prisma.aiAnalysisRun.create({
data: {
userId: params.userId,
chatSessionId: params.chatSession.id,
provider: getAiProviderLabel(),
model: 'pending',
status: AiRunStatus.queued,
},
});
if (!isAiConfigured()) {
await prisma.aiAnalysisRun.update({
where: { id: run.id },
data: {
status: AiRunStatus.failed,
model: 'unconfigured',
errorMessage: 'OPENAI_API_KEY is not set',
},
});
throw new ServiceUnavailableError(
'سرویس هوش مصنوعی پیکربندی نشده است. OPENAI_API_KEY را در backend/.env تنظیم کنید.',
);
}
await prisma.aiAnalysisRun.update({
where: { id: run.id },
data: { status: AiRunStatus.running },
});
const COACH_SIGNAL_PREFIX = '[COACH_SESSION_COMPLETE]';
let buffer = '';
let inSignalBlock = false;
let fullAccumulated = '';
const handleChunk = async (delta: string) => {
fullAccumulated += delta;
if (inSignalBlock) {
return;
}
buffer += delta;
while (buffer.length > 0) {
const openIdx = buffer.indexOf('[');
if (openIdx === -1) {
const textToEmit = buffer;
buffer = '';
await params.onVisibleChunk(textToEmit);
break;
}
if (openIdx > 0) {
const textBefore = buffer.slice(0, openIdx);
buffer = buffer.slice(openIdx);
await params.onVisibleChunk(textBefore);
}
// Buffer starts with '['
if (COACH_SIGNAL_PREFIX.startsWith(buffer)) {
if (buffer === COACH_SIGNAL_PREFIX) {
inSignalBlock = true;
buffer = '';
}
break;
} else {
const char = buffer.slice(0, 1);
buffer = buffer.slice(1);
await params.onVisibleChunk(char);
}
}
};
try {
const result = await streamAiResponse(messages, handleChunk, params.signal);
if (buffer.length > 0 && !inSignalBlock) {
await params.onVisibleChunk(buffer);
buffer = '';
}
const processed = processCoachAssistantResponse(result.text, context, {
userTurnCount,
alreadyCompleted,
});
const assistant = await prisma.chatMessage.create({
data: {
chatSessionId: params.chatSession.id,
role: ChatRole.assistant,
content: processed.displayContent,
metadata: processed.signal
? ({
completionSignal: processed.signal,
completionValidation: processed.validation,
} as unknown as Prisma.InputJsonValue)
: undefined,
},
});
await prisma.aiAnalysisRun.update({
where: { id: run.id },
data: {
status: AiRunStatus.succeeded,
model: result.model,
latencyMs: result.latencyMs,
totalTokens: result.totalTokens,
},
});
if (processed.validation?.valid && processed.signal && !alreadyCompleted) {
try {
await completeSession(
params.userId,
params.chatSession.sessionOrder,
processed.signal,
params.chatSession.id,
);
} catch (err) {
console.error('[chat] Failed to auto-complete session after valid coach signal:', err);
}
}
return {
message: serializeMessage(assistant),
completion: processed.validation?.valid
? { valid: true, signal: processed.signal }
: processed.signal
? {
valid: false,
errors:
processed.validation && 'errors' in processed.validation
? processed.validation.errors
: [],
}
: null,
};
} catch (err) {
if (params.signal?.aborted) {
if (fullAccumulated.trim()) {
const processed = processCoachAssistantResponse(fullAccumulated, context, {
userTurnCount,
alreadyCompleted,
});
const assistant = await prisma.chatMessage.create({
data: {
chatSessionId: params.chatSession.id,
role: ChatRole.assistant,
content: processed.displayContent,
},
});
await prisma.aiAnalysisRun.update({
where: { id: run.id },
data: {
status: AiRunStatus.failed,
errorMessage: 'Stream aborted by user',
},
});
return {
message: serializeMessage(assistant),
completion: null,
};
}
}
const message = err instanceof Error ? err.message : 'AI provider stream failed';
await prisma.aiAnalysisRun.update({
where: { id: run.id },
data: {
status: AiRunStatus.failed,
errorMessage: message.slice(0, 500),
},
});
if (err instanceof ServiceUnavailableError) {
throw err;
}
throw new ServiceUnavailableError(
'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.',
{ cause: message },
);
}
}
export async function streamChatMessage(
userId: bigint,
chatSessionId: bigint,
content: string,
onVisibleChunk: (delta: string) => Promise<void> | void,
signal?: AbortSignal,
) {
const trimmed = content.trim();
if (!trimmed) {
throw new ValidationError('Message content is required');
}
const session = await getOwnedChatSession(userId, chatSessionId);
await assertTrainingAccess(userId, session.sessionOrder);
const userMessage = await prisma.chatMessage.create({
data: {
chatSessionId: session.id,
role: ChatRole.user,
content: trimmed,
},
});
try {
const result = await streamCoachTurn({
userId,
chatSession: session,
onVisibleChunk,
signal,
});
return {
session: serializeChatSession(session),
userMessage: serializeMessage(userMessage),
assistantMessage: result.message,
completion: result.completion,
};
} catch (err) {
const assistantCount = await prisma.chatMessage.count({
where: {
chatSessionId: session.id,
role: ChatRole.assistant,
createdAt: { gte: userMessage.createdAt },
},
});
if (assistantCount === 0) {
await prisma.chatMessage.delete({ where: { id: userMessage.id } }).catch(() => undefined);
}
throw err;
}
}
export async function streamStartChatSession(
userId: bigint,
chatSessionId: bigint,
onVisibleChunk: (delta: string) => Promise<void> | void,
signal?: AbortSignal,
) {
const session = await getOwnedChatSession(userId, chatSessionId);
await assertTrainingAccess(userId, session.sessionOrder);
const existing = await prisma.chatMessage.count({ where: { chatSessionId: session.id } });
if (existing > 0) {
const list = await listChatMessages(userId, session.id);
return {
session: list.session,
messages: list.messages,
completion: null,
};
}
const result = await streamCoachTurn({
userId,
chatSession: session,
hiddenUserContent: buildCoachSessionStartCue(session.sessionOrder),
onVisibleChunk,
signal,
});
return {
session: serializeChatSession(session),
messages: [result.message],
completion: result.completion,
};
}
+10 -11
View File
@@ -2,22 +2,21 @@ import { z } from 'zod';
export const NAME_MAX_LENGTH = 100;
/** Minimum allowed profile age (years). Keep in sync with frontend ProfileCompletionPage. */
export const PROFILE_AGE_MIN = 12;
export const profileNameSchema = z
.string({ required_error: 'نام الزامی است' })
.string({ required_error: 'Name is required' })
.trim()
.min(1, 'نام نمی‌تواند خالی باشد')
.max(NAME_MAX_LENGTH, `نام حداکثر ${NAME_MAX_LENGTH} کاراکتر باشد`);
.min(1, 'Name cannot be empty')
.max(NAME_MAX_LENGTH, `Name must be at most ${NAME_MAX_LENGTH} characters`);
export const PROFILE_AGE_MIN = 12;
export const profileAgeSchema = z
.number({
required_error: 'سن الزامی است',
invalid_type_error: 'سن باید یک عدد صحیح باشد',
required_error: 'Age is required',
invalid_type_error: 'Age must be an integer',
})
.int('سن باید یک عدد صحیح باشد')
.min(PROFILE_AGE_MIN, `حداقل سن ${PROFILE_AGE_MIN} سال است`);
.int('Age must be an integer')
.min(PROFILE_AGE_MIN, `Age must be at least ${PROFILE_AGE_MIN}`);
export const patchProfileSchema = z
.object({
@@ -25,7 +24,7 @@ export const patchProfileSchema = z
age: profileAgeSchema.optional(),
})
.refine((value) => value.name !== undefined || value.age !== undefined, {
message: 'نام یا سن لازم است',
message: 'At least one of name or age is required',
});
export type PatchProfileInput = z.infer<typeof patchProfileSchema>;
+1 -1
View File
@@ -49,7 +49,7 @@ export async function updateOwnProfile(
if (!existing.profile) {
if (input.name === undefined || input.age === undefined) {
throw new ValidationError('برای تکمیل مشخصات، نام و سن هر دو لازم است');
throw new ValidationError('Name and age are both required to create a profile');
}
await prisma.userProfile.create({
-5
View File
@@ -16,13 +16,8 @@ async function startServer() {
await connectPrisma();
logger.info('database connection established');
const { seedCourseSessions } = await import('./modules/sessions/sessions.service.js');
const { seedAssignmentDefinitions } = await import(
'./modules/assignments/assignments.service.js'
);
await seedCourseSessions();
logger.info('course sessions seeded');
const assignmentCount = await seedAssignmentDefinitions();
logger.info({ assignmentCount }, 'assignment definitions seeded');
} catch (err) {
logger.warn({ err }, 'database connection failed at startup; /ready will report unavailable');
}
+2 -2
View File
@@ -1,8 +1,8 @@
export const APP_NAME = 'Novya API';
export const APP_DESCRIPTION = 'Novya learning platform backend API';
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
export const SESSION_UNLOCK_INTERVAL_MS = 0;
/** 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 const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 20;
+34 -26
View File
@@ -143,7 +143,14 @@ describe('assignments API', () => {
});
assert.equal((list.body.data?.assignments as unknown[]).length, 1);
// Session 3 is unlocked immediately (no 24h waiting period)
// Backdate session 2 completion so session 3 unlocks (24h gate for order >= 3)
await prisma.sessionProgress.update({
where: {
userId_sessionOrder: { userId: owner.user.id, sessionOrder: 2 },
},
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
// Incomplete homework must NOT block starting session 3
const chat3 = await requestJson(app, '/chat/sessions', {
method: 'POST',
@@ -356,8 +363,12 @@ describe('assignments API', () => {
await openChatAndComplete(token, 1);
await openChatAndComplete(token, 2);
// Unlock sessions 3–10 sequentially without cooldown delays
// Unlock sessions 3–10 by backdating previous completions as we go
for (let order = 3; order <= 10; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
await openChatAndComplete(token, order);
}
@@ -461,13 +472,13 @@ describe('assignments API', () => {
await openChatAndComplete(token, 2);
// With waiting period removed, no cooldown is set
// completeSession helper only asserts 200 — fetch progress for cooldown fields
const progress = await requestJson(app, '/sessions/progress', {
headers: authHeader(token),
});
assert.equal(progress.status, 200);
assert.equal(progress.body.data?.waitingForSessionOrder, null);
assert.equal(progress.body.data?.nextSessionUnlocksAt, null);
assert.ok(progress.body.data?.waitingForSessionOrder === 3);
assert.ok(progress.body.data?.nextSessionUnlocksAt);
// Re-complete to read unlockedAssignments payload from complete endpoint
const redo = await requestJson(app, '/sessions/2/complete', {
@@ -497,7 +508,7 @@ describe('assignments API', () => {
assert.equal(joy.lockReason, null);
assert.ok(joy.userAssignment?.id);
// Session 3 is unlocked immediately (no 24h wait)
// Session 3 still locked by cooldown — must NOT block assignment 1
const sessions = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
@@ -507,19 +518,18 @@ describe('assignments API', () => {
unlocksAt?: string | null;
}>;
const s3 = sessionList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, true);
assert.equal(s3.unlocksAt, null);
assert.equal(s3.unlocked, false);
assert.ok(s3.unlocksAt);
// Prerequisites still hold: Session 4 is NOT unlocked yet
const s4 = sessionList.find((s) => s.order === 4)!;
assert.equal(s4.unlocked, false);
// Complete session 3 immediately without any time delay
await openChatAndComplete(token, 3);
const sessionsAfter3 = await requestJson(app, '/sessions', {
// Opening cooldown (simulate later unlock of session 3) must not change assignment-1 state
await prisma.sessionProgress.update({
where: { userId_sessionOrder: { userId: user.id, sessionOrder: 2 } },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
const sessionsAfter = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const s3After = (sessionsAfter3.body.data as typeof sessionList).find((s) => s.order === 3)!;
const s3After = (sessionsAfter.body.data as typeof sessionList).find((s) => s.order === 3)!;
assert.equal(s3After.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
@@ -529,18 +539,18 @@ describe('assignments API', () => {
const joyAfter = items.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joyAfter.unlocked, true);
assert.equal(joyAfter.userAssignment?.id, joy.userAssignment?.id);
// Assignment 2 (meaning) is now unlocked immediately on session 3 complete
const meaningAfter3 = items.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAfter3.unlocked, true);
assert.equal(
items.filter((i) => i.unlocked).length,
2,
'unlocking session 3 must not unlock later assignments (session 4+)',
1,
'unlocking session 3 must not unlock later assignments',
);
// Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6
for (let order = 4; order <= 6; order += 1) {
for (let order = 3; order <= 6; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
await openChatAndComplete(token, order);
}
@@ -558,9 +568,7 @@ describe('assignments API', () => {
headers: authHeader(token),
});
const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!;
assert.equal(s7.unlocked, true, 'session 7 unlocks immediately after session 6 completion');
const s8 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 8)!;
assert.equal(s8.unlocked, false, 'session 8 remains locked until session 7 is completed');
assert.equal(s7.unlocked, false, 'session 7 may still be on cooldown');
assert.equal(
items.find((i) => i.definition.sessionOrder === 7)?.unlocked,
false,
-230
View File
@@ -1,230 +0,0 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { prisma } from '../src/lib/prisma.js';
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
import { seedCourseSessions } from '../src/modules/sessions/sessions.service.js';
import {
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
function pcqAnswers(value: number) {
return PCQ_QUESTION_IDS.map((questionId) => ({
questionId,
selectedValue: String(value),
}));
}
async function registerUser(suffix: string) {
const email = `stream-${suffix}-${Date.now()}@example.com`;
const { status, body } = await requestJson(app, '/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password: 'password12' }),
});
assert.equal(status, 201);
const token = body.data?.sessionToken as string;
const externalId = (body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
createdUserIds.push(user.id);
return { token, user };
}
async function completePreTest(token: string) {
const pre = await requestJson(app, '/assessments/attempts', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
});
assert.equal(pre.status, 201);
}
describe('chat SSE progressive streaming', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
if (dbAvailable) {
await seedCourseSessions();
}
});
after(async () => {
if (dbAvailable && createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
});
it('preserves non-streaming fallback when Accept is not text/event-stream', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('non-stream');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const res = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ content: 'سلام' }),
});
assert.equal(res.status, 201);
assert.ok(res.body.data?.userMessage);
assert.ok(res.body.data?.assistantMessage);
});
it('delivers progressive chunks via SSE without leaking [COACH_SESSION_COMPLETE] control markers', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('sse-delivery');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const response = await app.request(`/chat/sessions/${chatId}/messages`, {
method: 'POST',
headers: {
...authHeader(token),
Accept: 'text/event-stream',
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: 'سلام' }),
});
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
const reader = response.body?.getReader();
assert.ok(reader, 'Response body must be readable stream');
const decoder = new TextDecoder();
let buffer = '';
const events: Array<{ event: string; data: any }> = [];
let receivedFirstChunk = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() || '';
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.split('\n');
let eventName = 'message';
let dataStr = '';
for (const line of lines) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr = line.slice(5).trim();
}
if (dataStr) {
try {
const parsed = JSON.parse(dataStr);
events.push({ event: eventName, data: parsed });
if (eventName === 'chunk') {
receivedFirstChunk = true;
}
} catch {
events.push({ event: eventName, data: dataStr });
}
}
}
}
assert.ok(receivedFirstChunk, 'Must have received at least one chunk before stream completion');
const chunkEvents = events.filter((e) => e.event === 'chunk');
assert.ok(chunkEvents.length > 0, 'Must have delivered chunk events');
// Verify control marker never leaks in visible chunks
for (const chunk of chunkEvents) {
assert.doesNotMatch(
chunk.data.text || '',
/\[COACH_SESSION_COMPLETE\]/,
'Visible chunk must NEVER contain [COACH_SESSION_COMPLETE]',
);
}
const doneEvent = events.find((e) => e.event === 'done');
assert.ok(doneEvent, 'Must emit done event');
assert.ok(doneEvent.data.assistantMessage, 'Done event must contain assistantMessage');
assert.ok(doneEvent.data.assistantMessage.content, 'Assistant content must be non-empty');
// Verify DB consistency
const dbMessage = await prisma.chatMessage.findUnique({
where: { id: BigInt(doneEvent.data.assistantMessage.id) },
});
assert.ok(dbMessage, 'Message must be saved in database');
assert.equal(
dbMessage.content,
doneEvent.data.assistantMessage.content,
'Database content must match done event content',
);
assert.doesNotMatch(
dbMessage.content,
/\[COACH_SESSION_COMPLETE\]/,
'Database content must not contain raw control markers',
);
});
it('supports stream start on /chat/sessions/:id/start', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('sse-start');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const response = await app.request(`/chat/sessions/${chatId}/start`, {
method: 'POST',
headers: {
...authHeader(token),
Accept: 'text/event-stream',
},
});
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
const reader = response.body?.getReader();
if (reader) {
while (true) {
const { done } = await reader.read();
if (done) break;
}
}
});
});
-1
View File
@@ -109,7 +109,6 @@ describe('profile API', () => {
body: JSON.stringify({ name: 'Sara', age: 11 }),
});
assert.equal(underage.status, 422);
assert.match(String(underage.body.error?.message ?? ''), /حداقل سن 12/);
const teenOk = await requestJson(app, '/users/me/profile', {
method: 'PATCH',
-263
View File
@@ -1,263 +0,0 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { prisma } from '../src/lib/prisma.js';
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
import { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js';
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js';
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js';
import { seedCourseSessions } from '../src/modules/sessions/seedCatalog.js';
import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.service.js';
import {
getNextLockedSessionUnlockTime,
getPreviousSessionUnlockTime,
isSessionUnlockedForProgress,
sessionUsesUnlockInterval,
} from '../src/modules/sessions/unlock.js';
import {
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
function pcqAnswers(value: number) {
return PCQ_QUESTION_IDS.map((questionId) => ({
questionId,
selectedValue: String(value),
}));
}
async function registerUser(suffix: string) {
const email = `session-unlock-${suffix}-${Date.now()}@example.com`;
const { status, body } = await requestJson(app, '/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password: 'password12' }),
});
assert.equal(status, 201);
const token = body.data?.sessionToken as string;
const externalId = (body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
createdUserIds.push(user.id);
return { token, user };
}
import { ChatRole } from '@prisma/client';
async function completePreTest(token: string) {
const res = await requestJson(app, '/assessments/attempts', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
});
assert.equal(res.status, 201);
}
async function openChatAndComplete(token: string, order: number) {
const chat = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: order }),
});
assert.equal(chat.status, 201);
const chatId = (chat.body.data as { id: string }).id;
await prisma.chatMessage.createMany({
data: [
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` },
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` },
],
});
const session = getSessionByOrder(order)!;
const complete = await requestJson(app, `/sessions/${order}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(session)),
chatSessionId: chatId,
}),
});
assert.equal(complete.status, 200);
return { chatId };
}
describe('session unlock logic and waiting period removal', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
if (dbAvailable) {
await seedCourseSessions();
await seedAssignmentDefinitions();
}
});
after(async () => {
if (dbAvailable && createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
});
it('unit: unlock interval functions confirm 0 waiting period', () => {
// 1. sessionUsesUnlockInterval returns false for all session orders
for (let order = 1; order <= PROGRAM_SESSION_COUNT; order++) {
assert.equal(
sessionUsesUnlockInterval(order),
false,
`session ${order} should not use unlock interval`,
);
}
const completionTimes = new Map<number, Date>();
completionTimes.set(1, new Date());
completionTimes.set(2, new Date());
// 2. getPreviousSessionUnlockTime returns null (no waiting delay)
assert.equal(getPreviousSessionUnlockTime(3, completionTimes), null);
// 3. isSessionUnlockedForProgress unlocks session 3 immediately when 1 and 2 are complete
assert.equal(
isSessionUnlockedForProgress(3, [1, 2], true, false, completionTimes),
true,
'session 3 should unlock immediately after sessions 1 and 2 are complete',
);
// 4. Prerequisites still block: session 4 is NOT unlocked when only 1 and 2 are complete
assert.equal(
isSessionUnlockedForProgress(4, [1, 2], true, false, completionTimes),
false,
'session 4 must remain locked when session 3 is not complete',
);
// 5. canOpenTraining prerequisite still blocks all sessions
assert.equal(
isSessionUnlockedForProgress(1, [], false, false, completionTimes),
false,
'session 1 must be locked if canOpenTraining is false',
);
// 6. getNextLockedSessionUnlockTime returns null when next eligible session has no cooldown
assert.equal(
getNextLockedSessionUnlockTime([1, 2], completionTimes),
null,
'next eligible session should have no locked cooldown delay',
);
});
it('integration: completes sessions sequentially without delays; preserves assignments and prerequisites', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const participantA = await registerUser('part-a');
const participantB = await registerUser('part-b');
// Participant A takes pre-test
await completePreTest(participantA.token);
// Participant B has NOT taken pre-test — training is locked
const bSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantB.token),
});
const bList = bSessions.body.data as Array<{ order: number; unlocked: boolean }>;
assert.ok(bList.every((s) => !s.unlocked), 'all sessions locked for participant B without pre-test');
// Participant A: session 1 is unlocked initially, session 2-11 are locked
let aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
let aList = aSessions.body.data as Array<{ order: number; unlocked: boolean; completed: boolean; unlocksAt: string | null }>;
assert.equal(aList[0]?.unlocked, true);
assert.equal(aList[1]?.unlocked, false);
assert.equal(aList[2]?.unlocked, false);
// Complete session 1
await openChatAndComplete(participantA.token, 1);
// Session 2 is now unlocked immediately
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
assert.equal(aList[0]?.completed, true);
assert.equal(aList[1]?.unlocked, true);
assert.equal(aList[2]?.unlocked, false);
// Complete session 2
await openChatAndComplete(participantA.token, 2);
// Progress endpoint shows NO cooldown waiting
const aProgress = await requestJson(app, '/sessions/progress', {
headers: authHeader(participantA.token),
});
assert.equal(aProgress.status, 200);
assert.equal(aProgress.body.data?.waitingForSessionOrder, null);
assert.equal(aProgress.body.data?.nextSessionUnlocksAt, null);
// Session 3 is unlocked IMMEDIATELY (no 24h waiting period!)
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
const s3 = aList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, true, 'session 3 must be unlocked immediately');
assert.equal(s3.unlocksAt, null, 'session 3 unlocksAt must be null');
// Completing session 2 does NOT unlock session 4 or the rest of the program
const s4 = aList.find((s) => s.order === 4)!;
assert.equal(s4.unlocked, false, 'session 4 must remain locked');
const s11 = aList.find((s) => s.order === 11)!;
assert.equal(s11.unlocked, false, 'session 11 must remain locked');
// Associated assignment for session 2 (joy-activities) is unlocked immediately
const aCatalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(participantA.token),
});
const catalogItems = aCatalog.body.data?.catalog as Array<{
unlocked: boolean;
definition: { key: string; sessionOrder: number };
}>;
const joyAssignment = catalogItems.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joyAssignment.unlocked, true, 'joy assignment unlocked after session 2 complete');
// Assignment for session 3 (meaning-purpose) is STILL locked because session 3 is not yet completed
const meaningAssignment = catalogItems.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAssignment.unlocked, false, 'meaning assignment remains locked until session 3 is completed');
// Participant B cannot access participant A's assignments
const bAssignments = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(participantB.token),
});
assert.equal(bAssignments.status, 200);
assert.equal((bAssignments.body.data?.assignments as unknown[]).length, 0, 'participant B sees no assignments');
// Participant A immediately accesses and completes session 3 without any delay
await openChatAndComplete(participantA.token, 3);
// Associated assignment for session 3 is now unlocked immediately
const aCatalogAfter3 = await requestJson(app, '/assignments/catalog', {
headers: authHeader(participantA.token),
});
const itemsAfter3 = aCatalogAfter3.body.data?.catalog as typeof catalogItems;
const meaningAfter3 = itemsAfter3.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAfter3.unlocked, true, 'meaning assignment unlocks immediately after session 3 completion');
// Session 4 is now unlocked immediately
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
const s4After3 = aList.find((s) => s.order === 4)!;
assert.equal(s4After3.unlocked, true, 'session 4 unlocks immediately after session 3 completion');
// Completed sessions 1, 2, 3 remain completed
assert.equal(aList.find((s) => s.order === 1)!.completed, true);
assert.equal(aList.find((s) => s.order === 2)!.completed, true);
assert.equal(aList.find((s) => s.order === 3)!.completed, true);
});
});
+4 -8
View File
@@ -17,14 +17,10 @@ const globalForTestDb = globalThis as unknown as {
};
function migrateDeploy(): void {
try {
execSync('npx prisma generate --schema=./prisma/schema.prisma', {
stdio: 'inherit',
env: process.env,
});
} catch {
// Ignore file-lock error if another process (e.g. dev server) holds query engine
}
execSync('npx prisma generate --schema=./prisma/schema.prisma', {
stdio: 'inherit',
env: process.env,
});
execSync('npx prisma migrate deploy --schema=./prisma/schema.prisma', {
stdio: 'inherit',
env: process.env,
-83
View File
@@ -1,83 +0,0 @@
name: noya
networks:
noya_internal_net:
name: noya_internal_net
driver: bridge
volumes:
noya_mysql_data:
name: noya_mysql_data
services:
noya-mysql:
image: ${NOYA_MYSQL_IMAGE:-mysql:5.7}
container_name: noya-mysql
restart: unless-stopped
env_file:
- path: .env.mysql
required: false
volumes:
- noya_mysql_data:/var/lib/mysql
networks:
noya_internal_net:
aliases:
- mysql
- noya-mysql
# پورت دیتابیس هرگز روی هاست یا اینترنت باز نمی‌شود
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
noya-backend:
build:
context: ./backend
dockerfile: Dockerfile
args:
HTTP_PROXY: ${BUILD_HTTP_PROXY:-}
HTTPS_PROXY: ${BUILD_HTTPS_PROXY:-}
ALL_PROXY: ${BUILD_ALL_PROXY:-}
image: noya-backend:${NOYA_IMAGE_TAG:-latest}
container_name: noya-backend
restart: unless-stopped
env_file:
- path: ./backend/.env
required: false
environment:
- NODE_ENV=production
- PORT=3001
- HOST=0.0.0.0
- TRUST_PROXY=true
networks:
noya_internal_net:
aliases:
- backend
- noya-backend
depends_on:
noya-mysql:
condition: service_healthy
noya-frontend:
build:
context: .
dockerfile: Dockerfile
args:
HTTP_PROXY: ${BUILD_HTTP_PROXY:-}
HTTPS_PROXY: ${BUILD_HTTPS_PROXY:-}
ALL_PROXY: ${BUILD_ALL_PROXY:-}
image: noya-frontend:${NOYA_IMAGE_TAG:-latest}
container_name: noya-frontend
restart: unless-stopped
ports:
# پورت فقط روی 127.0.0.1 لوکال‌هاست سرور بایند می‌شود
- "127.0.0.1:${NOYA_WEB_PORT:-38080}:80"
networks:
noya_internal_net:
aliases:
- app
- noya-frontend
depends_on:
- noya-backend
+1 -1
View File
@@ -6,7 +6,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#e6eef6" />
<meta name="description" content="پلتفرم روان‌شناسی و سلامت روان نویا" />
<link rel="manifest" href="/manifest.webmanifest" />
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

-27
View File
@@ -1,27 +0,0 @@
# Noya PCQ-24 Result Illustrations Directory
این پوشه برای قرار دادن ۱۲ تصویر گرافیکی نتایج ابعاد سرمایه روان‌شناختی نویا (۳ تصویر برای هر یک از ۴ بعد) در نظر گرفته شده است:
## 1. خودکارآمدی (Self-efficacy)
- `self-efficacy-guardian.webp` — Guardian (نگهبان توانایی)
- `self-efficacy-problem-solver.webp` — Problem Solver (حل‌کنندهٔ مسئله)
- `self-efficacy-path-builder.webp` — Path Builder (سازندهٔ مسیر)
## 2. امیدواری (Hope)
- `hope-path-finder.webp` — Path Finder (کاشف مسیرهای تازه)
- `hope-goal-climber.webp` — Goal Climber (رهسپار قله)
- `hope-goal-grower.webp` — Goal Grower (پرورشدهندهٔ رؤیا)
## 3. تاب‌آوری (Resilience)
- `resilience-rise-again.webp` — Rise Again (دوباره روی پاهایم)
- `resilience-keep-going.webp` — Keep Going (ادامه دادن در سختی)
- `resilience-calm-after-storm.webp` — Calm After Storm (آرامش بعد از طوفان)
## 4. خوش‌بینی (Optimism)
- `optimism-see-the-good.webp` — See the Good (دیدن نیمهٔ روشن)
- `optimism-bright-future.webp` — Bright Future (نگاه به آیندهٔ روشن)
- `optimism-new-opportunity.webp` — New Opportunity (کشف فرصت تازه)
---
> مسیر وب دسترسی فایل‌ها در فرانت‌اند: `/images/results/<filename>.webp`
> در صورت نبود موقت فایل‌ها، سیستم به صورت خودکار آیکون و پس‌زمینه گرادیان هماهنگ با طراحی نویا را بدون خطا نمایش می‌دهد.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

-187
View File
@@ -1,187 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DIMENSION_CONFIGS,
DIMENSION_ORDER,
getScoreTier,
getFeedbackForScore,
getDescriptiveTitleForScore,
} from '../src/config/resultImages';
describe('PCQ-24 Result Logic & 12 Feedback Texts', () => {
it('has exactly 4 dimensions in correct order', () => {
assert.deepEqual(DIMENSION_ORDER, ['efficacy', 'hope', 'resilience', 'optimism']);
});
it('correctly maps boundary scores to tiers', () => {
// 6 to 15 is Category 1 (low)
assert.equal(getScoreTier(6), 'low');
assert.equal(getScoreTier(10), 'low');
assert.equal(getScoreTier(15), 'low');
// 16 to 25 is Category 2 (mid)
assert.equal(getScoreTier(16), 'mid');
assert.equal(getScoreTier(20), 'mid');
assert.equal(getScoreTier(25), 'mid');
// 26 to 36 is Category 3 (high)
assert.equal(getScoreTier(26), 'high');
assert.equal(getScoreTier(30), 'high');
assert.equal(getScoreTier(36), 'high');
});
it('provides distinct, teen-friendly descriptive titles for all 3 tiers across all 4 dimensions', () => {
const titles = new Set<string>();
for (const dim of DIMENSION_ORDER) {
const lowTitle = getDescriptiveTitleForScore(dim, 12);
const midTitle = getDescriptiveTitleForScore(dim, 20);
const highTitle = getDescriptiveTitleForScore(dim, 30);
// Verify no forbidden judgmental labels
for (const t of [lowTitle, midTitle, highTitle]) {
assert.ok(t.length > 5, `Title "${t}" too short`);
assert.ok(!t.includes('ضعیف'), `Title "${t}" contains forbidden word ضعیف`);
assert.ok(!t.includes('متوسط'), `Title "${t}" contains forbidden word متوسط`);
assert.ok(!t.includes('قوی'), `Title "${t}" contains forbidden word قوی`);
titles.add(t);
}
assert.notEqual(lowTitle, midTitle);
assert.notEqual(midTitle, highTitle);
}
assert.equal(titles.size, 12, 'Must have exactly 12 unique descriptive titles');
});
it('has non-empty distinct feedback for all 12 combinations', () => {
const allTexts = new Set<string>();
for (const dim of DIMENSION_ORDER) {
const config = DIMENSION_CONFIGS[dim];
assert.ok(config, `Missing config for ${dim}`);
const low = getFeedbackForScore(dim, 15);
const mid = getFeedbackForScore(dim, 20);
const high = getFeedbackForScore(dim, 26);
assert.ok(low.length > 20, `Low text for ${dim} is too short`);
assert.ok(mid.length > 20, `Mid text for ${dim} is too short`);
assert.ok(high.length > 20, `High text for ${dim} is too short`);
assert.notEqual(low, mid, `Low and Mid for ${dim} should differ`);
assert.notEqual(mid, high, `Mid and High for ${dim} should differ`);
allTexts.add(low);
allTexts.add(mid);
allTexts.add(high);
}
assert.equal(allTexts.size, 12, 'Must have exactly 12 unique feedback texts');
});
it('contains correct Persian half-spaces (ZWNJ) for key phrases like «جنبه‌های» and «نگاه فعلی‌ات»', () => {
const optimismMid = getFeedbackForScore('optimism', 20);
assert.ok(
optimismMid.includes('جنبه‌های'),
'Must contain «جنبه‌های» with proper ZWNJ (U+200C)'
);
assert.ok(
optimismMid.includes('نگاه فعلی‌ات'),
'Must contain «نگاه فعلی‌ات» with proper ZWNJ (U+200C)'
);
const resilienceMid = getFeedbackForScore('resilience', 20);
assert.ok(
resilienceMid.includes('جنبه‌های'),
'Must contain «جنبه‌های» in resilience mid feedback'
);
assert.ok(
resilienceMid.includes('تاب‌آوری'),
'Must contain «تاب‌آوری» with proper ZWNJ (U+200C)'
);
const efficacyLow = getFeedbackForScore('efficacy', 10);
assert.ok(
efficacyLow.includes('توانایی‌های'),
'Must contain «توانایی‌های» with proper ZWNJ (U+200C)'
);
const hopeLow = getFeedbackForScore('hope', 10);
assert.ok(
hopeLow.includes('هدف‌ها'),
'Must contain «هدف‌ها» with proper ZWNJ (U+200C)'
);
});
it('matches the exact specified Persian text for Self-Efficacy', () => {
assert.equal(
getFeedbackForScore('efficacy', 15),
'اعتماد به توانایی‌های خودت هنوز جای رشد داره. شاید بعضی وقت‌ها انجام کارهای تازه برات سخت به نظر برسه؛ این فقط بخشی از تصویر فعلی توئه و همه توانایی‌هایت رو مشخص نمی‌کنه.'
);
assert.equal(
getFeedbackForScore('efficacy', 20),
'به نظر می‌رسه در بعضی موقعیت‌ها به خودت اعتماد داری، اما گاهی هم درباره توانایی‌هایت تردید می‌کنی. این یعنی نگاهت به توانایی‌های خودت در موقعیت‌های مختلف می‌تونه متفاوت باشه.'
);
assert.equal(
getFeedbackForScore('efficacy', 28),
'یکی از ویژگی‌های برجسته تو، اعتماد به توانایی‌هایت برای انجام کارهاست. به نظر می‌رسه در پاسخ‌هایت، باور به توانایی خودت جایگاه پررنگی داره؛ هرچند این حس ممکنه در موقعیت‌های مختلف تغییر کنه.'
);
});
it('matches the exact specified Persian text for Hope', () => {
assert.equal(
getFeedbackForScore('hope', 10),
'پیدا کردن مسیر رسیدن به هدف‌ها هنوز می‌تونه بخش مهمی از تجربه تو باشه. این نتیجه فقط نگاه فعلی تو به هدف‌ها و راه‌های رسیدن به اون‌ها رو نشون می‌ده، نه چیزی که در آینده برات ممکنه یا غیرممکنه.'
);
assert.equal(
getFeedbackForScore('hope', 18),
'به نظر می‌رسه برای بعضی هدف‌هایت مسیر روشن‌تری داری، اما درباره بعضی هدف‌های دیگه هنوز در حال پیدا کردن راهت هستی. این کاملاً تصویری از نگاه فعلی تو به هدف‌ها و مسیرهای رسیدن به اون‌هاست.'
);
assert.equal(
getFeedbackForScore('hope', 32),
'امیدواری یکی از نقاط قوت برجسته تو در این آزمونه. به نظر می‌رسه پیدا کردن هدف‌ها و فکر کردن به راه‌های رسیدن به اون‌ها در پاسخ‌هایت جایگاه پررنگی داره.'
);
});
it('matches the exact specified Persian text for Resilience', () => {
assert.equal(
getFeedbackForScore('resilience', 12),
'کنار اومدن با سختی‌ها و برگشتن به مسیر، از بخش‌هایی است که هنوز می‌تونه در تو رشد کنه. این نمره به‌تنهایی نمی‌گه در شرایط سخت چطور رفتار می‌کنی؛ فقط تصویری از پاسخ‌های فعلی توئه.'
);
assert.equal(
getFeedbackForScore('resilience', 22),
'به نظر می‌رسه در بعضی شرایط می‌تونی با سختی‌ها کنار بیای، اما تجربه‌ات در موقعیت‌های مختلف یکسان نیست. این نتیجه نشون می‌ده تاب‌آوری تو در پاسخ‌های این آزمون، ترکیبی از جنبه‌های شکل‌گرفته و در حال رشده.'
);
assert.equal(
getFeedbackForScore('resilience', 30),
'تاب‌آوری یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن کنار اومدن با دشواری‌ها و ادامه دادن مسیر، در تصویر فعلی تو جایگاه پررنگی داره.'
);
});
it('matches the exact specified Persian text for Optimism', () => {
assert.equal(
getFeedbackForScore('optimism', 14),
'نگاهت به اتفاق‌ها و آینده هنوز ظرفیت رشد داره. این نمره به معنی منفی بودن نگاهت در همه موقعیت‌ها نیست؛ فقط نشون میده پاسخ‌های فعلی تو درباره خوش‌بینی چه تصویری دارن.'
);
assert.equal(
getFeedbackForScore('optimism', 24),
'گاهی ممکنه جنبه‌های امیدوارکننده اتفاق‌ها رو ببینی و گاهی هم نگاهت به شرایط متفاوت باشه. نتیجه تو تصویری از نحوه نگاه فعلی‌ات به اتفاق‌ها و آینده ارائه می‌ده.'
);
assert.equal(
getFeedbackForScore('optimism', 35),
'خوش‌بینی یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن نگاه امیدوارانه به اتفاق‌ها و آینده در تجربه فعلی تو جایگاه پررنگی داره.'
);
});
it('configures the 4 local image paths properly without remote dependencies', () => {
for (const dim of DIMENSION_ORDER) {
const config = DIMENSION_CONFIGS[dim];
assert.ok(config.path.startsWith('/images/components/'));
assert.ok(config.fileName.endsWith('.webp'));
assert.ok(!config.path.startsWith('http'));
assert.ok(config.titleFa.length > 0);
assert.ok(config.inviteText.length > 0);
}
});
});
@@ -1,327 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
DIMENSION_CONFIGS,
DIMENSION_ORDER,
type DimensionConfig,
getDescriptiveTitleForScore,
} from '@/config/resultImages';
import { DimensionCard } from './DimensionCard';
import { SmoothTextAppearance } from '@/components/motion';
import type { AssessmentSubmission } from '@/services/assessmentService';
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
import { cn } from '@/lib/utils';
import { MOTION_EASE } from '@/lib/motion';
export interface AssessmentResultViewProps {
phase: 'pre' | 'post';
currentSubmission: AssessmentSubmission;
baselineSubmission?: AssessmentSubmission | null;
onContinue?: () => void;
continueLabel?: string;
showActions?: boolean;
}
export function AssessmentResultView({
phase,
currentSubmission,
baselineSubmission,
onContinue,
continueLabel = 'مشاهده آموزشها و تمرینها',
showActions = true,
}: AssessmentResultViewProps) {
const navigate = useNavigate();
const isPre = phase === 'pre';
const scores = currentSubmission.scores;
const baselineScores = baselineSubmission?.scores;
// Active step: 0, 1, 2, 3 = dimension steps; 4 = final summary screen
const [currentStepIndex, setCurrentStepIndex] = useState<number>(0);
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
// Track revealed status for each dimension so it's preserved on back/forward
const [revealed, setRevealed] = useState<Record<DimensionName, boolean>>({
efficacy: false,
hope: false,
resilience: false,
optimism: false,
});
const totalScore = scores.total;
const maxTotalScore = 144;
const totalPercent = Math.round(
(Math.max(0, Math.min(maxTotalScore, totalScore)) / maxTotalScore) * 100
);
const hasComparison = Boolean(
!isPre && baselineScores && typeof baselineScores.total === 'number'
);
const totalDelta = hasComparison ? totalScore - (baselineScores?.total ?? 0) : 0;
const currentDimensionKey = DIMENSION_ORDER[currentStepIndex];
const isSummaryStep = currentStepIndex === 4;
const handleRevealCurrent = () => {
if (currentDimensionKey) {
setRevealed((prev) => ({ ...prev, [currentDimensionKey]: true }));
}
};
const handleNext = () => {
if (isTransitioning) return;
setIsTransitioning(true);
setCurrentStepIndex((prev) => Math.min(prev + 1, 4));
window.setTimeout(() => setIsTransitioning(false), 220);
};
const handlePrev = () => {
if (isTransitioning) return;
setIsTransitioning(true);
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
window.setTimeout(() => setIsTransitioning(false), 220);
};
const handleJumpToStep = (index: number) => {
if (isTransitioning) return;
const targetDimension = DIMENSION_ORDER[index];
if (
index < currentStepIndex ||
(index <= 3 && targetDimension && revealed[targetDimension]) ||
index === currentStepIndex
) {
setIsTransitioning(true);
setCurrentStepIndex(index);
window.setTimeout(() => setIsTransitioning(false), 220);
}
};
const handleFinalContinue = () => {
if (onContinue) {
onContinue();
} else if (isPre) {
navigate('/chat');
} else {
navigate('/course-status');
}
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[430px] flex-1 flex-col" dir="rtl">
{/* 1. Low-Height, Minimal 4-Segment Progress Indicator */}
<div className="shrink-0 space-y-1.5 px-1 pb-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{isSummaryStep ? 'جمع‌بندی نهایی' : `مؤلفه ${currentStepIndex + 1} از ۴`}
</span>
<span>
{isSummaryStep
? 'کارنامه نهایی'
: DIMENSION_CONFIGS[currentDimensionKey]?.titleFa}
</span>
</div>
{/* Clean, low-height segment bar */}
<div className="grid grid-cols-4 gap-1.5">
{DIMENSION_ORDER.map((dimKey, idx) => {
const isCompleted =
revealed[dimKey] && (currentStepIndex > idx || isSummaryStep);
const isCurrent = currentStepIndex === idx;
return (
<button
key={dimKey}
type="button"
onClick={() => handleJumpToStep(idx)}
disabled={!revealed[dimKey] && idx > currentStepIndex}
className={cn(
'h-1.5 rounded-full transition-all duration-300 focus:outline-none',
isCurrent
? 'bg-sky-500 dark:bg-sky-400'
: isCompleted
? 'bg-sky-500/50 hover:bg-sky-500 dark:bg-sky-400/50'
: 'bg-slate-200/80 dark:bg-slate-800'
)}
aria-label={DIMENSION_CONFIGS[dimKey]?.titleFa}
/>
);
})}
</div>
</div>
{/* 2. Main Content (Dimension Card OR Final Summary) */}
<div className="flex-1 min-h-0 flex flex-col">
<AnimatePresence mode="wait" initial={false}>
{!isSummaryStep && currentDimensionKey ? (
/* Step-by-Step Dimension Card */
<DimensionCard
key={currentDimensionKey}
config={DIMENSION_CONFIGS[currentDimensionKey]}
score={scores[currentDimensionKey]}
baselineScore={
hasComparison ? baselineScores?.[currentDimensionKey] : undefined
}
stepNumber={currentStepIndex + 1}
totalSteps={4}
isRevealed={revealed[currentDimensionKey]}
onReveal={handleRevealCurrent}
onNext={handleNext}
onPrev={currentStepIndex > 0 ? handlePrev : undefined}
isLastDimension={currentStepIndex === 3}
/>
) : (
/* Final Summary Screen */
<motion.div
key="final-summary"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.22, ease: MOTION_EASE }}
style={{ willChange: 'opacity, transform' }}
className="flex h-full min-h-0 flex-1 flex-col"
>
{/* Main Summary Glass Card */}
<div className="glass-panel flex h-full min-h-0 flex-1 flex-col justify-between overflow-y-auto space-y-4 rounded-3xl border border-white/50 p-4 sm:p-5 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
{/* Header Title */}
<div className="text-center sm:text-right">
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{isPre
? 'کارنامه اولیه سرمایه روان‌شناختی'
: 'کارنامه نهایی دوره نویا'}
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
خلاصه وضعیت چهار مؤلفه شما
</p>
</div>
{/* Total Score Section */}
<div className="space-y-1.5 rounded-2xl bg-sky-500/10 p-4 dark:bg-sky-500/15">
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-sky-800 dark:text-sky-300">
نمره کل آزمون:
</span>
<div className="flex items-baseline gap-1 font-bold tabular-nums">
<span className="text-2xl font-black text-sky-900 dark:text-sky-100 sm:text-3xl">
{totalScore.toLocaleString('fa-IR')}
</span>
<span className="text-xs text-muted-foreground">از ۱۴۴</span>
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<div
className="h-full rounded-full bg-neon-gradient transition-all duration-600"
style={{ width: `${totalPercent}%` }}
/>
</div>
{hasComparison && baselineScores && (
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>
پیش‌آزمون: {baselineScores.total.toLocaleString('fa-IR')} ➔
پس‌آزمون: {totalScore.toLocaleString('fa-IR')}
</span>
<span
className={
totalDelta > 0
? 'font-bold text-emerald-600 dark:text-emerald-400'
: totalDelta < 0
? 'font-bold text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
}
>
{totalDelta > 0
? `+${totalDelta.toLocaleString('fa-IR')} رشد کل`
: totalDelta < 0
? `${totalDelta.toLocaleString('fa-IR')} تغییر`
: 'بدون تغییر'}
</span>
</div>
)}
</div>
{/* 4-Dimension Comparative Bars (Simple & Readable) */}
<div className="space-y-2.5 pt-1">
{DIMENSION_ORDER.map((dimKey) => {
const config: DimensionConfig = DIMENSION_CONFIGS[dimKey];
const dimScore = scores[dimKey];
const dimPercent = Math.round((dimScore / 36) * 100);
return (
<div key={dimKey} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">
{config.titleFa}
</span>
<span className="text-[11px] text-muted-foreground">
{getDescriptiveTitleForScore(dimKey, dimScore)}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${dimPercent}%`,
backgroundColor: config.accentColor,
}}
/>
</div>
</div>
);
})}
</div>
{/* Non-diagnostic notice */}
<p className="text-center text-xs text-muted-foreground">
این نتیجه بازتاب پاسخ‌های فعلی شماست و تشخیص قطعی روان‌شناختی نیست.
</p>
{/* Mandatory Closing Text */}
<SmoothTextAppearance contentKey="final-summary-closing" distance={5} duration={0.22}>
<div className="rounded-2xl bg-white/50 p-4 text-xs leading-7 text-foreground/90 dark:bg-slate-800/40 sm:text-sm sm:leading-8">
<p className="whitespace-pre-line text-body">
حالا تصویر روشن‌تری از چهار مؤلفه سرمایه روان‌شناختی خودت داری.
{'\n\n'}
این نتیجه، نقطه شروعیه برای شناخت بهتر خودت؛ نه تعریف کامل شخصیت تو.
{'\n\n'}
برای آشنایی بیشتر با این مؤلفه‌ها و تقویت اون‌ها، پیشنهاد می‌کنیم آموزش‌های نویا رو ببینی و تمرین‌های مرتبط رو انجام بدی.
</p>
</div>
</SmoothTextAppearance>
{/* Single Prominent Button */}
{showActions && (
<div className="space-y-2 pt-1">
<Button
type="button"
variant="neon"
size="lg"
onClick={handleFinalContinue}
className="w-full gap-2 font-bold shadow-md"
>
<span>{continueLabel}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* Subtle Secondary Back Control */}
<div className="text-center">
<button
type="button"
onClick={() => setCurrentStepIndex(0)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
مرور دوباره مؤلفه‌ها
</button>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
-271
View File
@@ -1,271 +0,0 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, Zap } from 'lucide-react';
import {
type DimensionConfig,
getFeedbackForScore,
getDescriptiveTitleForScore,
} from '@/config/resultImages';
import { Button } from '@/components/ui/button';
import { SmoothTextAppearance } from '@/components/motion';
import { MOTION_EASE } from '@/lib/motion';
export interface DimensionCardProps {
config: DimensionConfig;
score: number;
baselineScore?: number | null;
stepNumber: number; // 1 to 4
totalSteps?: number; // default 4
isRevealed: boolean;
onReveal: () => void;
onNext: () => void;
onPrev?: () => void;
isLastDimension?: boolean;
}
export function DimensionCard({
config,
score,
baselineScore,
isRevealed,
onReveal,
onNext,
onPrev,
isLastDimension = false,
}: DimensionCardProps) {
const [imgSrc, setImgSrc] = useState<string>(config.path);
const [hasImageError, setHasImageError] = useState(false);
// Sync image source when dimension changes
useEffect(() => {
setImgSrc(config.path);
setHasImageError(false);
}, [config.path]);
// Range for PCQ-24 dimension is 6 to 36
const minScore = 6;
const maxScore = 36;
const clampedScore = Math.max(minScore, Math.min(maxScore, score));
// Position percentage along the 6-36 possible span
const positionPercent = Math.round(
((clampedScore - minScore) / (maxScore - minScore)) * 100
);
const hasComparison =
typeof baselineScore === 'number' && !Number.isNaN(baselineScore);
const delta = hasComparison ? score - baselineScore : 0;
const descriptiveTitle = getDescriptiveTitleForScore(config.dimension, score);
const feedbackText = getFeedbackForScore(config.dimension, score);
const handleImageError = () => {
if (imgSrc !== config.fallbackPath) {
setImgSrc(config.fallbackPath);
} else {
setHasImageError(true);
}
};
return (
<motion.div
key={config.dimension}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.22, ease: MOTION_EASE }}
style={{ willChange: 'opacity, transform' }}
className="relative flex h-full min-h-0 flex-1 flex-col justify-between rounded-3xl border border-white/45 bg-white/70 p-4 shadow-sm backdrop-blur-md transition-all sm:p-5 dark:border-white/10 dark:bg-slate-900/65 dark:backdrop-blur-md overflow-hidden"
dir="rtl"
>
{/* 1. Dimension Header (Strong Title & Distinct Tagline with standard vertical spacing) */}
<div className="shrink-0 text-center sm:text-right">
<h2 className="text-xl font-black tracking-tight text-foreground sm:text-2xl">
{config.titleFa}
</h2>
<p className="mt-1.5 text-xs font-normal text-muted-foreground/80 sm:text-sm">
{config.tagline}
</p>
</div>
{/* 2. Focused Large Illustration (Generous size, object-cover completely fitting the frame) */}
<div className="my-auto flex w-full flex-1 min-h-0 items-center justify-center py-2 sm:py-3">
<div className="relative mx-auto flex aspect-[1122/1402] h-full max-h-[30vh] min-h-[175px] w-auto max-w-[285px] sm:max-w-[315px] items-center justify-center overflow-hidden rounded-2xl border border-white/40 bg-white/25 shadow-sm backdrop-blur-xs dark:border-white/10 dark:bg-slate-800/35">
{!hasImageError ? (
<img
src={imgSrc}
alt={config.altText}
onError={handleImageError}
className="h-full w-full object-cover object-center"
loading="eager"
decoding="async"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center">
<Zap
className="h-8 w-8 opacity-70"
style={{ color: config.accentColor }}
aria-hidden
/>
<span className="text-xs font-semibold text-foreground">
{config.titleFa}
</span>
</div>
)}
</div>
</div>
{/* 3. Action / Result Area */}
<AnimatePresence mode="wait" initial={false}>
{!isRevealed ? (
/* Initial Unrevealed State */
<motion.div
key="unrevealed"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex flex-col items-center gap-3 pt-2 text-center shrink-0"
>
<p className="max-w-xs text-xs leading-5 text-muted-foreground sm:text-sm">
{config.inviteText}
</p>
{/* Single Prominent Button */}
<Button
type="button"
variant="neon"
size="lg"
onClick={onReveal}
className="h-11 sm:h-12 w-full font-bold shadow-md sm:max-w-sm"
>
نمایش نتیجه من
</Button>
{/* Subtle Secondary Back Control */}
{onPrev && (
<button
type="button"
onClick={onPrev}
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
>
بازگشت به مؤلفه قبلی
</button>
)}
</motion.div>
) : (
/* Revealed Result State */
<div
key="revealed"
className="flex flex-col shrink-0"
>
{/* 6. Simple Score Status Display (Unified Card: Title + Continuum + Raw Score) */}
<SmoothTextAppearance
contentKey={`score-status-${config.dimension}-${score}`}
distance={4}
duration={0.2}
>
<div className="rounded-2xl border border-sky-400/20 bg-sky-500/10 px-3.5 py-2.5 text-right dark:border-sky-400/10 dark:bg-sky-400/10">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-muted-foreground">وضعیت:</span>
<h3 className="text-xs font-bold text-foreground sm:text-sm">
{descriptiveTitle}
</h3>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground tabular-nums">
<span>نمره:</span>
<span className="font-bold text-foreground">
{score.toLocaleString('fa-IR')} از ۳۶
</span>
</div>
</div>
{/* Continuum Visual Range Bar */}
<div className="mt-2 space-y-1">
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${positionPercent}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="h-full rounded-full"
style={{
backgroundColor: config.accentColor,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground/75">
<span>نقطه شروع</span>
<span>در حال تقویت</span>
<span>نقطه اتکای پررنگ</span>
</div>
</div>
{/* Optional Baseline Delta Comparison */}
{hasComparison && (
<div className="mt-1.5 flex items-center justify-between border-t border-sky-400/15 pt-1.5 text-[10px] text-muted-foreground dark:border-sky-400/10">
<span>تغییر نسبت به پیش‌آزمون:</span>
<span
className={
delta > 0
? 'font-semibold text-emerald-600 dark:text-emerald-400'
: delta < 0
? 'font-semibold text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
}
>
{delta > 0
? `+${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
: delta < 0
? `${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
: 'بدون تغییر'}
</span>
</div>
)}
</div>
</SmoothTextAppearance>
{/* 7. Prepared Feedback Text with standard vertical spacing */}
<div className="mt-3.5 sm:mt-4 w-full">
<SmoothTextAppearance
contentKey={`feedback-${config.dimension}-${score}`}
distance={5}
duration={0.22}
>
<p className="text-xs leading-6 text-foreground/90 sm:text-sm sm:leading-7">
{feedbackText}
</p>
</SmoothTextAppearance>
</div>
{/* 8. Single Prominent Button with standard vertical spacing */}
<div className="mt-4 sm:mt-5">
<Button
type="button"
variant="neon"
size="lg"
onClick={onNext}
className="h-11 sm:h-12 w-full gap-2 font-bold shadow-md"
>
<span>{isLastDimension ? 'دیدن جمع‌بندی' : 'مؤلفه بعدی'}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
{/* 9. Subtle Secondary Back Control */}
{onPrev && (
<div className="mt-2 text-center">
<button
type="button"
onClick={onPrev}
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
>
بازگشت به مؤلفه قبلی
</button>
</div>
)}
</div>
)}
</AnimatePresence>
</motion.div>
);
}
+131 -433
View File
@@ -17,69 +17,27 @@ type MessageBubbleProps = {
onStreamComplete?: (messageId: string) => void;
};
/**
* Standardized typography scale & tokens for chat content.
* Follows Noya's design system & ChatGPT-like reading comfort.
*/
export const CHAT_TYPOGRAPHY = {
body: 'chat-prose break-words text-[15px] sm:text-base font-normal leading-[1.85] sm:leading-[1.9] text-foreground text-right',
h1: 'block w-full text-right text-[22px] sm:text-2xl font-bold leading-[1.45] text-foreground mt-6 mb-3 first:mt-1',
h2: 'block w-full text-right text-[19px] sm:text-[20px] font-bold leading-[1.5] text-foreground mt-5 mb-2.5 first:mt-1',
h3: 'block w-full text-right text-[17px] sm:text-[18px] font-bold leading-[1.5] text-foreground mt-4 mb-2 first:mt-1',
bold: 'font-bold text-foreground',
italic: 'italic text-foreground/90 font-normal',
subtext: 'text-xs sm:text-[13px] text-muted-foreground',
blockquote:
'border-r-[3px] border-sky-500/50 dark:border-sky-400/40 pr-3.5 pl-2.5 py-1.5 my-3 text-muted-foreground/95 bg-sky-500/5 dark:bg-sky-400/5 rounded-l-md text-[14.5px] sm:text-[15px] leading-[1.85] text-right',
codeInline:
'px-1.5 py-0.5 rounded-md bg-muted/80 text-foreground font-mono text-[13px] sm:text-sm border border-border/60 mx-1 dir-ltr inline-block align-baseline',
codeBlock:
'my-3 p-3.5 rounded-xl bg-slate-900 text-slate-100 dark:bg-slate-950/90 border border-white/10 font-mono text-xs sm:text-sm overflow-x-auto dir-ltr text-left leading-6',
hr: 'my-4 border-t border-border/60',
link: 'text-sky-600 dark:text-sky-400 font-medium underline underline-offset-4 hover:opacity-80 transition-opacity',
list: 'space-y-2.5 text-[15px] sm:text-base leading-[1.85] text-foreground my-2.5',
table: 'w-full border-collapse text-right text-sm leading-6 my-2',
tableHeader: 'border border-border bg-muted/60 px-3 py-2 font-bold text-foreground',
tableCell: 'border border-border px-3 py-2 text-foreground/90',
} as const;
export type ContentBlock =
type ContentBlock =
| { type: 'p'; text: string }
| { type: 'heading'; level: 1 | 2 | 3; text: string }
| {
type: 'list';
ordered: boolean;
items: { text: string; depth: number }[];
markers?: string[];
delimiters?: string[];
}
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'blockquote'; text: string }
| { type: 'codeblock'; language?: string; code: string }
| { type: 'hr' };
| { type: 'heading'; text: string }
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[] }
| { type: 'table'; headers: string[]; rows: string[][] };
const stripImageMarkdown = (text: string): string =>
text.replace(/!\[[^\]]*]\([^)]*\)/g, '').replace(/!\[[^\]]*]/g, '');
const cleanSystemArtifacts = (text: string): string =>
/** Hide leftover markdown/chat markers that must never appear on screen. */
const stripVisibleMarkupArtifacts = (text: string): string =>
text
.replace(/^#{1,6}\s+/gm, '')
.replace(/(^|\s)#{1,6}(?=\s|$)/gm, '$1')
.replace(/@[\w.\u0600-\u06FF-]*/g, '')
.replace(/```+/g, '')
.replace(/[ \t]{2,}/g, ' ');
const isSafeUrl = (url: string): boolean => {
const trimmed = url.trim().toLowerCase();
return (
trimmed.startsWith('https://') ||
trimmed.startsWith('http://') ||
trimmed.startsWith('/') ||
trimmed.startsWith('#') ||
trimmed.startsWith('mailto:')
);
};
/** Render inline markdown; during streaming hide incomplete ** / ` and apply bold early. */
/** Render inline markdown; during streaming hide incomplete ** and apply bold early. */
const renderInline = (text: string, isStreaming = false): ReactNode[] => {
const cleaned = cleanSystemArtifacts(stripImageMarkdown(text));
const cleaned = stripVisibleMarkupArtifacts(stripImageMarkdown(text));
if (!isStreaming) {
return renderInlineComplete(cleaned);
}
@@ -87,69 +45,16 @@ const renderInline = (text: string, isStreaming = false): ReactNode[] => {
};
const renderInlineComplete = (text: string): ReactNode[] => {
// Tokenize Links, Bold, Inline code, Italics, and plain text
const TOKEN_RE =
/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`|(?:\s|^)\*[^*\s][^*]*\*(?=\s|$)|(?:\s|^)_[^_\s][^_]*_(?=\s|$))/g;
const parts = text.split(TOKEN_RE);
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return parts.flatMap((part, index): ReactNode[] => {
if (!part) return [];
// 1. Link: [label](url)
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
const [, label = '', url = ''] = linkMatch;
if (isSafeUrl(url)) {
return [
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className={CHAT_TYPOGRAPHY.link}
>
{renderWithNums(label, `${index}-l`)}
</a>,
];
}
return renderWithNums(label, `${index}-l`);
}
// 2. Bold: **text**
const boldMatch = part.match(/^\*\*([^*]+)\*\*$/);
if (boldMatch) {
const bold = part.match(/^\*\*([^*]+)\*\*$/);
if (bold) {
return [
<strong key={index} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(boldMatch[1] ?? '', `${index}-b`)}
<strong key={index} className="font-semibold text-foreground">
{renderWithNums(bold[1] ?? '', `${index}-b`)}
</strong>,
];
}
// 3. Inline code: `code`
const codeMatch = part.match(/^`([^`]+)`$/);
if (codeMatch) {
return [
<code key={index} className={CHAT_TYPOGRAPHY.codeInline}>
{codeMatch[1]}
</code>,
];
}
// 4. Italic: *text* or _text_
const italicMatch =
part.match(/^(\s?)\*([^*]+)\*(\s?)$/) || part.match(/^(\s?)_([^_]+)_(\s?)$/);
if (italicMatch) {
const [, leading = '', inner = '', trailing = ''] = italicMatch;
return [
leading,
<em key={index} className={CHAT_TYPOGRAPHY.italic}>
{renderWithNums(inner, `${index}-i`)}
</em>,
trailing,
];
}
// 5. Plain text with localized number rendering
return renderWithNums(part, String(index));
});
};
@@ -160,75 +65,43 @@ const renderInlineStreaming = (text: string): ReactNode[] => {
let key = 0;
while (i < text.length) {
const openBold = text.indexOf('**', i);
const openCode = text.indexOf('`', i);
// Pick whichever marker appears earliest
if (openBold !== -1 && (openCode === -1 || openBold < openCode)) {
if (openBold > i) {
nodes.push(...renderWithNums(text.slice(i, openBold), String(key++)));
}
const closeBold = text.indexOf('**', openBold + 2);
if (closeBold === -1) {
// Pending bold chunk
const pending = text.slice(openBold + 2);
if (pending) {
nodes.push(
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(pending, `${key}-pending-b`)}
</strong>
);
}
break;
}
nodes.push(
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(text.slice(openBold + 2, closeBold), `${key}-b`)}
</strong>
);
i = closeBold + 2;
continue;
const open = text.indexOf('**', i);
if (open === -1) {
nodes.push(...renderWithNums(text.slice(i), String(key++)));
break;
}
if (openCode !== -1) {
if (openCode > i) {
nodes.push(...renderWithNums(text.slice(i, openCode), String(key++)));
}
const closeCode = text.indexOf('`', openCode + 1);
if (closeCode === -1) {
// Pending code chunk
const pending = text.slice(openCode + 1);
if (pending) {
nodes.push(
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
{pending}
</code>
);
}
break;
}
nodes.push(
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
{text.slice(openCode + 1, closeCode)}
</code>
);
i = closeCode + 1;
continue;
if (open > i) {
nodes.push(...renderWithNums(text.slice(i, open), String(key++)));
}
// Neither found, output remaining text
nodes.push(...renderWithNums(text.slice(i), String(key++)));
break;
const close = text.indexOf('**', open + 2);
if (close === -1) {
// Opening ** without closing yet — bold weight, no visible asterisks
const pending = text.slice(open + 2);
if (pending) {
nodes.push(
<strong key={key++} className="font-semibold text-foreground">
{renderWithNums(pending, `${key}-pending`)}
</strong>
);
}
break;
}
const boldContent = text.slice(open + 2, close);
nodes.push(
<strong key={key++} className="font-semibold text-foreground">
{renderWithNums(boldContent, `${key}-b`)}
</strong>
);
i = close + 2;
}
return nodes;
};
/** Keep digits opaque under RTL — isolate with dir=ltr and tabular numbers. */
/** Keep digits opaque under RTL justify — only wrap digits, leave text as plain nodes. */
const renderWithNums = (text: string, keyPrefix: string): ReactNode[] => {
const chunks = text.split(/(\d+|[۰-۹]+|[٠-٩]+)/g);
return chunks.flatMap((chunk, i): ReactNode[] => {
@@ -259,63 +132,37 @@ const isTableLine = (line: string): boolean =>
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
/** Western, Persian, or Arabic-Indic digits + . or ) */
const ORDERED_ITEM_RE = /^(\s*)((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))[.)]\s+(\S.*)$/;
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
const BULLET_ITEM_RE = /^(\s*)(?:[-•*])\s+(\S.*)$/;
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
const isBulletItem = (line: string): boolean => BULLET_ITEM_RE.test(line);
const parseOrderedItem = (
line: string
): { marker: string; delimiter: string; text: string; indent: number } | null => {
const parseOrderedItem = (line: string): { marker: string; text: string } | null => {
const match = line.match(ORDERED_ITEM_RE);
if (!match) return null;
const indentLen = match[1]?.length ?? 0;
return {
marker: match[2] ?? '',
delimiter: match[3] ?? '.',
text: match[4] ?? '',
indent: indentLen,
};
return { marker: match[1] ?? '', text: match[2] ?? '' };
};
const parseBulletItem = (line: string): { text: string; indent: number } | null => {
const match = line.match(BULLET_ITEM_RE);
if (!match) return null;
const indentLen = match[1]?.length ?? 0;
return {
text: match[2] ?? '',
indent: indentLen,
};
};
const stripListMarker = (line: string): string =>
line.replace(/^\s*(?:(?:\d+|[۰-۹]+|[٠-٩]+)[.)]|[-•*])\s+/, '');
const isHeadingLine = (line: string): boolean => /^#{1,6}\s+\S/.test(line.trim());
/** Markdown ## / # headings (stripped before display). */
const isHeadingLine = (line: string): boolean => /^#{1,6}\s*\S/.test(line.trim());
const parseHeadingLine = (line: string): { level: 1 | 2 | 3; text: string } | null => {
const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
if (!match) return null;
const rawLevel = match[1]?.length ?? 1;
const level = Math.min(rawLevel, 3) as 1 | 2 | 3;
return { level, text: match[2]?.trim() ?? '' };
};
const isBlockquoteLine = (line: string): boolean => /^\s*>\s*/.test(line);
const isCodeBlockStart = (line: string): boolean => /^\s*```/.test(line);
const isHrLine = (line: string): boolean => /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line);
const stripHeadingMarker = (line: string): string =>
stripVisibleMarkupArtifacts(line.trim().replace(/^#{1,6}\s*/, ''));
const parseBlocks = (text: string): ContentBlock[] => {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const blocks: ContentBlock[] = [];
let i = 0;
// Each non-empty line becomes its own paragraph (ChatGPT-like airy breaks).
const flushParagraph = (buffer: string[]) => {
const trimmed = buffer.map((l) => l.trim()).filter(Boolean).join('\n');
if (trimmed) {
blocks.push({ type: 'p', text: trimmed });
for (const line of buffer) {
const trimmed = line.trim();
if (trimmed) blocks.push({ type: 'p', text: trimmed });
}
};
@@ -327,53 +174,12 @@ const parseBlocks = (text: string): ContentBlock[] => {
continue;
}
// 1. Code Block (```lang)
if (isCodeBlockStart(line)) {
const lang = line.replace(/^\s*```/, '').trim();
const codeLines: string[] = [];
i += 1;
while (i < lines.length) {
const current = lines[i] ?? '';
if (/^\s*```/.test(current)) {
i += 1;
break;
}
codeLines.push(current);
i += 1;
}
blocks.push({ type: 'codeblock', language: lang, code: codeLines.join('\n') });
continue;
}
// 2. Headings (#, ##, ###)
if (isHeadingLine(line)) {
const parsed = parseHeadingLine(line);
if (parsed) {
blocks.push({ type: 'heading', level: parsed.level, text: parsed.text });
}
blocks.push({ type: 'heading', text: stripHeadingMarker(line) });
i += 1;
continue;
}
// 3. Horizontal Rule (---, ***)
if (isHrLine(line)) {
blocks.push({ type: 'hr' });
i += 1;
continue;
}
// 4. Blockquotes (> quote)
if (isBlockquoteLine(line)) {
const quoteLines: string[] = [];
while (i < lines.length && isBlockquoteLine(lines[i] ?? '')) {
quoteLines.push((lines[i] ?? '').replace(/^\s*>\s*/, ''));
i += 1;
}
blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
continue;
}
// 5. Tables
if (isTableLine(line)) {
const tableLines: string[] = [];
while (i < lines.length && isTableLine(lines[i] ?? '')) {
@@ -391,62 +197,32 @@ const parseBlocks = (text: string): ContentBlock[] => {
continue;
}
// 6. Ordered & Unordered Lists (with multi-line item continuation and nesting)
if (isOrderedItem(line) || isBulletItem(line)) {
const ordered = isOrderedItem(line);
const items: { text: string; depth: number }[] = [];
const items: string[] = [];
const markers: string[] = [];
const delimiters: string[] = [];
while (i < lines.length) {
const current = lines[i] ?? '';
if (!current.trim()) break;
const isCurrentOrdered = isOrderedItem(current);
const isCurrentBullet = isBulletItem(current);
if (ordered ? isCurrentOrdered : isCurrentBullet) {
if (ordered) {
const parsed = parseOrderedItem(current);
if (!parsed) break;
const depth = Math.min(Math.floor(parsed.indent / 2), 3);
markers.push(parsed.marker);
delimiters.push(parsed.delimiter);
items.push({ text: parsed.text, depth });
} else {
const parsed = parseBulletItem(current);
if (!parsed) break;
const depth = Math.min(Math.floor(parsed.indent / 2), 3);
items.push({ text: parsed.text, depth });
}
i += 1;
} else if (
items.length > 0 &&
!isHeadingLine(current) &&
!isTableLine(current) &&
!isBlockquoteLine(current) &&
!isCodeBlockStart(current) &&
!isHrLine(current) &&
!(ordered ? isCurrentBullet : isCurrentOrdered)
) {
// Multi-line item continuation: append with newline so it wraps on the text side
items[items.length - 1].text += '\n' + current.trim();
i += 1;
if (ordered ? !isOrderedItem(current) : !isBulletItem(current)) break;
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 {
break;
items.push(stripListMarker(current));
}
i += 1;
}
blocks.push(
ordered
? { type: 'list', ordered: true, items, markers, delimiters }
: { type: 'list', ordered: false, items }
ordered ? { type: 'list', ordered: true, items, markers } : { type: 'list', ordered: false, items }
);
continue;
}
// 7. Regular Paragraphs
const paragraphLines: string[] = [line];
const paragraph: string[] = [line];
i += 1;
while (i < lines.length) {
const current = lines[i] ?? '';
@@ -455,17 +231,14 @@ const parseBlocks = (text: string): ContentBlock[] => {
isHeadingLine(current) ||
isTableLine(current) ||
isOrderedItem(current) ||
isBulletItem(current) ||
isBlockquoteLine(current) ||
isCodeBlockStart(current) ||
isHrLine(current)
isBulletItem(current)
) {
break;
}
paragraphLines.push(current);
paragraph.push(current);
i += 1;
}
flushParagraph(paragraphLines);
flushParagraph(paragraph);
}
return blocks;
@@ -491,18 +264,18 @@ export const MessageBubble = ({
onStreamComplete?.(chatMessage.id);
}, [chatMessage.id, onStreamComplete]);
useStreamTypewriter({
const { displayedText, isStreaming } = useStreamTypewriter({
text: fullBody,
enabled: shouldStream,
onProgress: handleProgress,
onComplete: handleComplete,
});
const body = fullBody.trim();
const showCursor = Boolean(isAssistant && chatMessage.status === 'streaming');
const isActivelyTyping = Boolean(isAssistant && chatMessage.status === 'streaming');
const body = shouldStream ? displayedText : fullBody.trim();
const showCursor = shouldStream && isStreaming;
const isActivelyTyping = shouldStream && isStreaming;
// Parse structured blocks for rich, scannable reading
// Parse structure while streaming too (ChatGPT-like spacing as text appears).
const blocks = isAssistant
? body.trim()
? parseBlocks(body)
@@ -541,14 +314,14 @@ export const MessageBubble = ({
})}
</span>
</div>
<div className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}>
<div
className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}
>
{blocks.length > 0 || showCursor ? (
<div className={cn(isAssistant ? 'space-y-3.5' : '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;
const cursor = showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
const blockMotion = isActivelyTyping
? undefined
: {
@@ -557,120 +330,63 @@ export const MessageBubble = ({
transition: { duration: MOTION_DURATION.fast, ease: MOTION_EASE },
};
// 1. Lists (Ordered & Unordered)
if (block.type === 'list') {
const ListTag = block.ordered ? 'ol' : 'ul';
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<ListTag className={CHAT_TYPOGRAPHY.list}>
{block.items.map((item, itemIndex) => {
const isLastItem = isLast && itemIndex === block.items.length - 1;
const depthClass =
item.depth === 1
? 'mr-4 sm:mr-5'
: item.depth === 2
? 'mr-8 sm:mr-9'
: item.depth >= 3
? 'mr-12'
: '';
return (
<li
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
className={cn('chat-prose flex items-start', depthClass)}
>
{block.ordered ? (
<>
<span
className="chat-num shrink-0 font-bold select-none text-foreground/90 pl-2 leading-[1.85]"
dir="ltr"
>
<span dir="ltr">
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}
</span>
{block.delimiters?.[itemIndex] ?? '.'}
</span>
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
{renderInline(item.text, isActivelyTyping)}
{cursor && isLastItem ? cursor : null}
</div>
</>
) : (
<>
<span
className="shrink-0 select-none text-sky-500/80 dark:text-sky-400/80 text-base leading-[1.85] pl-2.5 font-bold"
aria-hidden
>
•
</span>
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
{renderInline(item.text, isActivelyTyping)}
{cursor && isLastItem ? cursor : null}
</div>
</>
)}
</li>
);
})}
<ListTag
className={cn(
'space-y-1.5 text-base leading-7 text-foreground',
block.ordered ? 'list-none pe-0 ps-0' : 'list-disc'
)}
>
{block.items.map((item, itemIndex) => (
<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>
</Wrapper>
);
}
// 2. Blockquote
if (block.type === 'blockquote') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<blockquote className={CHAT_TYPOGRAPHY.blockquote}>
{renderInline(block.text, isActivelyTyping)}
{cursor}
</blockquote>
</Wrapper>
);
}
// 3. Code Block
if (block.type === 'codeblock') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<div className="relative my-3 overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-sm dark:bg-slate-950">
{block.language && (
<div className="flex items-center justify-between border-b border-white/10 px-3.5 py-1.5 text-[11px] font-mono text-slate-400 dir-ltr">
<span>{block.language}</span>
</div>
)}
<pre className="p-3.5 overflow-x-auto text-xs sm:text-sm font-mono text-slate-100 dir-ltr text-left leading-6">
<code>{block.code}</code>
</pre>
</div>
</Wrapper>
);
}
// 4. Horizontal Rule
if (block.type === 'hr') {
return <hr key={`${chatMessage.id}-b-${index}`} className={CHAT_TYPOGRAPHY.hr} />;
}
// 5. Table
if (block.type === 'table') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper
key={`${chatMessage.id}-b-${index}`}
{...blockMotion}
className="overflow-x-auto rounded-lg border border-border/80 my-2.5"
className={isActivelyTyping ? undefined : 'overflow-x-auto'}
>
<table className={CHAT_TYPOGRAPHY.table}>
<table className="w-full border-collapse text-right text-sm leading-6">
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th
key={`${chatMessage.id}-th-${index}-${headerIndex}`}
className={CHAT_TYPOGRAPHY.tableHeader}
className="border border-border bg-muted/60 px-2 py-1.5 font-semibold"
>
{renderInline(header, false)}
</th>
@@ -679,14 +395,11 @@ export const MessageBubble = ({
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr
key={`${chatMessage.id}-tr-${index}-${rowIndex}`}
className="even:bg-muted/30"
>
<tr key={`${chatMessage.id}-tr-${index}-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td
key={`${chatMessage.id}-td-${index}-${rowIndex}-${cellIndex}`}
className={CHAT_TYPOGRAPHY.tableCell}
className="border border-border px-2 py-1.5"
>
{renderInline(cell, false)}
{cursor &&
@@ -704,49 +417,35 @@ export const MessageBubble = ({
);
}
// 6. Headings (H1, H2, H3)
if (block.type === 'heading') {
const Tag = block.level === 1 ? 'h1' : block.level === 2 ? 'h2' : 'h3';
const headingClass =
block.level === 1
? CHAT_TYPOGRAPHY.h1
: block.level === 2
? CHAT_TYPOGRAPHY.h2
: CHAT_TYPOGRAPHY.h3;
if (isActivelyTyping) {
const RawTag = Tag;
return (
<RawTag key={`${chatMessage.id}-b-${index}`} className={headingClass}>
<h3
key={`${chatMessage.id}-b-${index}`}
className="block w-full text-right text-base font-bold leading-7 text-foreground"
>
{renderInline(block.text, true)}
{cursor}
</RawTag>
</h3>
);
}
const MotionHeading =
block.level === 1 ? motion.h1 : block.level === 2 ? motion.h2 : motion.h3;
return (
<MotionHeading
<motion.h3
key={`${chatMessage.id}-b-${index}`}
initial={index === 0 ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
className={headingClass}
className="block w-full text-right text-base font-bold leading-7 text-foreground"
>
{renderInline(block.text, false)}
{cursor}
</MotionHeading>
</motion.h3>
);
}
// 7. Paragraph
if (block.type === 'p') {
const proseClass = cn(
CHAT_TYPOGRAPHY.body,
isOwnMessage && 'chat-user-prose'
);
const proseClass =
'chat-prose break-words text-base leading-7 text-foreground';
if (isActivelyTyping) {
return (
@@ -777,7 +476,6 @@ export const MessageBubble = ({
) : (
<p className="text-sm text-muted-foreground">پاسخ خالی دریافت شد.</p>
)}
{chatMessage.status === 'error' && (
<span className="mt-2 block text-[10px] text-red-500 dark:text-red-300">
ارسال ناموفق بود. دوباره تلاش کنید.
@@ -1,94 +0,0 @@
import { forwardRef, type ReactNode } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { MOTION_EASE } from '@/lib/motion';
export interface SmoothTextAppearanceProps {
children: ReactNode;
className?: string;
/** Unique key for the text content to trigger entrance animation once per actual content change */
contentKey?: string | number;
/** Duration in seconds (180ms to 280ms, default 0.22s = 220ms) */
duration?: number;
/** Vertical displacement in pixels (4px to 6px, default 5px) */
distance?: number;
/** If true, skip enter animation completely (e.g. static load or already revealed) */
skipAnimation?: boolean;
}
/**
* SmoothTextAppearance
*
* Fast, natural text entrance inspired by ChatGPT's smooth response rendering:
* - Unified block entrance (no letter-by-letter typewriter delay)
* - 220ms duration (customizable between 180-280ms)
* - Subtle vertical travel of 4-6px (default 5px) combined with opacity
* - Strictly isolated to opacity & transform to avoid layout reflow (CLS)
* - Rock-solid typography & RTL stability
* - Respects prefers-reduced-motion
*/
export const SmoothTextAppearance = forwardRef<HTMLDivElement, SmoothTextAppearanceProps>(
(
{
children,
className,
contentKey,
duration = 0.22,
distance = 5,
skipAnimation = false,
},
ref
) => {
const shouldReduceMotion = useReducedMotion();
// Respect reduced motion: no vertical displacement, minimal soft fade
const effectiveDistance = shouldReduceMotion ? 0 : Math.max(4, Math.min(6, distance));
const effectiveDuration = shouldReduceMotion ? 0.15 : Math.max(0.18, Math.min(0.28, duration));
if (skipAnimation) {
return (
<div
ref={ref}
className={cn('text-right text-foreground dir-rtl will-change-auto', className)}
dir="rtl"
>
{children}
</div>
);
}
return (
<motion.div
ref={ref}
key={contentKey}
initial={{
opacity: 0,
y: effectiveDistance,
}}
animate={{
opacity: 1,
y: 0,
}}
transition={{
duration: effectiveDuration,
ease: MOTION_EASE,
delay: 0,
}}
style={{
willChange: 'opacity, transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
}}
className={cn(
'text-right text-foreground dir-rtl',
className
)}
dir="rtl"
>
{children}
</motion.div>
);
}
);
SmoothTextAppearance.displayName = 'SmoothTextAppearance';
-2
View File
@@ -1,5 +1,3 @@
export { AnimationWrapper } from './AnimationWrapper';
export { PageTransition, AnimatedRoutesShell } from './PageTransition';
export { StaggeredList, StaggerItem } from './StaggeredList';
export { SmoothTextAppearance, type SmoothTextAppearanceProps } from './SmoothTextAppearance';
+24 -24
View File
@@ -16,30 +16,30 @@ export const PCQ_LIKERT_OPTIONS: Array<{ value: PcqLikertValue; label: string }>
];
export const PCQ24_QUESTIONS: PcqQuestion[] = [
{ id: 'pcq_01', text: 'وقتی با مسئله‌ای روبه‌رو می‌شوم که حل کردنش زمان می‌برد، به توانایی خودم برای بررسی و پیدا کردن راه‌حل اعتماد دارم.' },
{ id: 'pcq_02', text: 'وقتی باید دربارهٔ درس‌هایم با معلم یا مسئول آموزشی صحبت کنم، احساس می‌کنم می‌توانم از پس صحبت کردن بربیایم.' },
{ id: 'pcq_03', text: 'برای شرکت در گفت‌و‌گوهای کلاسی دربارهٔ روش‌های بهتر درس خواندن، به توانایی خودم اعتماد دارم.' },
{ id: 'pcq_04', text: 'وقتی لازم است برای بهتر شدن وضعیت درسی‌ام هدف تعیین کنم، به توانایی خودم اطمینان دارم.' },
{ id: 'pcq_05', text: 'برای ارتباط گرفتن با کسی خارج از مدرسه و صحبت دربارهٔ مسائل درسی، احساس راحتی و اطمینان می‌کنم.' },
{ id: 'pcq_06', text: 'وقتی باید برای چند نفر از هم‌کلاسی‌هایم مطلبی را توضیح بدهم، به توانایی خودم اعتماد دارم.' },
{ id: 'pcq_07', text: 'اگر در درس یا یادگیری با مسئلهٔ سختی روبه‌رو شوم، می‌توانم راهی برای بیرون آمدن از آن وضعیت پیدا کنم.' },
{ id: 'pcq_08', text: 'این روزها با انرژی برای رسیدن به هدف‌های درسی‌ام تلاش می‌کنم.' },
{ id: 'pcq_09', text: 'به نظرم برای حل بیشتر مسئله‌ها می‌توان راه‌های مختلفی پیدا کرد.' },
{ id: 'pcq_10', text: 'در حال حاضر، خودم را تا حدی فرد موفقی می‌دانم.' },
{ id: 'pcq_11', text: 'می‌توانم راه‌های مختلفی برای رسیدن به هدف‌های درسی‌ای که الان دارم پیدا کنم.' },
{ id: 'pcq_12', text: 'در حال حاضر، دارم به هدف‌های درسی‌ای که برای خودم تعیین کرده‌ام نزدیک می‌شوم.' },
{ id: 'pcq_13', text: 'وقتی شکست می‌خورم، دوباره شروع کردن و ادامه دادن برایم سخت است.', reverseScored: true },
{ id: 'pcq_14', text: 'بالاخره می‌توانم راهی برای کنار آمدن با مشکلاتی که برایم پیش می‌آیند پیدا کنم.' },
{ id: 'pcq_15', text: 'اگر لازم باشد در درس‌هایم مستقل عمل کنم و به کمک دیگران متکی نباشم، می‌توانم از پسش بربیایم.' },
{ id: 'pcq_16', text: 'معمولاً وقتی در درس یا مدرسه با موقعیت پراسترس روبه‌رو می‌شوم، می‌توانم آرامش خودم را حفظ کنم.' },
{ id: 'pcq_17', text: 'می‌توانم از پس مشکلات درسی‌ام بربیایم، چون قبلاً هم سختی‌هایی را پشت سر گذاشته‌ام.' },
{ id: 'pcq_18', text: 'احساس می‌کنم در زمینهٔ درسی فعلی‌ام می‌توانم چند کار را همزمان انجام بدهم.' },
{ id: 'pcq_19', text: 'وقتی نمی‌دانم نتیجهٔ تلاش درسی‌ام چه می‌شود، معمولاً انتظار دارم نتیجهٔ خوبی بگیرم.' },
{ id: 'pcq_20', text: 'اگر احتمال داشته باشد در کاری اشتباه کنم، معمولاً انتظار دارم اشتباه پیش بیاید.', reverseScored: true },
{ id: 'pcq_21', text: 'معمولاً به جنبه‌های مثبت اتفاق‌هایی که در درس و مدرسه برایم می‌افتند توجه می‌کنم.' },
{ id: 'pcq_22', text: 'دربارهٔ اتفاق‌هایی که در آینده برای درس و تحصیلم می‌افتند، نگاه مثبتی دارم.' },
{ id: 'pcq_23', text: 'در کارهای درسی‌ام، اوضاع هیچ‌وقت آن‌طور که می‌خواهم پیش نمی‌رود.', reverseScored: true },
{ id: 'pcq_24', text: 'باور دارم بعد از سختی‌های درس و تحصیل هم ممکن است اتفاق‌های خوب و امیدوارکننده‌ای پیش بیاید.' }
{ id: 'pcq_01', text: 'با اعتماد، یک مسئله طولانی را بررسی می کنم تا یک راه حل بیابم.' },
{ id: 'pcq_02', text: 'من با اعتماد کامل می توانم وظایفم را در کارهای جمعی معین کنم.' },
{ id: 'pcq_03', text: 'من با اعتماد می توانم در بخش های اساسی زندگی ام مشارکت کنم.' },
{ id: 'pcq_04', text: 'من با اعتماد کامل می توانم اهداف زندگی ام را تعیین کنم.' },
{ id: 'pcq_05', text: 'من با اعتماد کامل می توانم در بیرون با مردم روبرو شوم تا درباره مسائل و مشکلات بحث کنم.' },
{ id: 'pcq_06', text: 'من با اعتماد کامل می توانم اطلاعات مفیدی به سایر دانشجویان هم رشته ام ارائه کنم.' },
{ id: 'pcq_07', text: 'اگر من در کار و موقعیتی گیر بیفتم می توانم راه های زیادی برای خلاص شدن از آن بیابم.' },
{ id: 'pcq_08', text: 'در حال حاضر من به شدت اهداف تحصیلی ام را دنبال می کنم.' },
{ id: 'pcq_09', text: 'من فکر می کنم روش های زیادی برای هر مشکل وجود دارد.' },
{ id: 'pcq_10', text: 'هم اکنون من خودم را به عنوان موفق ترین فرد در تحصیل می بینم.' },
{ id: 'pcq_11', text: 'من می توانم راه های زیادی برای رسیدن به اهداف جاری تحصیلی ام بیابم.' },
{ id: 'pcq_12', text: 'در حال حاضر من می توانم با اهداف تحصیلی که برای خودم مشخص کرده ام روبرو شوم.' },
{ id: 'pcq_13', text: 'زمانی که با مانعی در امور تحصیلی ام روبرو می شوم، با زحمت کمی آن را اصلاح می کنم و به کارم ادامه می دهم.' },
{ id: 'pcq_14', text: 'من معمولاً مشکلات متنوعی را می توانم مدیریت کنم.' },
{ id: 'pcq_15', text: 'من اگر مجبور باشم، در حین انجام تکالیف با خودم حرف می زنم.' },
{ id: 'pcq_16', text: 'من معمولاً در مواقع فشار کاری یا تحصیلی قدم می زنم.' },
{ id: 'pcq_17', text: 'من می توانم بر مشکلاتم غلبه کنم، زیرا که قبلاً تجربه چنین مشکلاتی را داشته ام.' },
{ id: 'pcq_18', text: 'من احساس می کنم که می توانم در حین انجام امور تحصیلی چند فعالیت دیگر را نیز انجام دهم.' },
{ id: 'pcq_19', text: 'من انتظار بهترین عملکرد را دارم، حتی زمانی که اهداف کاری ام در حین انجام آنها نامشخص باشند.' },
{ id: 'pcq_20', text: 'اگر چیز اشتباهی برایم پیش آید، سعی می کنم عملکرد عاقلانه و بهتری از خود نشان بدهم.' },
{ id: 'pcq_21', text: 'من همیشه جنبه های مثبت کارم را می بینم.' },
{ id: 'pcq_22', text: 'من راجع به آنچه که برایم در آینده در ارتباط با کار و تحصیلم اتفاق می افتد خوش بین هستم.' },
{ id: 'pcq_23', text: 'در امور تحصیلی کارها هیچ وقت در مسیری که من می خواستم پیش نرفته است.', reverseScored: true },
{ id: 'pcq_24', text: 'من به امور تحصیلی ام به عنوان بهترین وجه زندگی ام نگاه می کنم.' }
];
export function scorePcqAnswer(rawValue: string, reverseScored?: boolean): number {
-192
View File
@@ -1,192 +0,0 @@
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
export type ScoreTier = 'low' | 'mid' | 'high';
export interface DimensionConfig {
/** Stable dimension key */
dimension: DimensionName;
/** Persian dimension title */
titleFa: string;
/** Short tagline */
tagline: string;
/** Accent color for charts and highlights */
accentColor: string;
/** Gradient styling */
gradient: string;
/** Primary image file name */
fileName: string;
/** Real local path */
path: string;
/** Fallback local path */
fallbackPath: string;
/** Accessible image alt text */
altText: string;
/** Short Persian invitation sentence for step initial state */
inviteText: string;
/** Short, non-diagnostic descriptive title for each score tier */
descriptiveTitleByTier: {
low: string; // 6 to 15
mid: string; // 16 to 25
high: string; // 26 to 36
};
/** Exactly 3 feedback texts for the 3 operational score categories */
feedbackByTier: {
low: string; // 6 to 15
mid: string; // 16 to 25
high: string; // 26 to 36
};
}
/**
* 4 PCQ-24 dimensions configuration with local images, descriptive titles, and 12 exact feedback texts.
* Operational Score Categories (not psychometric diagnostic labels):
* - Category 1 (low): 6 to 15
* - Category 2 (mid): 16 to 25
* - Category 3 (high): 26 to 36
*/
export const DIMENSION_CONFIGS: Record<DimensionName, DimensionConfig> = {
efficacy: {
dimension: 'efficacy',
titleFa: 'خودکارآمدی',
tagline: 'باور به توانایی‌ها و مهارت‌های فردی',
accentColor: '#00a8e8',
gradient: 'from-sky-500/20 via-cyan-500/15 to-transparent',
fileName: 'self-efficacy.webp',
path: '/images/components/self-efficacy.webp',
fallbackPath: '/images/results/self-efficacy.webp',
altText: 'تصویر اختصاصی خودکارآمدی نویا',
inviteText: 'آماده‌ای تا ببینی باورت به توانایی‌ها و روبه‌رو شدن با چالش‌ها چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر شناخت و رشد توانایی‌ها',
mid: 'باور رو‌به‌رشد به توانایی‌ها',
high: 'باور پررنگ به توانایی‌های فردی',
},
feedbackByTier: {
low: 'اعتماد به توانایی‌های خودت هنوز جای رشد داره. شاید بعضی وقت‌ها انجام کارهای تازه برات سخت به نظر برسه؛ این فقط بخشی از تصویر فعلی توئه و همه توانایی‌هایت رو مشخص نمی‌کنه.',
mid: 'به نظر می‌رسه در بعضی موقعیت‌ها به خودت اعتماد داری، اما گاهی هم درباره توانایی‌هایت تردید می‌کنی. این یعنی نگاهت به توانایی‌های خودت در موقعیت‌های مختلف می‌تونه متفاوت باشه.',
high: 'یکی از ویژگی‌های برجسته تو، اعتماد به توانایی‌هایت برای انجام کارهاست. به نظر می‌رسه در پاسخ‌هایت، باور به توانایی خودت جایگاه پررنگی داره؛ هرچند این حس ممکنه در موقعیت‌های مختلف تغییر کنه.',
},
},
hope: {
dimension: 'hope',
titleFa: 'امیدواری',
tagline: 'انگیزه و یافتن راه‌های رسیدن به هدف',
accentColor: '#0d9488',
gradient: 'from-teal-500/20 via-emerald-500/15 to-transparent',
fileName: 'hope.webp',
path: '/images/components/hope.webp',
fallbackPath: '/images/results/hope.webp',
altText: 'تصویر اختصاصی امیدواری نویا',
inviteText: 'آماده‌ای تا ببینی نگاهت به هدف‌ها و پیدا کردن مسیرهای رسیدن به اون‌ها چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر کشف هدف‌ها و راه‌ها',
mid: 'انگیزه و تلاش برای یافتن راه‌ها',
high: 'امیدواری و مسیرسازی پررنگ',
},
feedbackByTier: {
low: 'پیدا کردن مسیر رسیدن به هدف‌ها هنوز می‌تونه بخش مهمی از تجربه تو باشه. این نتیجه فقط نگاه فعلی تو به هدف‌ها و راه‌های رسیدن به اون‌ها رو نشون می‌ده، نه چیزی که در آینده برات ممکنه یا غیرممکنه.',
mid: 'به نظر می‌رسه برای بعضی هدف‌هایت مسیر روشن‌تری داری، اما درباره بعضی هدف‌های دیگه هنوز در حال پیدا کردن راهت هستی. این کاملاً تصویری از نگاه فعلی تو به هدف‌ها و مسیرهای رسیدن به اون‌هاست.',
high: 'امیدواری یکی از نقاط قوت برجسته تو در این آزمونه. به نظر می‌رسه پیدا کردن هدف‌ها و فکر کردن به راه‌های رسیدن به اون‌ها در پاسخ‌هایت جایگاه پررنگی داره.',
},
},
resilience: {
dimension: 'resilience',
titleFa: 'تاب‌آوری',
tagline: 'ایستادگی و برگشت‌پذیری پس از سختی‌ها',
accentColor: '#8b5cf6',
gradient: 'from-indigo-500/20 via-purple-500/15 to-transparent',
fileName: 'resilience.webp',
path: '/images/components/resilience.webp',
fallbackPath: '/images/results/resilience.webp',
altText: 'تصویر اختصاصی تاب‌آوری نویا',
inviteText: 'آماده‌ای تا ببینی کنار اومدن با سختی‌ها و برگشتن به مسیر در پاسخ‌هایت چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر یادگیری و برگشت به مسیر',
mid: 'ایستادگی منعطف در موقعیت‌های مختلف',
high: 'ایستادگی و برگشت‌پذیری پررنگ',
},
feedbackByTier: {
low: 'کنار اومدن با سختی‌ها و برگشتن به مسیر، از بخش‌هایی است که هنوز می‌تونه در تو رشد کنه. این نمره به‌تنهایی نمی‌گه در شرایط سخت چطور رفتار می‌کنی؛ فقط تصویری از پاسخ‌های فعلی توئه.',
mid: 'به نظر می‌رسه در بعضی شرایط می‌تونی با سختی‌ها کنار بیای، اما تجربه‌ات در موقعیت‌های مختلف یکسان نیست. این نتیجه نشون می‌ده تاب‌آوری تو در پاسخ‌های این آزمون، ترکیبی از جنبه‌های شکل‌گرفته و در حال رشده.',
high: 'تاب‌آوری یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن کنار اومدن با دشواری‌ها و ادامه دادن مسیر، در تصویر فعلی تو جایگاه پررنگی داره.',
},
},
optimism: {
dimension: 'optimism',
titleFa: 'خوش‌بینی',
tagline: 'نگاه مثبت و امیدوارکننده به آینده',
accentColor: '#10b981',
gradient: 'from-emerald-500/20 via-teal-500/15 to-transparent',
fileName: 'optimism.webp',
path: '/images/components/optimism.webp',
fallbackPath: '/images/results/optimism.webp',
altText: 'تصویر اختصاصی خوش‌بینی نویا',
inviteText: 'آماده‌ای تا ببینی نگاهت به اتفاق‌ها و دیدن جنبه‌های مثبت چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر کشف جنبه‌های روشن اتفاق‌ها',
mid: 'نگاه واقع‌بینانه و پویا به آینده',
high: 'دیدگاه مثبت و امیدوارانه به آینده',
},
feedbackByTier: {
low: 'نگاهت به اتفاق‌ها و آینده هنوز ظرفیت رشد داره. این نمره به معنی منفی بودن نگاهت در همه موقعیت‌ها نیست؛ فقط نشون میده پاسخ‌های فعلی تو درباره خوش‌بینی چه تصویری دارن.',
mid: 'گاهی ممکنه جنبه‌های امیدوارکننده اتفاق‌ها رو ببینی و گاهی هم نگاهت به شرایط متفاوت باشه. نتیجه تو تصویری از نحوه نگاه فعلی‌ات به اتفاق‌ها و آینده ارائه می‌ده.',
high: 'خوش‌بینی یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن نگاه امیدوارانه به اتفاق‌ها و آینده در تجربه فعلی تو جایگاه پررنگی داره.',
},
},
};
export const RESULT_IMAGES = DIMENSION_CONFIGS;
/**
* 4 dimensions ordered strictly according to the PCQ-24 specification:
* 1. Self-efficacy (خودکارآمدی)
* 2. Hope (امیدواری)
* 3. Resilience (تاب‌آوری)
* 4. Optimism (خوش‌بینی)
*/
export const DIMENSION_ORDER: DimensionName[] = [
'efficacy',
'hope',
'resilience',
'optimism',
];
/**
* Maps a dimension score (6 to 36) to one of the 3 operational categories:
* - 6 to 15: low (دسته اول)
* - 16 to 25: mid (دسته دوم)
* - 26 to 36: high (دسته سوم)
*
* NOTE: Both bounds are inclusive (e.g. 15 is low, 16 is mid, 25 is mid, 26 is high).
*/
export function getScoreTier(score: number): ScoreTier {
if (score <= 15) return 'low';
if (score <= 25) return 'mid';
return 'high';
}
/**
* Returns the short, teen-friendly descriptive title for a dimension and score.
*/
export function getDescriptiveTitleForScore(
dimension: DimensionName,
score: number
): string {
const config = DIMENSION_CONFIGS[dimension];
if (!config) return '';
const tier = getScoreTier(score);
return config.descriptiveTitleByTier[tier];
}
/**
* Returns the exact pre-written feedback text for a given dimension and score.
*/
export function getFeedbackForScore(
dimension: DimensionName,
score: number
): string {
const config = DIMENSION_CONFIGS[dimension];
if (!config) return '';
const tier = getScoreTier(score);
return config.feedbackByTier[tier];
}
+43 -218
View File
@@ -81,7 +81,6 @@ export function useChat(options: UseChatOptions = {}) {
const messagesRef = useRef<ChatMessage[]>([]);
/** After first successful bootstrap, never flash the full-page chat skeleton again. */
const hasHydratedChatRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
coachContextRef.current = coachContext;
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
@@ -93,10 +92,6 @@ export function useChat(options: UseChatOptions = {}) {
clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
@@ -371,14 +366,6 @@ export function useChat(options: UseChatOptions = {}) {
scheduleCompletionAfterStream(messageId);
};
const stopGenerating = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsAiThinking(false);
}, []);
const startCoachingSession = async (_userId: string) => {
if (!chatSessionId) {
throw new Error('جلسه گفتگو در دسترس نیست. لطفا صفحه را رفرش کنید.');
@@ -389,115 +376,37 @@ export function useChat(options: UseChatOptions = {}) {
const sessionOrder = coachContextRef.current?.order;
setIsAiThinking(true);
abortControllerRef.current = new AbortController();
const assistantLocalId = createLocalId();
let accumulatedContent = '';
let hasCreatedAssistantMessage = false;
let pendingRaf: number | null = null;
const scheduleUpdate = () => {
if (pendingRaf !== null) return;
pendingRaf = window.requestAnimationFrame(() => {
pendingRaf = null;
const textToPaint = accumulatedContent;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
)
);
});
};
try {
await api.stream(
`/chat/sessions/${chatSessionId}/start`,
{
signal: abortControllerRef.current.signal,
onEvent: (ev) => {
if (ev.event === 'chunk' && ev.data?.text) {
accumulatedContent += ev.data.text;
if (!hasCreatedAssistantMessage) {
hasCreatedAssistantMessage = true;
setIsAiThinking(false);
const placeholderAssistant: ChatMessage = {
id: assistantLocalId,
user_id: _userId,
content: accumulatedContent,
created_at: new Date().toISOString(),
role: 'assistant',
status: 'streaming',
sessionOrder,
};
setMessages((prev) => {
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
return [...withoutCurrent, placeholderAssistant];
});
} else {
scheduleUpdate();
}
} else if (ev.event === 'done' && ev.data) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
setIsAiThinking(false);
const doneData = ev.data as {
messages?: BackendChatMessage[];
assistantMessage?: BackendChatMessage;
session?: { id: string };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
};
const backendMsg = doneData.messages?.[0] || doneData.assistantMessage;
const canonicalAssistant = backendMsg
? {
...mapBackendMessage(backendMsg, _userId, sessionOrder ?? 0),
status: 'sent' as const,
}
: null;
const result = await api.post<{
session: { id: string };
messages: BackendChatMessage[];
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
}>(`/chat/sessions/${chatSessionId}/start`);
if (canonicalAssistant) {
setMessages((prev) => {
const exists = prev.some((m) => m.id === assistantLocalId);
if (exists) {
return prev.map((msg) =>
msg.id === assistantLocalId ? canonicalAssistant : msg
);
}
return [...prev, canonicalAssistant];
});
if (doneData.completion) {
queueCompletionForStream(
doneData.completion,
doneData.session?.id || chatSessionId,
canonicalAssistant.id
);
scheduleCompletionAfterStream(canonicalAssistant.id);
}
}
} else if (ev.event === 'error') {
throw new Error(ev.data?.message || 'خطا در شروع جلسه گفتگو');
}
},
}
const backendMessages = result.messages.map((msg) =>
mapBackendMessage(msg, _userId, sessionOrder ?? 0)
);
} catch (error) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
if (abortControllerRef.current?.signal.aborted) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
)
const streamingAssistant = backendMessages[backendMessages.length - 1];
setMessages((prev) => {
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
const withStream = backendMessages.map((msg, index) =>
index === backendMessages.length - 1 && msg.role === 'assistant'
? { ...msg, status: 'streaming' as const }
: msg
);
return null;
return [...withoutCurrent, ...withStream];
});
if (streamingAssistant?.role === 'assistant') {
queueCompletionForStream(result.completion, result.session.id, streamingAssistant.id);
} else {
emitCompletionIfValid(result.completion, result.session.id);
}
return backendMessages[backendMessages.length - 1];
} catch (error) {
throw resolveFriendlyError(error);
} finally {
setIsAiThinking(false);
abortControllerRef.current = null;
}
};
@@ -530,112 +439,31 @@ export function useChat(options: UseChatOptions = {}) {
}
setIsAiThinking(true);
abortControllerRef.current = new AbortController();
const assistantLocalId = createLocalId();
let accumulatedContent = '';
let hasCreatedAssistantMessage = false;
let pendingRaf: number | null = null;
const scheduleUpdate = () => {
if (pendingRaf !== null) return;
pendingRaf = window.requestAnimationFrame(() => {
pendingRaf = null;
const textToPaint = accumulatedContent;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
)
);
});
};
try {
await api.stream(
`/chat/sessions/${chatSessionId}/messages`,
{
body: { content: trimmed },
signal: abortControllerRef.current.signal,
onEvent: (ev) => {
if (ev.event === 'chunk' && ev.data?.text) {
accumulatedContent += ev.data.text;
if (!hasCreatedAssistantMessage) {
hasCreatedAssistantMessage = true;
setIsAiThinking(false);
const placeholderAssistant: ChatMessage = {
id: assistantLocalId,
user_id: nextUserId,
content: accumulatedContent,
created_at: new Date().toISOString(),
role: 'assistant',
status: 'streaming',
sessionOrder,
};
setMessages((prev) => [...prev, placeholderAssistant]);
} else {
scheduleUpdate();
}
} else if (ev.event === 'ready' && ev.data?.userMessage) {
const persistedUser = mapBackendMessage(ev.data.userMessage, nextUserId, sessionOrder ?? 0);
setMessages((prev) =>
prev.map((msg) => (msg.id === pendingId ? persistedUser : msg))
);
} else if (ev.event === 'done' && ev.data) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
setIsAiThinking(false);
const doneData = ev.data as {
assistantMessage?: BackendChatMessage;
session?: { id: string };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
};
const canonicalAssistant = doneData.assistantMessage
? {
...mapBackendMessage(doneData.assistantMessage, nextUserId, sessionOrder ?? 0),
status: 'sent' as const,
}
: null;
const result = await api.post<{
session: { id: string };
userMessage: BackendChatMessage & { role: 'user' };
assistantMessage: BackendChatMessage & { role: 'assistant' };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
}>(`/chat/sessions/${chatSessionId}/messages`, { content: trimmed });
if (canonicalAssistant) {
setMessages((prev) => {
const exists = prev.some((m) => m.id === assistantLocalId);
if (exists) {
return prev.map((msg) =>
msg.id === assistantLocalId ? canonicalAssistant : msg
);
}
return [...prev, canonicalAssistant];
});
const persistedUser = mapBackendMessage(result.userMessage, nextUserId, sessionOrder ?? 0);
const assistant = {
...mapBackendMessage(result.assistantMessage, nextUserId, sessionOrder ?? 0),
status: 'streaming' as const,
};
if (doneData.completion) {
queueCompletionForStream(
doneData.completion,
doneData.session?.id || chatSessionId,
canonicalAssistant.id
);
scheduleCompletionAfterStream(canonicalAssistant.id);
}
}
} else if (ev.event === 'error') {
throw new Error(ev.data?.message || 'خطا در دریافت پاسخ');
}
},
setMessages((prev) => {
const withoutPending = prev.filter((msg) => msg.id !== pendingId);
if (skipUserMessage) {
return [...withoutPending, assistant];
}
);
return [...withoutPending, persistedUser, assistant];
});
queueCompletionForStream(result.completion, result.session.id, assistant.id);
return assistant;
} catch (error) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
if (abortControllerRef.current?.signal.aborted) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
)
);
return null;
}
if (!skipUserMessage) {
setMessages((prev) =>
prev.map((msg) =>
@@ -646,7 +474,6 @@ export function useChat(options: UseChatOptions = {}) {
throw resolveFriendlyError(error);
} finally {
setIsAiThinking(false);
abortControllerRef.current = null;
}
};
@@ -660,7 +487,5 @@ export function useChat(options: UseChatOptions = {}) {
sendMessage,
startCoachingSession,
markAssistantStreamComplete,
stopGenerating,
};
}
+89 -10
View File
@@ -8,10 +8,23 @@ type UseStreamTypewriterOptions = {
onComplete?: () => void;
};
/** ~20% faster than the original pacing (delays × 0.8). */
const SPEED = 0.8;
const randomDelayMs = (char: string, nextChar = ''): number => {
if (char === '\n') {
if (nextChar === '\n') return (340 + Math.random() * 140) * SPEED;
return (200 + Math.random() * 90) * SPEED;
}
if (/[.!?…۔؟]/.test(char)) return (80 + Math.random() * 90) * SPEED;
if (/[,،;:]/.test(char)) return (40 + Math.random() * 40) * SPEED;
if (/\s/.test(char)) return (12 + Math.random() * 10) * SPEED;
return (15 + Math.random() * 15) * SPEED;
};
/**
* Direct, continuous message stream hook.
* Avoids slow character-by-character timer replay on complete responses
* while cleanly maintaining progress and completion callbacks.
* Simulated streaming typewriter with variable per-character delay.
* Stable across parent re-renders (onComplete/onProgress via refs).
*/
export function useStreamTypewriter({
text,
@@ -19,8 +32,13 @@ export function useStreamTypewriter({
onProgress,
onComplete,
}: UseStreamTypewriterOptions) {
const [displayedText, setDisplayedText] = useState(text);
const [isStreaming, setIsStreaming] = useState(false);
const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text));
const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text));
const indexRef = useRef(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textRef = useRef(text);
const enabledRef = useRef(enabled);
const completedForTextRef = useRef<string | null>(enabled ? null : text);
const onProgressRef = useRef(onProgress);
const onCompleteRef = useRef(onComplete);
@@ -33,18 +51,79 @@ export function useStreamTypewriter({
}, [onComplete]);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!text) {
setDisplayedText('');
setIsStreaming(false);
indexRef.current = 0;
textRef.current = '';
enabledRef.current = enabled;
completedForTextRef.current = null;
return;
}
if (!enabled) {
setDisplayedText(text);
setIsStreaming(false);
textRef.current = text;
enabledRef.current = false;
completedForTextRef.current = text;
return;
}
const textChanged = textRef.current !== text;
const enabledChanged = enabledRef.current !== enabled;
textRef.current = text;
enabledRef.current = enabled;
if (!textChanged && !enabledChanged && completedForTextRef.current === text) {
setDisplayedText(text);
setIsStreaming(false);
return;
}
setDisplayedText(text);
setIsStreaming(false);
onProgressRef.current?.(text);
onCompleteRef.current?.();
const shouldRestart =
textChanged || enabledChanged || completedForTextRef.current !== text;
if (shouldRestart) {
indexRef.current = 0;
completedForTextRef.current = null;
setDisplayedText('');
setIsStreaming(true);
const tick = () => {
const full = textRef.current;
if (indexRef.current >= full.length) {
setIsStreaming(false);
completedForTextRef.current = full;
onCompleteRef.current?.();
return;
}
const currentChar = full[indexRef.current] ?? '';
const nextChar = full[indexRef.current + 1] ?? '';
const nextIndex = indexRef.current + 1;
const nextText = full.slice(0, nextIndex);
indexRef.current = nextIndex;
setDisplayedText(nextText);
onProgressRef.current?.(nextText);
timeoutRef.current = setTimeout(tick, randomDelayMs(currentChar, nextChar));
};
timeoutRef.current = setTimeout(tick, randomDelayMs(text[0] ?? ' ', text[1] ?? ''));
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [text, enabled]);
return { displayedText, isStreaming };
}
-13
View File
@@ -91,25 +91,12 @@ export function getLoginErrorMessage(error: unknown): string {
function localizeValidationMessage(message: string | undefined): string | null {
if (!message) return null;
const lower = message.toLowerCase();
if (/حداقل سن|سن باید|سن الزامی/.test(message)) {
return message;
}
if (/age must be at least\s*(\d+)/i.test(message)) {
const min = message.match(/age must be at least\s*(\d+)/i)?.[1] ?? '12';
return `حداقل سن ${min} سال است`;
}
if (/age must be an integer|age is required/i.test(message)) {
return 'سن باید یک عدد صحیح باشد';
}
if (lower.includes('email')) {
return 'فرمت ایمیل نامعتبر است.';
}
if (lower.includes('password')) {
return 'رمز عبور نامعتبر است.';
}
if (/^validation failed$/i.test(message.trim())) {
return 'اطلاعات واردشده نامعتبر است.';
}
return localizeKnownEnglishMessage(message);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
export const SESSION_UNLOCK_INTERVAL_MS = 0;
/** 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>;
+10 -36
View File
@@ -6,13 +6,12 @@ import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
import { getProgramProgress } from '@/services/programService';
import { Progress } from '@/components/ui/progress';
import { Navigate, useNavigate } from 'react-router-dom';
import { Check, ChevronLeft, Lock, NotebookPen } from 'lucide-react';
import { Check, ChevronLeft, Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion';
import { motion } from 'framer-motion';
import { fastTransition } from '@/lib/motion';
import { CourseStatusSkeleton } from '@/components/skeletons';
import { Button } from '@/components/ui/button';
type StepState = 'open' | 'locked' | 'done';
@@ -86,20 +85,6 @@ export default function AccountPage() {
</span>
</p>
)}
{!isGuest ? (
<Button
type="button"
variant="outline"
className="mt-3 w-full justify-between"
onClick={() => navigate('/assignments')}
>
<span className="inline-flex items-center gap-2">
<NotebookPen className="h-4 w-4 text-sky-600 dark:text-sky-400" />
تکالیف من
</span>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
</Button>
) : null}
</AnimationWrapper>
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
@@ -107,15 +92,11 @@ export default function AccountPage() {
<StatusRow
title="پیش‌آزمون"
state={preState}
onOpen={preDone ? () => navigate('/assessment/pre') : undefined}
onOpen={() => navigate('/assessment/pre')}
lockedHint={preDone ? 'انجام شده — قابل تکرار نیست' : undefined}
>
{preDone ? (
<div className="flex items-center justify-between">
<StatusDone label="انجام شده" />
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
مشاهده کارنامه
</span>
</div>
<StatusDone label="انجام شده" />
) : (
<p className="text-sm text-body">برای شروع مسیر، پیش‌آزمون را انجام دهید</p>
)}
@@ -158,21 +139,14 @@ export default function AccountPage() {
<StatusRow
title="پس‌آزمون"
state={postState}
onOpen={postDone ? () => navigate('/finished') : postState === 'open' ? () => navigate('/assessment/post') : undefined}
onOpen={() => navigate('/assessment/post')}
lockedHint={
postDone
? undefined
? 'انجام شده — قابل تکرار نیست'
: 'پس از رسیدن به جلسه پایانی فعال می‌شود'
}
>
{postState === 'done' && (
<div className="flex items-center justify-between">
<StatusDone label="انجام شده" />
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
مشاهده کارنامه
</span>
</div>
)}
{postState === 'done' && <StatusDone label="انجام شده" />}
{postState === 'open' && (
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
<span
@@ -206,10 +180,10 @@ function StatusRow({
title: string;
children: ReactNode;
state: StepState;
onOpen?: () => void;
onOpen: () => void;
lockedHint?: string;
}) {
const isInteractive = Boolean(onOpen) && (state === 'open' || state === 'done');
const isInteractive = state === 'open';
const content = (
<div className="flex items-start justify-between gap-3">
@@ -231,7 +205,7 @@ function StatusRow({
</div>
);
if (isInteractive && onOpen) {
if (isInteractive) {
return (
<motion.button
type="button"
+13 -56
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -7,11 +7,8 @@ import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import {
saveAssessment,
getLatestByPhase,
type AttemptPhase,
type AssessmentSubmission,
} from '@/services/assessmentService';
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
import { BackendError } from '@/services/backend';
import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendError';
import { useToast } from '@/components/ui/use-toast';
@@ -43,39 +40,16 @@ export default function AssessmentPage() {
const [introStep, setIntroStep] = useState(0);
const [contentVisible, setContentVisible] = useState(true);
const [isAnimating, setIsAnimating] = useState(false);
const [completedSubmission, setCompletedSubmission] = useState<AssessmentSubmission | null>(null);
const [showResultView, setShowResultView] = useState(false);
const [loadingExistingResult, setLoadingExistingResult] = useState(false);
const isPreTest = phase === 'pre';
const isPostTest = phase === 'post';
const invalidPhase = !isPreTest && !isPostTest;
useEffect(() => {
// If pre-test was already completed and user is on /assessment/pre, load the result to display
if (isPreTest && !loading && progress?.pre_test_completed_at && effectiveUserId && !completedSubmission) {
setLoadingExistingResult(true);
getLatestByPhase(effectiveUserId, 'pre')
.then((submission) => {
if (submission) {
setCompletedSubmission(submission);
setShowResultView(true);
}
})
.catch((err) => {
console.error('Failed to load existing pre-test result:', err);
})
.finally(() => {
setLoadingExistingResult(false);
});
}
}, [isPreTest, loading, progress?.pre_test_completed_at, effectiveUserId, completedSubmission]);
const canAccess = useMemo(() => {
if (isPreTest) return permissions.canTakePreTest || Boolean(progress?.pre_test_completed_at);
if (isPreTest) return permissions.canTakePreTest;
if (isPostTest) return permissions.canTakePostTest;
return false;
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest, progress?.pre_test_completed_at]);
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest]);
const totalQuestions = PCQ24_QUESTIONS.length;
const currentQuestion = PCQ24_QUESTIONS[currentIndex];
@@ -93,23 +67,7 @@ export default function AssessmentPage() {
return <Navigate to="/" replace />;
}
if (loading || loadingExistingResult) {
return <AssessmentSkeleton />;
}
if (showResultView && completedSubmission) {
return (
<div className="ambient-glow relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
<AssessmentResultView
phase={isPreTest ? 'pre' : 'post'}
currentSubmission={completedSubmission}
onContinue={() => navigate(isPreTest ? '/chat' : '/finished')}
/>
</div>
);
}
if (!canAccess) {
if (!loading && !canAccess) {
if (isPreTest && progress?.pre_test_completed_at) {
return <Navigate to="/" replace />;
}
@@ -121,6 +79,10 @@ export default function AssessmentPage() {
}
}
if (loading) {
return <AssessmentSkeleton />;
}
const advanceWithFade = (delta: number) => {
if (isAnimating || submitting || isRedirecting) return;
setIsAnimating(true);
@@ -189,16 +151,17 @@ export default function AssessmentPage() {
if (isPreTest) {
await markPreTestDone(submission.completedAt);
await reloadProgress();
setCompletedSubmission(submission);
setShowResultView(true);
setSuccessMessage('پیش‌آزمون با موفقیت ثبت شد. در حال انتقال به فضای چت...');
setIsRedirecting(true);
setTimeout(() => navigate('/chat'), 800);
return;
}
await markPostTestDone(submission.completedAt);
await reloadProgress();
setSuccessMessage('پس‌آزمون با موفقیت ثبت شد. در حال انتقال به صفحه کارنامه پایانی...');
setSuccessMessage('پس‌آزمون با موفقیت ثبت شد. در حال انتقال به صفحه پایان دوره...');
setIsRedirecting(true);
setTimeout(() => navigate('/finished'), 600);
setTimeout(() => navigate('/finished'), 800);
} catch (error: unknown) {
console.error('Error saving assessment:', error);
if (error instanceof BackendError) {
@@ -216,12 +179,6 @@ export default function AssessmentPage() {
if (isPreTest) {
await markPreTestDone();
await reloadProgress();
const latest = await getLatestByPhase(effectiveUserId, 'pre');
if (latest) {
setCompletedSubmission(latest);
setShowResultView(true);
return;
}
navigate('/chat', { replace: true });
return;
}
+8 -15
View File
@@ -8,7 +8,6 @@ import {
fetchAssignmentCatalog,
type AssignmentCatalogItem,
} from '@/services/assignmentService';
import { getBackendErrorMessage } from '@/lib/backendError';
import { AnimationWrapper, StaggerItem, StaggeredList } from '@/components/motion';
import { PageSkeleton } from '@/components/skeletons';
import { Button } from '@/components/ui/button';
@@ -41,7 +40,7 @@ export default function AssignmentsPage() {
const rows = await fetchAssignmentCatalog();
setItems(rows);
} catch (err) {
setError(getBackendErrorMessage(err, 'بارگذاری تکالیف ناموفق بود'));
setError(err instanceof Error ? err.message : 'بارگذاری تکالیف ناموفق بود');
setItems([]);
} finally {
setLoading(false);
@@ -110,20 +109,14 @@ export default function AssignmentsPage() {
</Button>
</div>
) : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
<p>فهرست تکالیف هنوز از سرور بارگذاری نشده است.</p>
<p className="mt-2 text-xs">
پس از تکمیل هر یک از جلسات ۲ تا ۱۰، تکلیف همان جلسه اینجا باز می‌شود.
</p>
<Button variant="link" className="mt-2 px-0" onClick={() => void load()}>
تلاش مجدد
</Button>
</div>
<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 isOpen = item.unlocked && Boolean(ua);
const unlocked = item.unlocked && ua;
const completed = ua?.status === 'COMPLETED';
const content = (
@@ -137,7 +130,7 @@ export default function AssignmentsPage() {
{item.definition.title}
</h2>
</div>
{isOpen ? (
{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" />
@@ -150,7 +143,7 @@ export default function AssignmentsPage() {
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
{assignmentTypeLabel(item.definition.assignmentType)}
</span>
{isOpen && ua ? (
{unlocked && ua ? (
<>
<span
className={cn(
@@ -176,7 +169,7 @@ export default function AssignmentsPage() {
return (
<StaggerItem key={item.definition.id}>
{isOpen && ua ? (
{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"
+24 -54
View File
@@ -15,7 +15,7 @@ import { exampleCompletionSignal, looksLikeFinalPostTestHandoff } from '@/lib/ai
import { useToast } from '@/components/ui/use-toast';
import { getFetchErrorMessage } from '@/lib/fetchError';
import { APP_NAME } from '@/utils/constants';
import { ArrowUp, Loader2, Sparkles, Square } from 'lucide-react';
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
import { LoadingOverlay } from '@/components/ui/loading-overlay';
import { ChatSkeleton, PageSkeleton } from '@/components/skeletons';
@@ -201,7 +201,6 @@ export default function ChatPage() {
sendMessage,
startCoachingSession,
markAssistantStreamComplete,
stopGenerating,
} = useChat({
coachContext,
userId: effectiveUserId,
@@ -297,27 +296,15 @@ export default function ChatPage() {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const scroller = messagesScrollRef.current;
if (!scroller) return;
if (behavior === 'smooth') {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
} else {
const end = messagesEndRef.current;
if (end) {
end.scrollIntoView({ behavior, block: 'end' });
}
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
}, []);
// Ensure scroll position remains accurate when mobile virtual keyboard opens/resizes
useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const handleViewportResize = () => {
scrollToBottom('auto');
};
const viewport = window.visualViewport;
viewport.addEventListener('resize', handleViewportResize);
return () => {
viewport.removeEventListener('resize', handleViewportResize);
};
}, [scrollToBottom]);
const isStreamOrThinking =
isAiThinking || messages.some((msg) => msg.status === 'streaming');
@@ -539,7 +526,7 @@ export default function ChatPage() {
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-4">
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
<LoadingOverlay
open={isSessionTransitionPending}
message={
@@ -548,7 +535,7 @@ export default function ChatPage() {
: 'در حال آماده‌سازی جلسه بعد…'
}
/>
<div className="relative z-10 shrink-0 border-b border-slate-200/50 pb-2.5 sm:pb-3 dark:border-slate-800/60">
<div className="relative z-10 shrink-0">
{isProgramComplete ? (
<p className="text-center text-xs text-muted-foreground">دوره آموزشی تکمیل شده</p>
) : isWaitingForUnlock && waitingForNextSession ? (
@@ -600,7 +587,7 @@ export default function ChatPage() {
<div
ref={messagesScrollRef}
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain px-0.5 py-2.5"
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
{loading && messages.length === 0 ? (
<ChatSkeleton />
@@ -743,7 +730,7 @@ export default function ChatPage() {
)}
</div>
<div className="relative z-20 w-full shrink-0 pt-2 pb-0.5">
<div className="relative z-10 w-full shrink-0 pb-1 pt-1">
<form onSubmit={handleSend} className="w-full">
<div className="composer-glass">
<textarea
@@ -755,9 +742,6 @@ export default function ChatPage() {
setMessage(e.target.value);
resizeComposer();
}}
onFocus={() => {
window.setTimeout(() => scrollToBottom('smooth'), 120);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -770,34 +754,20 @@ export default function ChatPage() {
className="input-neon max-h-36 min-h-12 flex-1 resize-none overflow-y-auto border-0 bg-transparent py-3 text-right text-base leading-6 text-foreground placeholder:text-muted-foreground shadow-none outline-none backdrop-blur-none focus-visible:border-0 focus-visible:shadow-none focus-visible:ring-0 disabled:opacity-60"
aria-label={`پیام به ${APP_NAME}`}
/>
{isStreamBusy ? (
<Button
type="button"
size="icon"
variant="outline"
onClick={stopGenerating}
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 border-sky-400/50 bg-sky-500/15 text-sky-400 hover:bg-sky-500/25 dark:border-sky-400/40 dark:bg-sky-400/10 dark:text-sky-300"
aria-label="توقف تولید"
title="توقف تولید"
>
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
type="submit"
size="icon"
variant="neon"
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
disabled={composerDisabled || !message.trim()}
aria-label="ارسال"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
)}
</Button>
)}
<Button
type="submit"
size="icon"
variant="neon"
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
disabled={composerDisabled || !message.trim()}
aria-label="ارسال"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
)}
</Button>
</div>
</form>
</div>
+33 -92
View File
@@ -1,59 +1,14 @@
import { useEffect, useState, useCallback } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import { Check, CheckCircle2 } from 'lucide-react';
import { FinishedSkeleton } from '@/components/skeletons';
import {
getLatestByPhase,
type AssessmentSubmission,
} from '@/services/assessmentService';
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
import { Button } from '@/components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { getBackendErrorMessage } from '@/lib/backendError';
export default function FinishedPage() {
const { effectiveUserId, guestSessionHydrated } = useAuth();
const { permissions, loading: journeyLoading } = useLearningJourney(
effectiveUserId ?? undefined
);
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
const [postAttempt, setPostAttempt] = useState<AssessmentSubmission | null>(null);
const [preAttempt, setPreAttempt] = useState<AssessmentSubmission | null>(null);
const [loadingAttempts, setLoadingAttempts] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAttempts = useCallback(async () => {
if (!effectiveUserId) return;
setLoadingAttempts(true);
setError(null);
try {
const [latestPost, latestPre] = await Promise.all([
getLatestByPhase(effectiveUserId, 'post'),
getLatestByPhase(effectiveUserId, 'pre'),
]);
setPostAttempt(latestPost);
setPreAttempt(latestPre);
} catch (err) {
console.error('Failed to load finished page assessment attempts:', err);
setError(
getBackendErrorMessage(
err,
'دریافت نتایج با مشکل مواجه شد. لطفاً دوباره تلاش کنید.'
)
);
} finally {
setLoadingAttempts(false);
}
}, [effectiveUserId]);
useEffect(() => {
if (effectiveUserId && guestSessionHydrated) {
void fetchAttempts();
}
}, [effectiveUserId, guestSessionHydrated, fetchAttempts]);
if (!guestSessionHydrated || journeyLoading || loadingAttempts) {
if (!guestSessionHydrated || loading) {
return <FinishedSkeleton />;
}
@@ -65,51 +20,37 @@ export default function FinishedPage() {
return <Navigate to="/" replace />;
}
if (error) {
return (
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
<AlertCircle className="h-10 w-10 text-destructive" aria-hidden />
<h2 className="text-lg font-bold text-foreground">خطا در دریافت نتایج</h2>
<p className="text-sm text-muted-foreground">{error}</p>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => void fetchAttempts()}
>
<RefreshCw className="h-4 w-4" />
<span>تلاش مجدد</span>
</Button>
</div>
</div>
);
}
if (!postAttempt) {
return (
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
<h2 className="text-lg font-bold text-foreground">نتیجه‌ای یافت نشد</h2>
<p className="text-sm text-muted-foreground">
هنوز نتیجه پس‌آزمونی برای حساب شما ثبت نشده است.
</p>
<Button type="button" variant="neon" onClick={() => window.location.assign('/')}>
بازگشت به خانه
</Button>
</div>
</div>
);
}
return (
<div className="relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
<AssessmentResultView
phase="post"
currentSubmission={postAttempt}
baselineSubmission={preAttempt}
/>
<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">
<CheckCircle2 className="h-9 w-9" aria-hidden />
</div>
</div>
<h1 className="text-xl font-bold leading-8 text-foreground sm:text-2xl">
دوره آموزشی شما با موفقیت به پایان رسید.
</h1>
<ul className="glass-panel w-full space-y-3 px-4 py-4 text-right text-sm text-body">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
پیش‌آزمون انجام شده
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
دوره آموزشی تکمیل شده
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
پس‌آزمون انجام شده
</li>
</ul>
<p className="text-sm text-muted-foreground">
از منوی پروفایل می‌توانید وضعیت دوره را ببینید یا خارج شوید.
</p>
</div>
</div>
);
}
+2 -10
View File
@@ -144,7 +144,7 @@ export default function ProfileCompletionPage() {
id="profile-age"
type="text"
inputMode="numeric"
placeholder={`حداقل ${PROFILE_AGE_MIN} سال`}
placeholder="سن"
value={age}
onChange={(event) => {
setAge(event.target.value);
@@ -152,17 +152,9 @@ export default function ProfileCompletionPage() {
}}
disabled={isLoading}
autoComplete="off"
min={PROFILE_AGE_MIN}
aria-invalid={Boolean(fieldError.age)}
aria-describedby="profile-age-hint"
/>
{fieldError.age ? (
<p className="text-xs text-red-400">{fieldError.age}</p>
) : (
<p id="profile-age-hint" className="text-xs text-muted-foreground">
حداقل سن ورود {PROFILE_AGE_MIN} سال است
</p>
)}
{fieldError.age ? <p className="text-xs text-red-400">{fieldError.age}</p> : null}
</div>
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
-119
View File
@@ -118,123 +118,6 @@ async function request<T>(
return (body as ApiSuccess<T>).data;
}
export type SseEvent<T = any> = {
event: string;
data: T;
};
export async function streamRequest(
path: string,
options: {
method?: string;
body?: unknown;
signal?: AbortSignal;
onEvent: (event: SseEvent) => void;
},
): Promise<void> {
const url = `${baseUrl}${path}`;
const token = getSessionToken();
const headers = new Headers();
headers.set('Accept', 'text/event-stream');
headers.set('Content-Type', 'application/json');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
let response: Response;
try {
response = await fetch(url, {
method: options.method ?? 'POST',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
} catch {
if (options.signal?.aborted) {
return;
}
throw new BackendError(
0,
'NETWORK_ERROR',
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
);
}
if (response.status === 401) {
clearSessionToken();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
}
throw new BackendError(401, 'UNAUTHORIZED', 'نشست شما منقضی شده است. لطفا دوباره وارد شوید.');
}
if (!response.ok) {
let errBody: any;
try {
errBody = await response.json();
} catch {
// ignore
}
throw new BackendError(
response.status,
errBody?.error?.code ?? 'SERVER_ERROR',
errBody?.error?.message ?? `Request failed with status ${response.status}`,
errBody?.error?.details,
);
}
if (!response.body) {
throw new BackendError(0, 'NO_BODY', 'پاسخی از سرور دریافت نشد.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.split('\n');
let eventName = 'message';
let dataStr = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataStr = line.slice(5).trim();
}
}
if (dataStr) {
try {
const parsed = JSON.parse(dataStr);
options.onEvent({ event: eventName, data: parsed });
} catch {
options.onEvent({ event: eventName, data: dataStr });
}
}
}
}
} catch (err) {
if (options.signal?.aborted) {
return;
}
throw err;
} finally {
reader.releaseLock();
}
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
@@ -248,6 +131,4 @@ export const api = {
body: data !== undefined ? JSON.stringify(data) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
stream: streamRequest,
};
+2 -1
View File
@@ -334,7 +334,8 @@ export async function submitHomeworkReport(
/**
* Check if a specific session is unlocked for a user.
* Session N unlocks immediately when previous sessions 1…N-1 are complete.
* Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed
* since session N-1 was completed.
*/
export function isSessionUnlocked(userId: string, sessionOrder: number): boolean {
const cachedSessions = getSessions(userId);
+21 -24
View File
@@ -173,12 +173,6 @@
}
}
@supports (height: 100dvh) {
html {
height: 100dvh;
}
}
html.dark {
color-scheme: dark;
}
@@ -899,7 +893,7 @@
}
.bubble-ai {
@apply rounded-2xl px-1 py-1 text-[15px] sm:text-base leading-[1.85];
@apply rounded-2xl px-1 py-1 text-base leading-7;
background: transparent;
border: none;
box-shadow: none;
@@ -909,29 +903,22 @@
.bubble-ai p,
.bubble-ai li,
.bubble-ai .chat-prose {
text-align: right;
line-height: 1.85;
text-align: justify;
text-align-last: right;
text-justify: inter-word;
}
/* ChatGPT-like breathing room between blocks */
.bubble-ai > div {
@apply space-y-3.5;
@apply space-y-4;
}
.bubble-ai p + p {
margin-top: 0.875rem;
}
.bubble-ai h1 {
@apply mt-6 mb-3 text-[22px] sm:text-2xl font-bold leading-[1.45] text-foreground first:mt-1;
}
.bubble-ai h2 {
@apply mt-5 mb-2.5 text-[19px] sm:text-[20px] font-bold leading-[1.5] text-foreground first:mt-1;
margin-top: 0.35rem;
}
.bubble-ai h3 {
@apply mt-4 mb-2 text-[17px] sm:text-[18px] font-bold leading-[1.5] text-foreground first:mt-1;
@apply mt-1 mb-0.5;
}
.bubble-user .chat-prose {
@@ -939,14 +926,13 @@
max-width: 100%;
text-align: right;
white-space: pre-wrap;
line-height: 1.75;
}
.bubble-ai strong {
font-weight: 700;
font-weight: 600;
}
/* Digits stay readable under RTL without breaking line layout. */
/* Digits stay readable under RTL justify without breaking line layout. */
.bubble-ai .chat-num,
.bubble-user .chat-num {
color: hsl(var(--foreground));
@@ -958,11 +944,22 @@
.bubble-ai ol,
.bubble-ai ul {
list-style: none;
list-style-position: outside;
padding-inline-start: 1.35rem;
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;
}
/* App chrome — sticky top bar */
.app-header {
position: sticky;