Polish Novya UI with brand colors, first-load skeletons, and session transition overlay.

Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-09 01:31:24 +03:30
co-authored by Cursor
parent 4ac0c16665
commit 8016b8f45e
56 changed files with 1874 additions and 529 deletions
+2
View File
@@ -121,6 +121,8 @@ Timestamps are authoritative. Derived steps:
Permissions on `GET /journey`: `canTakePreTest`, `canOpenTraining`, `canTakePostTest`, `isCompleted`.
`canTakePostTest` is true after pre-test when either training is fully completed **or** the learner has completed sessions 1–10 (reached Session 11), and the post-test is not yet submitted.
## API Endpoints
### Health
+145
View File
@@ -0,0 +1,145 @@
/**
* Dev helper: jump a password user to Session 11 chat (sessions 1–10 done,
* pre-test done, training/post-test NOT done) so you can test the post-test handoff.
*
* Usage:
* npx tsx scripts/jump-to-session-11.ts alireza@gmail.com
*/
import { AttemptPhase, AttemptStatus, SessionProgressStatus } from '@prisma/client';
import { prisma } from '../src/lib/prisma.ts';
import { PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.ts';
const emailArg = process.argv[2]?.trim().toLowerCase();
if (!emailArg) {
console.error('Usage: npx tsx scripts/jump-to-session-11.ts <email>');
process.exit(1);
}
async function main() {
const identity = await prisma.authIdentity.findFirst({
where: {
provider: 'password',
OR: [{ email: emailArg }, { providerSubject: emailArg }],
},
include: {
user: { include: { journey: true, profile: true } },
},
});
if (!identity) {
throw new Error(`No password account found for ${emailArg}`);
}
const userId = identity.userId;
const now = new Date();
const hourAgo = (n: number) => new Date(now.getTime() - n * 60 * 60 * 1000);
await prisma.$transaction(async (tx) => {
await tx.learningJourney.upsert({
where: { userId },
create: {
userId,
currentStep: 'training_in_progress',
preTestCompletedAt: hourAgo(20),
trainingStartedAt: hourAgo(19),
trainingCompletedAt: null,
postTestCompletedAt: null,
lastActivityAt: now,
},
update: {
currentStep: 'training_in_progress',
preTestCompletedAt: identity.user.journey?.preTestCompletedAt ?? hourAgo(20),
trainingStartedAt: identity.user.journey?.trainingStartedAt ?? hourAgo(19),
trainingCompletedAt: null,
postTestCompletedAt: null,
lastActivityAt: now,
},
});
const existingPre = await tx.assessmentAttempt.findFirst({
where: { userId, phase: AttemptPhase.pre, status: AttemptStatus.scored },
});
if (!existingPre) {
const preAt = hourAgo(20);
await tx.assessmentAttempt.create({
data: {
userId,
phase: AttemptPhase.pre,
status: AttemptStatus.scored,
submittedAt: preAt,
scoredAt: preAt,
},
});
}
// Clear final-session / post-test leftovers so the handoff can be retested.
await tx.sessionProgress.deleteMany({
where: { userId, sessionOrder: PROGRAM_SESSION_COUNT },
});
await tx.chatSession.deleteMany({
where: { userId, sessionOrder: PROGRAM_SESSION_COUNT },
});
await tx.assessmentAttempt.deleteMany({
where: { userId, phase: AttemptPhase.post },
});
for (let order = 1; order < PROGRAM_SESSION_COUNT; order += 1) {
const completedAt = hourAgo(PROGRAM_SESSION_COUNT - order);
await tx.sessionProgress.upsert({
where: { userId_sessionOrder: { userId, sessionOrder: order } },
create: {
userId,
sessionOrder: order,
status: SessionProgressStatus.completed,
startedAt: completedAt,
completedAt,
completionMetadata: {
reason: 'Dev jump-to-session-11 helper',
sessionComplete: true,
},
},
update: {
status: SessionProgressStatus.completed,
startedAt: completedAt,
completedAt,
completionMetadata: {
reason: 'Dev jump-to-session-11 helper',
sessionComplete: true,
},
},
});
}
});
const progress = await prisma.sessionProgress.findMany({
where: { userId, status: SessionProgressStatus.completed },
select: { sessionOrder: true },
orderBy: { sessionOrder: 'asc' },
});
const journey = await prisma.learningJourney.findUnique({ where: { userId } });
console.log(
JSON.stringify(
{
email: identity.email ?? identity.providerSubject,
profileName: identity.user.profile?.name ?? null,
completedSessions: progress.map((p) => p.sessionOrder),
nextSession: PROGRAM_SESSION_COUNT,
trainingCompletedAt: journey?.trainingCompletedAt ?? null,
postTestCompletedAt: journey?.postTestCompletedAt ?? null,
hint: 'Login → open Chat. You should land on Session 11 review. Finish the wrap-up to reach post-test.',
},
null,
2,
),
);
}
main()
.catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Dev helper: reset password for a password-auth user.
* Usage: npx tsx scripts/reset-user-password.ts <email> <newPassword>
*/
import { prisma } from '../src/lib/prisma.ts';
import { hashPassword } from '../src/modules/auth/password.ts';
const email = process.argv[2]?.trim().toLowerCase();
const nextPassword = process.argv[3];
if (!email || !nextPassword) {
console.error('Usage: npx tsx scripts/reset-user-password.ts <email> <newPassword>');
process.exit(1);
}
if (nextPassword.length < 8 || nextPassword.length > 128) {
console.error('Password must be 8–128 characters');
process.exit(1);
}
const identity = await prisma.authIdentity.findFirst({
where: {
provider: 'password',
OR: [{ email }, { providerSubject: email }],
},
});
if (!identity) {
console.error(`No password account found for ${email}`);
process.exit(1);
}
const passwordHash = await hashPassword(nextPassword);
await prisma.authIdentity.update({
where: { id: identity.id },
data: { passwordHash },
});
console.log(
JSON.stringify(
{
email: identity.email ?? identity.providerSubject,
passwordUpdated: true,
},
null,
2,
),
);
await prisma.$disconnect();
@@ -0,0 +1,74 @@
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.ts';
import { getSessionByOrder } from '../src/modules/sessions/catalog.ts';
import {
processCoachAssistantResponse,
looksLikeFinalPostTestHandoff,
COACH_SESSION_COMPLETE_OPEN,
COACH_SESSION_COMPLETE_CLOSE,
} from '../src/modules/ai/sessionCompletion.ts';
const s11 = getSessionByOrder(11);
if (!s11) throw new Error('no s11');
const ctx = buildAiCoachSessionContext(s11);
const samples = [
'دوره آموزشی شما تمام شد. حالا پس‌آزمون را در اپ انجام دهید.',
'جلسات تموم شد؛ نوبت پس‌آزمون است.',
'مرور تمام شد. لطفا پس‌آزمون را انجام دهید.',
'فقط یک جمله معمولی بدون اشاره.',
];
let failed = 0;
for (const t of samples) {
const handoff = looksLikeFinalPostTestHandoff(t);
const expect = t !== samples[3];
if (handoff !== expect) {
failed += 1;
console.error('handoff mismatch', { t, handoff, expect });
}
}
const text = 'مرور جلسات تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.';
const r = processCoachAssistantResponse(text, ctx, { userTurnCount: 4, alreadyCompleted: false });
if (!r.validation?.valid || r.signal?.sessionOrder !== 11 || r.signal?.readyForNextSession !== false) {
failed += 1;
console.error('synthetic completion failed', r.validation, r.signal);
}
const badSignal = {
sessionComplete: true,
sessionId: 'wrong',
sessionOrder: 11,
reason: 'x',
coveredTopics: ['نامرتبط'],
completedExercises: [],
userConfirmed: true,
readyForNextSession: true,
};
// Without handoff text, a bad marker must NOT finish the course.
const badNoHandoff = processCoachAssistantResponse(
`ادامه مرور\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`,
ctx,
{ userTurnCount: 3, alreadyCompleted: false },
);
if (badNoHandoff.validation?.valid) {
failed += 1;
console.error('bad marker without handoff should not validate', badNoHandoff.validation);
}
const bad = processCoachAssistantResponse(
`دوره تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`,
ctx,
{ userTurnCount: 3, alreadyCompleted: false },
);
if (!bad.validation?.valid || bad.signal?.sessionId !== ctx.sessionId || bad.signal?.readyForNextSession !== false) {
failed += 1;
console.error('repair with handoff failed', bad.validation, bad.signal);
}
if (failed > 0) {
console.error(`FAILED ${failed}`);
process.exit(1);
}
console.log('session-11 completion recovery OK');
+34 -10
View File
@@ -21,12 +21,23 @@ const stripHtml = (html: string): string =>
.replace(/\s+/g, ' ')
.trim();
const mentionsPretest = (text: string): boolean =>
text.includes('پیش‌آزمون') || text.includes('پیش آزمون');
const isPretestExercise = (block: ContentBlock): boolean => {
if (block.type !== 'exercise') return false;
const haystack = `${block.title ?? ''} ${block.prompt ?? ''}`;
return haystack.includes('پیش‌آزمون') || haystack.includes('پیش آزمون');
return mentionsPretest(haystack);
};
/** Remove pre-test clauses so the model never sees them as a spoken «next section». */
const stripPretestFromSpeech = (text: string): string =>
text
.replace(/\s*و\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '')
.replace(/\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '')
.replace(/\s+/g, ' ')
.trim();
const hasUserDirectedSpousePrompt = (text: string): boolean =>
text.includes('همسر خود') ||
text.includes('با همسر') ||
@@ -35,18 +46,26 @@ const hasUserDirectedSpousePrompt = (text: string): boolean =>
const formatBlock = (block: ContentBlock): string | null => {
if (isPretestExercise(block)) {
return `[silent-skip exercise] «پیش‌آزمون» is an in-app assessment, NOT course teaching. NEVER mention پیش‌آزمون, پیش آزمون, pre-test, or PCQ to the user. Skip this block silently and continue the educational sequence.`;
return `[silent-skip exercise] Pre-test is OUTSIDE chat and already done before Session 1. NEVER say پیش‌آزمون / پیش آزمون / pre-test / PCQ. NEVER announce it as «بخش بعدی». NEVER say it is done separately in the app. Skip silently; after the last educational section, go to session wrap-up — do not invent a fake next teaching section about assessment.`;
}
switch (block.type) {
case 'callout':
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
case 'heading':
if (mentionsPretest(block.text)) {
// Omit from spoken roadmap/content — listing it causes the model to announce it.
return null;
}
return `[heading h${block.level ?? 3}] ${block.text}`;
case 'text': {
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
if (body.includes('پیش‌آزمون') || body.includes('پیش آزمون')) {
return `[text — OMIT پیش‌آزمون when speaking; it is not part of the educational conversation] ${body}`;
if (mentionsPretest(body)) {
const cleaned = stripPretestFromSpeech(body);
if (!cleaned || cleaned.length < 8) {
return null;
}
return `[text] ${cleaned}`;
}
return `[text] ${body}`;
}
@@ -131,6 +150,8 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str
if (b.type !== 'heading') return null;
const text = b.text.replace(/\s+/g, ' ').trim();
if (titleNorm && text === titleNorm) return null;
// Never list pre-test in the spoken roadmap (learner already completed it in-app).
if (mentionsPretest(text)) return null;
if (seen.has(text)) return null;
seen.add(text);
return `- ${text}`;
@@ -183,10 +204,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory):
• that learning happens through conversation and interaction along a guided path
- Introduce yourself as «نویا» at most ONCE in this first greeting. After that, never repeat identity lines.
- Then introduce Session 1 «${context.title}», list THIS session's contents from SESSION OUTLINE, and begin teaching the first section.
- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow and is NOT part of the educational conversation.
- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user. When you reach it internally: skip it silently and continue Session 1 teaching.
- If a Session 1 text block mentions that a pre-test is administered: OMIT that phrase when speaking. Teach orientation only.
- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue the lesson.
- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow done BEFORE chat and is NOT part of the educational conversation.
- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user.
- CRITICAL: Do NOT announce pre-test as the next section. FORBIDDEN meta lines (and any paraphrase): «بخش بعدی پیش‌آزمون است»، «پیش‌آزمون به صورت جداگانه در اپلیکیشن انجام می‌شود»، «ما در اینجا به آن نمی‌پردازیم»، «پیش‌آزمون را اینجا انجام نمی‌دهیم». If you skip it, skip with ZERO words about it.
- After the last educational section («ضوابط مربوط به درمان»), Session 1 teaching is done. Silent-skip pre-test internally, then move to wrap-up / close confirmation — do not invent another teaching section about assessment.
- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue or wrap up the lesson.
- Follow Session 1 blocks in their defined order (orientation → topic sections → later items). Do NOT ask the user to choose between «نقش‌ها / مسئولیت‌ها» and «چارچوب برنامه» or any other lesson branches.
- CRITICAL THIN-CONTENT RULE: Several Session 1 sections are topic markers only (e.g. «نقش‌های روان‌شناس»، «مسئولیت‌های شرکت‌کنندگان»، «ضوابط مربوط به درمان»). Body text may only say that these are explained — without listing them. In that case: briefly restate the available wording / purpose of the section, ask ONE focused check-in when useful, then advance. Do NOT invent role lists, responsibility lists, treatment rules, clinical duties, ethics codes, or therapy-frame details from general psychological knowledge.
- Preserve course framing words that appear in the blocks (e.g. «درمان»، «روان‌شناس») when teaching, but still present yourself as an educational coach delivering this program — not as a licensed clinician providing psychotherapy.
@@ -327,7 +349,9 @@ STRICT COURSE GROUNDING (mandatory — highest priority after safety):
ASSESSMENT BOUNDARY (mandatory — Chat is NOT an assessment tool):
- PCQ-24 pre-test and post-test are separate in-app assessment flows handled outside Chat. They are NOT educational course content.
- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, or say it was already completed.
- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, disclaimer it, or say it was already completed.
- FORBIDDEN disclaimers / transitions (never say these or paraphrases): talking about پیش‌آزمون as «بخش بعدی», saying it happens separately in the app, saying you will not cover it here, or explaining why you skip it. Silent skip means ZERO user-facing words about it.
- SESSION OUTLINE must never include پیش‌آزمون. Do not list it in the roadmap. After «ضوابط مربوط به درمان», wrap up the session — do not invent a next educational block about assessment.
- You must NEVER in chat:
• administer another psychological assessment or screening
• invent questionnaire items or substitute pre-test/post-test activities
@@ -472,7 +496,7 @@ TEACHING LOOP:
6. Then advance to the next block in order without asking them to pick the path.
7. NEVER dump the entire lesson in one message. Listing the outline is not dumping the lesson.
8. NEVER invent exercises, reflections, quotes, role lists, questionnaires, rating scales, or topics not present in the session blocks above.
9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip — NEVER mention it to the user. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached.
9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip with ZERO words about it — do not announce «بخش بعدی» as pre-test; after educational sections, wrap up. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached.
10. NEVER convert a teaching question into a multi-item psychological survey or symptom checklist.
11. When criteria in SESSION COMPLETION SIGNAL are fully met, emit the structured completion block (see below). The application validates it and saves progress — you do NOT save progress yourself.
12. Stay on this session until completion is validated; do not teach locked/future sessions.
+114 -14
View File
@@ -193,16 +193,84 @@ export function validateCoachSessionCompletionSignal(
return { valid: true, signal: s as CoachSessionCompletionSignal };
}
/** Final session: coach directed learner to in-app post-test without emitting the hidden block. */
const looksLikeFinalPostTestHandoff = (text: string): boolean => {
const normalized = text.replace(/\u200c/g, '').replace(/\s+/g, ' ');
/** Strip ZWNJ and collapse whitespace for Persian phrase matching. */
const normalizePersian = (text: string): string =>
text.replace(/\u200c/g, '').replace(/\s+/g, ' ').trim();
/**
* Final session: coach wrapped up the review and directed the learner to the in-app post-test,
* with or without a hidden completion block.
*/
export const looksLikeFinalPostTestHandoff = (text: string): boolean => {
const normalized = normalizePersian(text);
if (!normalized) return false;
const mentionsPostTest = /پس[\s\-]?آزمون/.test(normalized);
const directsToApp =
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|انجام بده|بزن)/.test(normalized) ||
/(جلسه|دوره).{0,40}(تمام|تموم|پایان|تمام شده)/.test(normalized);
return mentionsPostTest && directsToApp;
const courseFinished =
/(جلسه|جلسات|دوره|آموزش|برنامه).{0,50}(تمام|تموم|پایان|به پایان|تمام شده|تموم شده|به اتمام)/.test(
normalized,
) ||
/(تمام|تموم|پایان).{0,40}(جلسه|جلسات|دوره|آموزش|برنامه)/.test(normalized) ||
/مرور.{0,30}(تمام|تموم|پایان)/.test(normalized);
const directsToAppOrTest =
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|بزن|انجام بده|انجام دهید|بدهید|هدایت)/.test(normalized) ||
/(حالا|الان|اکنون).{0,30}(پس[\s\-]?آزمون|آزمون)/.test(normalized) ||
/(نوبت|وقت).{0,20}(پس[\s\-]?آزمون|آزمون)/.test(normalized);
// Strong signal: post-test + (app/direction OR course finished)
if (mentionsPostTest && (directsToAppOrTest || courseFinished)) {
return true;
}
// Soft signal: post-test mentioned together with explicit closing language
if (mentionsPostTest && /(جمع[\s\-]?بندی نهایی|ختم دوره|خداحافظ|موفق باشید)/.test(normalized)) {
return true;
}
return false;
};
/** Repair common AI mistakes on final-session completion payloads. */
export function normalizeFinalSessionSignal(
signal: Partial<CoachSessionCompletionSignal>,
context: AiCoachSessionContext,
reason?: string,
): CoachSessionCompletionSignal {
const base = exampleCompletionSignal(context);
const headings = extractSessionHeadings(context.blocks);
const minTopicsRequired = Math.max(1, Math.ceil(headings.length * 0.6));
const incomingTopics =
isStringArray(signal.coveredTopics) && signal.coveredTopics.length > 0
? signal.coveredTopics
: [];
const matchedHeadings = headings.filter((heading) => listMatchesAny(incomingTopics, heading));
const coveredTopics =
matchedHeadings.length >= minTopicsRequired ? incomingTopics : base.coveredTopics;
const completedExercises = isStringArray(signal.completedExercises)
? signal.completedExercises
: base.completedExercises;
const completedReflections = isStringArray(signal.completedReflections)
? signal.completedReflections
: base.completedReflections;
return {
sessionComplete: true,
sessionId: context.sessionId,
sessionOrder: context.order,
reason:
(isNonEmptyString(signal.reason) ? signal.reason : null) ??
reason ??
base.reason,
coveredTopics,
completedExercises: completedExercises.length > 0 ? completedExercises : base.completedExercises,
completedReflections,
userConfirmed: true,
readyForNextSession: false,
};
}
export function processCoachAssistantResponse(
rawAssistantText: string,
context: AiCoachSessionContext,
@@ -212,27 +280,59 @@ export function processCoachAssistantResponse(
},
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
const handoff =
context.isFinalSession && looksLikeFinalPostTestHandoff(parsed.displayContent);
let signal = parsed.signal;
// Light repair only: fix identity/flags AI often gets wrong. Do not invent coverage.
if (signal && context.isFinalSession) {
signal = {
...signal,
sessionComplete: true,
sessionId: context.sessionId,
sessionOrder: context.order,
readyForNextSession: false,
userConfirmed: signal.userConfirmed === true || handoff,
};
}
if (
!signal &&
context.isFinalSession &&
!options.alreadyCompleted &&
options.userTurnCount >= 2 &&
looksLikeFinalPostTestHandoff(parsed.displayContent)
handoff
) {
signal = {
...exampleCompletionSignal(context),
reason:
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
};
signal = normalizeFinalSessionSignal(
{},
context,
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
);
}
if (!signal) {
return { ...parsed, validation: null };
}
const validation = validateCoachSessionCompletionSignal(signal, context, options);
let validation = validateCoachSessionCompletionSignal(signal, context, options);
// Full catalog recovery only after a clear post-test handoff in the visible message.
if (
!validation.valid &&
context.isFinalSession &&
!options.alreadyCompleted &&
options.userTurnCount >= 2 &&
handoff
) {
signal = normalizeFinalSessionSignal(
signal,
context,
'Final-session completion recovered with catalog defaults after wrap-up/handoff.',
);
validation = validateCoachSessionCompletionSignal(signal, context, options);
}
return { ...parsed, signal, validation };
}
@@ -5,6 +5,7 @@ import {
getOrCreateJourney,
deriveCurrentStep,
derivePermissions,
countCompletedSessions,
} from '../journey/journey.service.js';
import {
PCQ_QUESTION_IDS,
@@ -94,8 +95,11 @@ export function getPcq24Scale() {
}
export async function submitAttempt(userId: bigint, input: SubmitAttemptInput) {
const journey = await getOrCreateJourney(userId);
const permissions = derivePermissions(journey);
const [journey, completedSessionCount] = await Promise.all([
getOrCreateJourney(userId),
countCompletedSessions(userId),
]);
const permissions = derivePermissions(journey, { completedSessionCount });
if (input.phase === 'pre' && !permissions.canTakePreTest) {
throw new ConflictError('Pre-test already completed');
@@ -212,10 +216,11 @@ export async function getLatestAttempt(userId: bigint, phase: AttemptPhase) {
}
export async function getAssessmentSummary(userId: bigint) {
const [pre, post, journey] = await Promise.all([
const [pre, post, journey, completedSessionCount] = await Promise.all([
getLatestAttempt(userId, AttemptPhase.pre),
getLatestAttempt(userId, AttemptPhase.post),
getOrCreateJourney(userId),
countCompletedSessions(userId),
]);
return {
@@ -223,7 +228,7 @@ export async function getAssessmentSummary(userId: bigint) {
post,
journey: {
currentStep: deriveCurrentStep(journey),
permissions: derivePermissions(journey),
permissions: derivePermissions(journey, { completedSessionCount }),
},
};
}
+16
View File
@@ -8,6 +8,7 @@ import {
} from '../../shared/errors/index.js';
import { getOrCreateJourney, derivePermissions } from '../journey/journey.service.js';
import {
completeSession,
getCompletedOrders,
getSessionCompletionTimes,
isSessionUnlockedForProgress,
@@ -299,6 +300,21 @@ async function runCoachTurn(params: {
},
});
// Persist progress as soon as the coach signal validates so session 11
// reaches 100% / trainingCompletedAt even if the client never retries complete.
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
+37 -8
View File
@@ -1,5 +1,6 @@
import { SessionProgressStatus, type LearningJourney } from '@prisma/client';
import { prisma } from '../../lib/prisma.js';
import type { LearningJourney } from '@prisma/client';
import { PROGRAM_SESSION_COUNT } from '../sessions/catalog.js';
export type LearningStep =
| 'registered'
@@ -26,6 +27,9 @@ export type PublicJourney = {
permissions: JourneyPermissions;
};
/** Sessions 1..(N-1) done ⇒ learner is on final session and may open post-test manually. */
export const POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS = PROGRAM_SESSION_COUNT - 1;
/**
* Timestamps are authoritative. currentStep is a denormalized cache of this derivation:
* registered → pre_test_done → training_in_progress → training_completed → completed
@@ -47,12 +51,25 @@ export function deriveCurrentStep(journey: LearningJourney): LearningStep {
return 'registered';
}
export function derivePermissions(journey: LearningJourney): JourneyPermissions {
export type DerivePermissionsOptions = {
/** Number of educational sessions marked completed (1..11). */
completedSessionCount?: number;
};
export function derivePermissions(
journey: LearningJourney,
options: DerivePermissionsOptions = {},
): JourneyPermissions {
const completedSessionCount = options.completedSessionCount ?? 0;
const reachedFinalSession =
completedSessionCount >= POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS ||
Boolean(journey.trainingCompletedAt);
return {
canTakePreTest: !journey.preTestCompletedAt,
canOpenTraining: Boolean(journey.preTestCompletedAt),
canTakePostTest: Boolean(
journey.preTestCompletedAt && journey.trainingCompletedAt && !journey.postTestCompletedAt,
journey.preTestCompletedAt && reachedFinalSession && !journey.postTestCompletedAt,
),
isCompleted: Boolean(journey.postTestCompletedAt),
};
@@ -62,7 +79,16 @@ function toIso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
export function toPublicJourney(journey: LearningJourney): PublicJourney {
export async function countCompletedSessions(userId: bigint): Promise<number> {
return prisma.sessionProgress.count({
where: { userId, status: SessionProgressStatus.completed },
});
}
export function toPublicJourney(
journey: LearningJourney,
options: DerivePermissionsOptions = {},
): PublicJourney {
const currentStep = deriveCurrentStep(journey);
return {
currentStep,
@@ -71,7 +97,7 @@ export function toPublicJourney(journey: LearningJourney): PublicJourney {
trainingCompletedAt: toIso(journey.trainingCompletedAt),
postTestCompletedAt: toIso(journey.postTestCompletedAt),
lastActivityAt: journey.lastActivityAt.toISOString(),
permissions: derivePermissions(journey),
permissions: derivePermissions(journey, options),
};
}
@@ -87,16 +113,19 @@ export async function getOrCreateJourney(userId: bigint): Promise<LearningJourne
}
export async function getJourneyForUser(userId: bigint): Promise<PublicJourney> {
const journey = await getOrCreateJourney(userId);
const [journey, completedSessionCount] = await Promise.all([
getOrCreateJourney(userId),
countCompletedSessions(userId),
]);
const currentStep = deriveCurrentStep(journey);
if (journey.currentStep !== currentStep) {
const healed = await prisma.learningJourney.update({
where: { userId },
data: { currentStep },
});
return toPublicJourney(healed);
return toPublicJourney(healed, { completedSessionCount });
}
return toPublicJourney(journey);
return toPublicJourney(journey, { completedSessionCount });
}
export function syncCurrentStep(journey: LearningJourney): LearningStep {
@@ -253,6 +253,19 @@ export async function completeSession(
where: { userId, sessionOrder: order, endedAt: null },
data: { endedAt: now },
});
// Repair stuck journeys: all sessions done but trainingCompletedAt never set.
if (completedOrders.length >= PROGRAM_SESSION_COUNT && !journey.trainingCompletedAt) {
await prisma.learningJourney.update({
where: { userId },
data: {
trainingCompletedAt: now,
lastActivityAt: now,
currentStep: 'training_completed',
},
});
}
return getSessionProgress(userId);
}
+2 -1
View File
@@ -224,7 +224,8 @@ describe('full E2E learning journey QA', () => {
const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
assert.equal(
(beforeFinal.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest,
false,
true,
'post-test should unlock once the learner reaches session 11',
);
await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT, owner.user.id);
+4 -11
View File
@@ -2,17 +2,14 @@
<html lang="fa" dir="rtl" class="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<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" />
<meta name="theme-color" content="#e6eef6" />
<meta name="description" content="پلتفرم روان‌شناسی و سلامت روان نویا" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link
href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css"
rel="stylesheet"
type="text/css"
/>
<title>نویا</title>
</head>
<body>
@@ -20,7 +17,3 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="55" viewBox="0 0 100 55">
<image x="37" y="13" width="56" height="36" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAAkCAYAAADckvn+AAAHy0lEQVRYhWSOywmAMBBE3wTswJt12FCKE+zAHjxbzwhZ1Lg5zY8Ho/nYAIEVivDjuy57U77u1RJ854et5cxG/nGJjW34s2BV0AqabF2gHXS2HXEDAAD//7TNoQ3AIAAAwROkrgljMQYztINUsyUCD8EhMIi+/eQ/OCfiRkdF2yemnpPfyCi4lkPCiw8PDAAAAP//wu9BkPv+gz3kx8DA4M7AwGDEwMAgjeTBjwwMDHcZGBiOMzAwbGFgYNhJnHfweZ6ogHFiYGCYj6QBmQYlgUKw2xgYGgEAAAD//3zTsQ2AMBBD0f87KiZIw4TpGYEtWIoyo3CRqKJcRG+7ev4juoEnYQX3THTJ8AGvwHukl4nO3YHocne6BDbCA3wJTddCvyyWDgAA//8CmYQN2DEwMNxmYGCoZWBg4EMKIRD+h4ZhYiCztRgYGBZAY1OIuNgkGWgyMDAoEtAFiU0GhmAAAAAA///C5sFwBgaGgwwMDDJInoJpwGfYPwaG/xCPMjB4MzAwXGZgYBDGVEYxUEcxATM1I9zLwKAMAAAA//9C92AwAwPDCoSDcXoGH4DFsBQDA8M5BgYGLsJayPY8/szK8P83AAAA//9C9qACAwPDGigb5EBii0BYwkcHoBiVY2BgWE+6UzE9DJaGqPlLpLsYGBgY/gEAAAD//0L24CaYIAHPgfRAMdgh2IIfph8k58bAwBBAgqNwALg1xEU3yAUMDAwAAAAA//+CeRDkAF0sRS66FvQkDfMsLvUw8cmY0mQnS3Q34C4nGBj+AAAAAP//gikuw2MrsthpBgaGRAYGBitooCxHsxRXcgUVWLYkegQXQHcjsp0wOQifgeEfAAAA//8CVfSyDAwMlpia/kMVg+oZsIbpDAwMWWjqNoLzGCPDKmglhlxRI5Wu4ADwZ2D4fxjD/8jxTJTfGHE3TtDNYmD4AwAAAP//AlnsiKkOWRW4xryOxXMw+dUMDAxdWGIOPTZBsU7A8UQBUD2IrAF3NcbA8BsAAAD//wJ5UA3KgRUuMEchsf9PIFCmgtp+2NpY/5HsVmVgYGAl1hd4PJxKSAFcmoHhKwAAAP//AnkQ1NbEZTDMwaD2Jj7wloGB4ScBF4LsESFgDiHQCa3O8BWGCHczMDwFAAAA//9CLgGxxRFMTpZAgIkzMDCwY9GHDEB5hwPVCUQnS1DMt0MKQ3DZAEst6KkN1Q8MDJcBAAAA//8CWfocSRG6jTCDMqBtTFygFEk/eoDB+KAK+juSAaDCzYaBgYEfre6FmfMHGiDa0BJbEkfgYcv7IPCcgYHhBgAAAP//AnkQVPTjAyADzRkYGOoYGP43YYloUOmYhxwloKY8vFBFgHcMDAxvQAXYfwZGUGxUoCvAWaBCJAi1iZEAOF62MDAwMAAAAAD//wJ5cB+008qDoy8GE2yE1mVLoT0NIQZGhhCG/wxxUHWwtiuuEL0PjhVGhmyG/5iegxZI0K4PXOg/AwMjFk/h7TPCJPoZGBgYAAAAAP//AnkQlHRmMDAwlOBRDKvLXKAYHWBrmKMDUECCQDOSmSi+IbK3iysrIANQh+E6AwMDAwAAAP//grVAmqAexWU4zFBQAxpZHNZzgKnBph/UaADR66B8QRwOw6YfV8sKZiY2u74yMDAkgXkMDAwAAAAA//+CefAztKuEzWJ0RyB3eHEB9PzynYERnteJiW0S8huK20DAHl6YMTAwAAAAAP//Qmq4/gc1u2rQFJNgONb6EwZArR0YOAOzEK0hAHcIEfZByzBomQbBnxkZGKwZGBjOwlUxMDAAAAAA//9Cb5m3QkerfqPVLejpnig3IHkUVEHDAKzJB+uJoNuD1B0Dd8nQ+BjyILM2MDD812Bg+H8MxQkMDAwAAAAA///CNmSxkIGRQYWBgWEStLQEtVJAGJfj8cc2I3h85hqSyFkGhv+gZISjeiI6DEGtq9mQ8aP/gQwMDM8wVDAwMAAAAAD//1zYsQ2AMBBD0e+wASUTMANrsAlL07ACMYLkIKG9wjrJhaXXqFr8+hMqawVtoAUrd8r1yVdVrFe+sq2hFjRh7a2wNfg7g0acIrfsZ1G1+xa5j54ZJawTdFRZ6/WtlzWwuAAAAP//wtX1AA3shEJ7GvrQwSP0eg6ly4EkB2LDUkYuoqWEtQa4QWx0kQUYGBgAAAAA//9CeBDiNFDzqY2BgSEGzUCMShzaIkG3F9mjoEHgKdRxJpmj5AwMDAAAAAD//4J6EGwAaNT6KFKDGL19iACobSokHtgcUOxdZ2Bk8CSxI0uWB/ACBgYGAAAAAP//giUl0CDtEajnSK3jkIfMQfgCAwODCY4qgL6AgYEBAAAA//+CeRA0ls+Je7yOoEth5oBaK4YMDAzfCPuEmLFkCgEDAwMAAAD//4I5DDRUDzMZV1rBUg/C6ygQyEdqDVEGsLqADE8zMDAAAAAA//+COe4TiXqQPQYaLAYNe4DqTVplJfIAAwMDAAAA//+CObIXiY/cmsDWugABUIDMY2BgMIVWJ6AGARTQJ+mhAFyBysDAAAAAAP//glUTB6BVQx8DA4MYmhpQgQOqy0AtB9BcwyFo1wc0/0ZFQIOSlIGBAQAAAP//Qq7oQR1Z0PA9aHgPNIwA8sAr6CwuqBmEPqhEJCDB4SjVD6Y+sDQp46gMDAwAAAAA//8DALxlzs5xmtVoAAAAAElFTkSuQmCC"/>
<image x="13" y="38" width="10" height="11" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAALCAYAAABGbhwYAAABHklEQVQYlWIU2bGcAQnwMTAwuDEwMEgyMDDcZGBg2AWWY2BgAAAAAP//TMohAQAwCACwCSrc0ohGRCEbWbCfXvwLjcLDIjFwAAAA//+CKWSHSkghaRJmYGAwZWBgcGdgYAgFAAAA//+CKVwHVfQPzQZGBgaGEAYGhiQAAAAA//9iYmBgEGNgYPBCUwAD/8EMBoYSAAAAAP//AinUxqEIARgYxAEAAAD//wIpfEhQGQPDBwAAAAD//wIpBHkCFBS4AQPDXAAAAAD//wIpBAE/KA3io+NLDAwMbQAAAAD//4IpvMXAwKDBwMBwEMnYrwwMDJMZGBj0GRgYGAAAAAD//0IOcJD1DgwMDCIMDAyCDAwMt+EyDAwMAAAAAP//AwCE2RfkDQLlngAAAABJRU5ErkJggg=="/>
<image id="Layer_3_copy" data-name="Layer 3 copy" x="22" y="38" width="11" height="11" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAACprHcmAAABOElEQVQYlWIU2bGcAQ3oMjAwWDEwMPxhYGDYzsDA8Awsz8DAAAAAAP//YkFTaM/AwFDMwMBgzsDA8JeBgcGSgYGhDqyBgYEBAAAA//9MyjENACAMALAGEUtmYz5QwTGbmMEH73p3jdi42AgkDh4KPgAAAP//gilWYmBgmAFl/0PDIHCKgYGBEQAAAP//AikWhVoNU4gOQGLMDAwM/gAAAAD//wIp1mFgYNDEoggVMDCoAQAAAP//Ail+xMDA8IKgUgaG5wAAAAD//wIpvsvAwDAdKsCIRRHEXwwM6wAAAAD//4IxDjMwMCyDKgaJIWMQCGVgYPgKAAAA//9CDrpoBgaGWgYGhndIYpcZGBi8GBgY1jAwMDAAAAAA///CFoPcDAwMhgwMDN8YGBjOwUUZGBgAAAAA//8DAIFAIozDmcghAAAAAElFTkSuQmCC"/>
<image id="Layer_3_copy_2" data-name="Layer 3 copy 2" x="69" y="18" width="11" height="11" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAACprHcmAAABLElEQVQYlWLkXfSIAQ1YMzAwGDAwMLxlYGDYxMDA8A0sz8DAAAAAAP//YkFTGMLAwJDCwMCgy8DA8J6BgUGdgYGhESzDwMAAAAAA//9CNnkKAwNDNro1DAwMT8GaGBi+AgAAAP//YoIK+CIp/IeGpRkYGHYyMDAwAAAAAP//AikWYWBgqEVSiA1YMzAwmAMAAAD//wIpBrlPGYciEPgPJhkYNAEAAAD//wIpfszAwPAOj2JGMMnA8AIAAAD//wIpvsPAwLAAj2IQeM7AwLAHAAAA//+CebCVgYHhPgMDA4gPwiDTYGwQcGZgYPgDAAAA//+CcUBAiYGBYQma1ccZGBj0GBgYrjMwMDAAAAAA///CFoPiDAwMGgwMDC8ZGBhuwEUZGBgAAAAA//8DAJ1dI1qWzFIYAAAAAElFTkSuQmCC"/>
<image x="8" y="12" width="30" height="26" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAaCAYAAACgoey0AAADJElEQVRIiWIU2bGcAQ24MjAw2DEwMPAyMDBcZWBg2MrAwPAMXRFFgIGBAQAAAP//YkEygJuBgWEKAwNDHAMDAxOS+BUGBoYGBgaGtVSzlYGBAQAAAP//glnAwcDAcJ2BgSEBydL/UFqHgYFhDQMDQzbVbGVgYAAAAAD//4JZMoeBgUEWatk/JEthfBAAhYYSVWxlYGAAAAAA//8CWSzGwMAQjSTGiEUdzPIsqtjKwMAAAAAA//8CWWyOxIf5FBkgO8SIKrYyMDAAAAAA//8CWcxFgnpOqtjKwMAAAAAA//8CWfyDCHUwX/+hiq0MDAwAAAAA//8CWYycdQgBbFFBOmBgYAAAAAD//wJZ+heHTpAl6BZhS3ikAwYGBgAAAAD//8JlMcxCqlmEAhgYGAAAAAD//8JmMdWCEydgYGAAAAAA//8CWfwLjwJ0R8hQxVYGBgYAAAAA//8iZDE6UGBgYFCl2FYGBgYAAAAA//8CWfyWgBpYIoOVXlMptpWBgQEAAAD//wJZfJeBgeELkepBDgBVmxMpspWBgQEAAAD//4IF9UUkMVBKRsbIYrA4z2NgYDjAwMAQxcDAIM3AwMAOxaCSDYZBNR4IY5Z2DAwMAAAAAP//gtXHWxgYGKxxZB+YGMxSUJCDxOyhGARg0cWMphZEg9R+ZmBgOMbAwFDNwMBwj4GBgQEAAAD//4K1QMQZGBheoBmMDrBlM1JKPRAAha42AwPDHQAAAAD//4JpfMnAwDAbzYfYALocyJGw+hsZo4vB1LExMDDMYGBgYAAAAAD//0J2cQ60wsDnW2ILF3QzYGkEBKwYGBgkAQAAAP//QrYYFAxOeHyNLySQfUfYUQwM7AAAAAD//0KPo+MMDAwBaBahJxRyAcycJwwMDE8AAAAA///Cljg2MjAwWDAwMNyEWoRcdRLjI3Sfw/gwM5oZGBj+AAAAAP//wpUqTzIwMGgwMDCUwZI/kgOIxbB4RXZ4DwMDwyIGBgYGAAAAAP//wtagxwZMGRgYLBkYGBQZGBh40FqiyMGPnIhg2RKEnzIwMGxiYGA4B5ZhYGAAAAAA//8DAAh7bhdE+snFAAAAAElFTkSuQmCC"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+6 -2
View File
@@ -63,9 +63,13 @@ const checks = {
thinContentRule: prompt.includes('CRITICAL THIN-CONTENT RULE'),
noSubstitutePretest: prompt.includes('Do NOT retake, recreate, simulate, or substitute'),
noRepeatPretestInChat: prompt.includes('Do NOT repeat the pre-test inside Chat'),
silentSkipPretest: prompt.includes('silent skip'),
omitPretestWhenSpeaking: prompt.includes('OMIT that phrase when speaking'),
silentSkipPretest: prompt.includes('silent skip') || prompt.includes('silent-skip'),
omitPretestWhenSpeaking: prompt.includes('ZERO words about it'),
neverMentionPretestToUser: prompt.includes('NEVER mention «پیش‌آزمون»'),
noPretestNextSectionAnnounce: prompt.includes('Do NOT announce pre-test as the next section'),
noPretestAppDisclaimer: prompt.includes('به صورت جداگانه در اپلیکیشن انجام می‌شود'),
noPretestInOutline: !prompt.includes('SESSION OUTLINE') || !prompt.match(/SESSION OUTLINE[\s\S]*?- پیش‌آزمون/),
pretestStrippedFromIntroText: !prompt.includes('و پیش‌آزمون اجراء می‌شود'),
assessmentResultsRule: prompt.includes('do NOT invent interpretation'),
completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'),
completionMarker: prompt.includes('COACH_SESSION_COMPLETE'),
+2 -5
View File
@@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute';
import { AppHeader } from '@/components/AppHeader';
import { HomeRedirect } from '@/components/HomeRedirect';
import { useTheme } from '@/hooks/useTheme';
import { PageSkeleton } from '@/components/skeletons';
const AccountPage = lazy(() => import('@/pages/Account/AccountPage'));
const ChatPage = lazy(() => import('@/pages/Chat/ChatPage'));
@@ -14,11 +15,7 @@ const LoginPage = lazy(() => import('@/pages/Auth/Login/LoginPage'));
const SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage'));
const ProfileCompletionPage = lazy(() => import('@/pages/Profile/ProfileCompletionPage'));
const PageLoader = () => (
<div className="flex min-h-[50vh] flex-1 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-neon-cyan/30 border-t-neon-cyan shadow-glow-sm" />
</div>
);
const PageLoader = () => <PageSkeleton />;
function AppRoutes() {
return (
+50 -19
View File
@@ -3,16 +3,18 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/hooks/useAuth';
import { APP_NAME } from '@/utils/constants';
import { BrandLogo } from '@/components/BrandLogo';
import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react';
import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion';
import { useTheme } from '@/hooks/useTheme';
import { cn } from '@/lib/utils';
/**
* Minimal top shell: product mark + profile menu (Course Status, Logout).
* Replaces the former bottom navigation.
*/
export function AppHeader() {
const { isAuthenticated, signOut, isGuest } = useAuth();
const { isAuthenticated, signOut, isGuest, user } = useAuth();
const { toggleTheme, isDark } = useTheme();
const navigate = useNavigate();
const location = useLocation();
@@ -21,6 +23,9 @@ export function AppHeader() {
const menuRef = useRef<HTMLDivElement>(null);
const isAuthPage = location.pathname === '/login' || location.pathname === '/signup';
const usernameLabel = isGuest
? 'مهمان'
: user?.email?.trim() || 'کاربر';
useEffect(() => {
setMenuOpen(false);
@@ -35,8 +40,16 @@ export function AppHeader() {
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMenuOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [menuOpen]);
const handleSignOut = async () => {
@@ -52,24 +65,31 @@ export function AppHeader() {
return (
<>
<header className="app-header border-b border-border/40 backdrop-blur-2xl">
<header className="app-header">
<div className="mx-auto flex h-14 w-full items-center justify-between gap-3 px-4">
<Link
to="/"
className="truncate text-base font-semibold tracking-tight text-foreground"
className="inline-flex shrink-0 items-center transition-opacity duration-300 hover:opacity-90"
aria-label={APP_NAME}
>
{APP_NAME}
<BrandLogo imgClassName="h-7 sm:h-8" />
</Link>
<div className="relative" ref={menuRef}>
<motion.button
type="button"
onClick={() => setMenuOpen((open) => !open)}
whileHover={{ scale: 1.05 }}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
transition={fastTransition}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-cyan-200 bg-white text-cyan-700 shadow-sm transition-colors hover:border-cyan-500 hover:text-cyan-800 dark:border-neon-cyan/40 dark:bg-background/70 dark:text-neon-cyan dark:shadow-none dark:hover:border-neon-cyan dark:hover:text-neon-cyan dark:hover:shadow-glow-sm"
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-200',
'border-slate-200 bg-white text-sky-700 shadow-sm',
'hover:border-sky-300 hover:bg-sky-50 hover:text-sky-800',
'dark:border-slate-600/60 dark:bg-slate-800 dark:text-sky-300 dark:shadow-none',
'dark:hover:border-slate-500 dark:hover:bg-slate-700 dark:hover:text-sky-200',
menuOpen && 'border-sky-400 ring-2 ring-sky-400/25 dark:border-sky-500 dark:ring-sky-500/20'
)}
aria-label="منوی پروفایل"
aria-expanded={menuOpen}
aria-haspopup="menu"
@@ -80,39 +100,50 @@ export function AppHeader() {
<AnimatePresence>
{menuOpen && (
<motion.div
key="menu-panel"
role="menu"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
initial={{ opacity: 0, scale: 0.96, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: -4 }}
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
className="glass-panel-strong absolute left-0 z-[70] mt-2 w-52 origin-top-left p-1"
className="header-menu-panel"
>
<div className="header-menu-identity" aria-label="نام کاربری">
<p className="text-[11px] font-medium text-muted-foreground">نام کاربری</p>
<p
className="truncate text-sm font-semibold text-foreground"
title={usernameLabel}
dir="ltr"
>
{usernameLabel}
</p>
</div>
<Link
to="/course-status"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-body transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5 dark:hover:text-white"
className="header-menu-item"
onClick={() => setMenuOpen(false)}
>
<ClipboardList className="h-4 w-4 shrink-0 text-neon-cyan" />
<ClipboardList className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
وضعیت دوره
</Link>
<button
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-body transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5 dark:hover:text-white"
className="header-menu-item"
onClick={toggleTheme}
>
{isDark ? (
<Sun className="h-4 w-4 shrink-0 text-neon-cyan" />
<Sun className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
) : (
<Moon className="h-4 w-4 shrink-0 text-neon-cyan" />
<Moon className="h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400" />
)}
{isDark ? 'حالت روشن' : 'حالت تاریک'}
</button>
<button
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-500/10 dark:text-red-300"
className="header-menu-item header-menu-item-danger"
onClick={() => {
setMenuOpen(false);
setShowLogoutConfirm(true);
@@ -129,10 +160,10 @@ export function AppHeader() {
</header>
{showLogoutConfirm && (
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-navy-deep/70 p-4 backdrop-blur-sm sm:items-center">
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-slate-900/50 p-4 backdrop-blur-md sm:items-center">
<div className="glass-panel-strong w-full max-w-sm space-y-4 p-6">
<h3 className="text-lg font-semibold text-foreground">خروج از حساب کاربری</h3>
<p className="text-sm text-body">آیا مطمئن هستید که می‌خواهید خارج شوید؟</p>
<p className="text-sm leading-relaxed text-body">آیا مطمئن هستید که می‌خواهید خارج شوید؟</p>
<div className="flex gap-3">
<button
type="button"
+29
View File
@@ -0,0 +1,29 @@
import { APP_NAME } from '@/utils/constants';
import { cn } from '@/lib/utils';
type BrandLogoProps = {
className?: string;
/** Image height class; width follows aspect ratio */
imgClassName?: string;
alt?: string;
};
/**
* Product wordmark from /logo/mainlogo.png (transparent background).
*/
export const BrandLogo = ({
className,
imgClassName,
alt = APP_NAME,
}: BrandLogoProps) => {
return (
<span className={cn('inline-flex items-center', className)}>
<img
src="/logo/mainlogo.png"
alt={alt}
className={cn('h-8 w-auto select-none object-contain', imgClassName)}
draggable={false}
/>
</span>
);
};
+2 -5
View File
@@ -1,6 +1,7 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import { PageSkeleton } from '@/components/skeletons';
/**
* Entry router at "/".
@@ -11,11 +12,7 @@ export function HomeRedirect() {
const { progress, loading } = useLearningJourney(effectiveUserId ?? undefined);
if (!guestSessionHydrated || (effectiveUserId && loading)) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="border-primary h-12 w-12 animate-spin rounded-full border-b-2" />
</div>
);
return <PageSkeleton />;
}
if (!effectiveUserId) {
+3 -8
View File
@@ -10,6 +10,7 @@ import {
} from '@/atoms/auth';
import { hasSessionToken } from '@/services/backend';
import { ROUTES } from '@/utils/constants';
import { PageSkeleton } from '@/components/skeletons';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -17,12 +18,6 @@ interface ProtectedRouteProps {
allowIncompleteProfile?: boolean;
}
const AuthBootSpinner = () => (
<div className="flex h-full min-h-0 flex-1 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-neon-cyan/30 border-t-neon-cyan" />
</div>
);
export function ProtectedRoute({ children, allowIncompleteProfile = false }: ProtectedRouteProps) {
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
const [backendUser] = useAtom(backendUserAtom);
@@ -40,11 +35,11 @@ export function ProtectedRoute({ children, allowIncompleteProfile = false }: Pro
const canUseBackendUser = Boolean(backendUser && hasToken);
if (!authInitialCheckDone) {
return <AuthBootSpinner />;
return <PageSkeleton />;
}
if (!guestSessionHydrated) {
return <AuthBootSpinner />;
return <PageSkeleton />;
}
if (!isAuthenticated && !guestSession) {
+91
View File
@@ -0,0 +1,91 @@
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
type ChatSkeletonProps = {
className?: string;
};
/** Message-list placeholders for chat bootstrap. */
export const ChatSkeleton = ({ className }: ChatSkeletonProps) => (
<div className={cn('space-y-5 py-2', className)} aria-hidden>
{[0, 1, 2].map((row) => (
<div key={row} className={cn('flex w-full flex-col gap-2', row % 2 === 1 ? 'items-end' : 'items-start')}>
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-2.5 w-10" />
</div>
<Skeleton
className={cn('h-20 rounded-2xl', row % 2 === 1 ? 'w-[72%]' : 'w-[88%]')}
/>
</div>
))}
</div>
);
/** Course-status cards placeholder. */
export const CourseStatusSkeleton = ({ className }: { className?: string }) => (
<div className={cn('mx-auto w-full max-w-md space-y-4 px-4 py-6', className)} aria-hidden>
<div className="space-y-2 pb-2">
<Skeleton className="ms-auto h-7 w-36" />
<Skeleton className="ms-auto h-4 w-24" />
</div>
{[0, 1, 2].map((i) => (
<div
key={i}
className="rounded-2xl border border-slate-200/80 bg-white/70 p-4 dark:border-slate-700/50 dark:bg-slate-800/50"
>
<Skeleton className="ms-auto mb-3 h-4 w-28" />
<Skeleton className="ms-auto mb-2 h-3 w-full max-w-[14rem]" />
{i === 1 ? <Skeleton className="mt-3 h-2.5 w-full rounded-full" /> : null}
</div>
))}
</div>
);
/** Assessment question surface placeholder. */
export const AssessmentSkeleton = ({ className }: { className?: string }) => (
<div className={cn('mx-auto flex w-full max-w-lg flex-col gap-6 px-4 py-8', className)} aria-hidden>
<Skeleton className="h-2 w-full rounded-full" />
<div className="space-y-3">
<Skeleton className="ms-auto h-4 w-24" />
<Skeleton className="ms-auto h-6 w-full max-w-sm" />
<Skeleton className="ms-auto h-6 w-4/5 max-w-xs" />
</div>
<div className="space-y-3">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
);
/** Generic centered page section skeleton (auth boot / redirects). */
export const PageSkeleton = ({ className }: { className?: string }) => (
<div
className={cn('flex h-full min-h-[50vh] flex-1 flex-col items-center justify-center gap-4 px-6', className)}
aria-hidden
>
<Skeleton className="h-14 w-14 rounded-full" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
<div className="mt-4 w-full max-w-sm space-y-3">
<Skeleton className="h-16 w-full rounded-2xl" />
<Skeleton className="h-16 w-full rounded-2xl" />
</div>
</div>
);
/** Finished / success page placeholder. */
export const FinishedSkeleton = ({ className }: { className?: string }) => (
<div className={cn('mx-auto flex w-full max-w-md flex-col items-center gap-6 px-4 py-10', className)} aria-hidden>
<Skeleton className="h-16 w-16 rounded-full" />
<Skeleton className="h-6 w-64 max-w-full" />
<Skeleton className="h-6 w-48 max-w-full" />
<div className="w-full space-y-3 rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700/50">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
);
+4 -4
View File
@@ -10,15 +10,15 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'btn-neon border-0',
default: 'btn-neon',
destructive: 'btn-danger-neon',
outline: 'btn-glass',
secondary:
'bg-secondary/80 text-secondary-foreground border border-neon-cyan/25 hover:border-neon-cyan/50 hover:shadow-glow-sm backdrop-blur-md',
'border border-neon-cyan/30 bg-secondary/80 text-secondary-foreground shadow-[0_2px_4px_rgba(15,23,42,0.06)] backdrop-blur-md hover:border-neon-cyan/55 hover:shadow-glow-sm focus-visible:border-neon-cyan focus-visible:shadow-[0_0_0_1px_rgba(38,212,202,0.35),0_3px_8px_rgba(15,23,42,0.08)]',
ghost:
'text-body hover:bg-black/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-neon-cyan dark:hover:bg-white/5 dark:hover:text-white',
'text-body hover:bg-black/5 hover:text-foreground focus-visible:border focus-visible:border-neon-cyan focus-visible:shadow-[0_0_0_1px_rgba(38,212,202,0.3),0_2px_6px_rgba(15,23,42,0.08)] dark:hover:bg-white/5 dark:hover:text-white',
link: 'text-neon-cyan underline-offset-4 hover:underline hover:text-neon-teal',
neon: 'btn-neon border-0',
neon: 'btn-neon',
glass: 'btn-glass',
},
size: {
+44
View File
@@ -0,0 +1,44 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type LoadingOverlayProps = {
open: boolean;
/** Short status copy under the spinner */
message?: string;
className?: string;
/** Use fixed positioning to cover the whole viewport/shell */
fullScreen?: boolean;
};
/**
* Standard dimmed scrim + centered loader.
* Parent should be `relative` when `fullScreen` is false.
*/
export const LoadingOverlay = ({
open,
message = 'لطفاً صبر کنید…',
className,
fullScreen = false,
}: LoadingOverlayProps) => {
if (!open) return null;
return (
<div
className={cn(
'z-[55] flex items-center justify-center bg-slate-900/35 backdrop-blur-[2px] transition-opacity duration-200 dark:bg-slate-950/55',
fullScreen ? 'fixed inset-0' : 'absolute inset-0',
className
)}
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="mx-4 flex max-w-[16rem] flex-col items-center gap-3 rounded-2xl border border-slate-200/80 bg-white/95 px-6 py-5 text-center shadow-lg dark:border-slate-700/60 dark:bg-slate-800/95">
<Loader2 className="h-7 w-7 animate-spin text-sky-600 dark:text-sky-400" aria-hidden />
{message ? (
<p className="text-sm font-medium leading-relaxed text-foreground">{message}</p>
) : null}
</div>
</div>
);
};
+12
View File
@@ -0,0 +1,12 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Pulsing placeholder block for loading states (shadcn-style).
*/
export const Skeleton = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('animate-pulse rounded-md bg-slate-200/80 dark:bg-slate-700/60', className)}
{...props}
/>
);
+1 -1
View File
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:top-auto sm:flex-col',
'absolute inset-x-0 bottom-0 top-auto z-[100] flex max-h-full w-full flex-col-reverse gap-2 p-4 sm:flex-col',
className
)}
{...props}
+34 -12
View File
@@ -54,8 +54,8 @@ const createLocalId = () => {
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
};
/** Pause after the closing message finishes typing before session completion UI. */
export const SESSION_COMPLETION_POST_STREAM_DELAY_MS = 10_000;
/** Brief pause after the closing message finishes typing before completing the session. */
export const SESSION_COMPLETION_POST_STREAM_DELAY_MS = 1_200;
type PendingCompletion = {
completion: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] };
@@ -68,6 +68,9 @@ export function useChat(options: UseChatOptions = {}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [isAiThinking, setIsAiThinking] = useState(false);
/** True once a valid completion is queued — blocks input, but does not show overlay yet. */
const [isSessionCompletionQueued, setIsSessionCompletionQueued] = useState(false);
/** True only while transitioning after the closing message finished typing. */
const [isSessionTransitionPending, setIsSessionTransitionPending] = useState(false);
const [chatSessionId, setChatSessionId] = useState<string | null>(null);
const coachContextRef = useRef<AiCoachSessionContext | null>(coachContext);
@@ -76,6 +79,8 @@ export function useChat(options: UseChatOptions = {}) {
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedSessionOrderRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
/** After first successful bootstrap, never flash the full-page chat skeleton again. */
const hasHydratedChatRef = useRef(false);
coachContextRef.current = coachContext;
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
@@ -150,12 +155,13 @@ export function useChat(options: UseChatOptions = {}) {
return;
}
// Only queue — do NOT show transition loading while the closing message is still typing.
pendingCompletionRef.current = {
completion,
sessionId,
messageId: assistantMessageId,
};
setIsSessionTransitionPending(true);
setIsSessionCompletionQueued(true);
},
[]
);
@@ -178,11 +184,14 @@ export function useChat(options: UseChatOptions = {}) {
return;
}
// Show overlay only after the closing message finished typing (and brief pause).
setIsSessionTransitionPending(true);
try {
await emitCompletionIfValid(current.completion, current.sessionId);
} finally {
pendingCompletionRef.current = null;
completionTimerRef.current = null;
setIsSessionCompletionQueued(false);
// Defer unlock until the next session UI has painted to avoid layout jump / freeze.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -191,7 +200,9 @@ export function useChat(options: UseChatOptions = {}) {
});
}
})();
}, SESSION_COMPLETION_POST_STREAM_DELAY_MS);
}, coachContextRef.current?.isFinalSession
? 800
: SESSION_COMPLETION_POST_STREAM_DELAY_MS);
},
[emitCompletionIfValid]
);
@@ -226,6 +237,8 @@ export function useChat(options: UseChatOptions = {}) {
setMessages([]);
setChatSessionId(null);
setLoading(false);
loadedSessionOrderRef.current = null;
hasHydratedChatRef.current = false;
return;
}
@@ -237,9 +250,12 @@ export function useChat(options: UseChatOptions = {}) {
}
if (!coachContextRef.current?.order) {
setMessages([]);
setChatSessionId(null);
setLoading(false);
// Wait for session context on first entry only — never wipe an active chat.
if (!hasHydratedChatRef.current) {
setMessages([]);
setChatSessionId(null);
setLoading(true);
}
return;
}
@@ -250,7 +266,13 @@ export function useChat(options: UseChatOptions = {}) {
loadedSessionOrderRef.current !== sessionOrder &&
messagesRef.current.length > 0;
if (!isSoftSessionSwitch) {
// Skeleton only on the very first empty bootstrap — never mid-chat / re-fetch.
const showInitialSkeleton =
!isSoftSessionSwitch &&
!hasHydratedChatRef.current &&
messagesRef.current.length === 0;
if (showInitialSkeleton) {
setLoading(true);
}
@@ -323,13 +345,12 @@ export function useChat(options: UseChatOptions = {}) {
setMessages(timeline);
} catch (error) {
console.error('Error bootstrapping chat session:', error);
if (!isSoftSessionSwitch) {
if (!isSoftSessionSwitch && messagesRef.current.length === 0) {
setMessages([]);
}
} finally {
if (!isSoftSessionSwitch) {
setLoading(false);
}
hasHydratedChatRef.current = true;
setLoading(false);
}
};
@@ -460,6 +481,7 @@ export function useChat(options: UseChatOptions = {}) {
messages,
loading,
isAiThinking,
isSessionCompletionQueued,
isSessionTransitionPending,
chatSessionId,
sendMessage,
+4 -1
View File
@@ -24,6 +24,7 @@ import {
syncProgramSessions,
type ProgramProgress,
} from '@/services/programService';
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
export interface WaitingForNextSession {
sessionOrder: number;
@@ -101,7 +102,9 @@ export function useCurrentSession(userId: string | null | undefined): UseCurrent
const isProgramComplete = Boolean(
userId &&
(programProgress?.completedAt !== null || isProgramCompleted(userId))
(Boolean(programProgress?.completedAt) ||
(programProgress?.completedSessions.length ?? 0) >= PROGRAM_SESSION_COUNT ||
isProgramCompleted(userId))
);
return {
+27 -4
View File
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, hasSessionToken } from '@/services/backend';
import { getLatestByPhase } from '@/services/assessmentService';
import { getProgramProgress } from '@/services/programService';
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
/** Sessions 1..(N-1) done ⇒ learner is on final session; post-test may open manually. */
const POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS = PROGRAM_SESSION_COUNT - 1;
export type LearningStep =
| 'registered'
@@ -65,6 +70,8 @@ export function useLearningJourney(userId?: string) {
isCompleted: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
/** Skeleton / blocking load only on first fetch — silent on reloadProgress. */
const hasLoadedOnceRef = useRef(false);
const mapBackendJourney = useCallback((journey: {
currentStep: LearningStep;
@@ -118,12 +125,16 @@ export function useLearningJourney(userId?: string) {
return;
}
setLoading(true);
const isInitialLoad = !hasLoadedOnceRef.current;
if (isInitialLoad) {
setLoading(true);
}
if (useLocalOnly) {
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
setProgress(local);
setBackendPermissions(null);
hasLoadedOnceRef.current = true;
setLoading(false);
return;
}
@@ -154,10 +165,15 @@ export function useLearningJourney(userId?: string) {
setProgress(merged);
setBackendPermissions(null);
} finally {
hasLoadedOnceRef.current = true;
setLoading(false);
}
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
useEffect(() => {
hasLoadedOnceRef.current = false;
}, [userId]);
useEffect(() => {
loadProgress();
}, [loadProgress]);
@@ -239,17 +255,24 @@ export function useLearningJourney(userId?: string) {
}, [progress, saveProgress, userId]);
const derivedPermissions = useMemo(() => {
const completedSessions = userId
? getProgramProgress(userId).completedSessions.length
: 0;
const reachedFinalSession =
Boolean(progress?.training_completed_at) ||
completedSessions >= POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS;
return {
canTakePreTest: !progress?.pre_test_completed_at,
canOpenTraining: Boolean(progress?.pre_test_completed_at),
canTakePostTest: Boolean(
progress?.pre_test_completed_at &&
progress?.training_completed_at &&
reachedFinalSession &&
!progress?.post_test_completed_at
),
isCompleted: Boolean(progress?.post_test_completed_at)
};
}, [progress]);
}, [progress, userId]);
const permissions = backendPermissions ?? derivedPermissions;
+1 -1
View File
@@ -15,7 +15,7 @@ const applyThemeClass = (theme: Theme) => {
}
const themeColor = document.querySelector('meta[name="theme-color"]');
if (themeColor) {
themeColor.setAttribute('content', theme === 'dark' ? '#0a0f1d' : '#e6eef6');
themeColor.setAttribute('content', theme === 'dark' ? '#0F172A' : '#F8FAFC');
}
};
+34 -10
View File
@@ -21,12 +21,23 @@ const stripHtml = (html: string): string =>
.replace(/\s+/g, ' ')
.trim();
const mentionsPretest = (text: string): boolean =>
text.includes('پیش‌آزمون') || text.includes('پیش آزمون');
const isPretestExercise = (block: ContentBlock): boolean => {
if (block.type !== 'exercise') return false;
const haystack = `${block.title ?? ''} ${block.prompt ?? ''}`;
return haystack.includes('پیش‌آزمون') || haystack.includes('پیش آزمون');
return mentionsPretest(haystack);
};
/** Remove pre-test clauses so the model never sees them as a spoken «next section». */
const stripPretestFromSpeech = (text: string): string =>
text
.replace(/\s*و\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '')
.replace(/\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '')
.replace(/\s+/g, ' ')
.trim();
const hasUserDirectedSpousePrompt = (text: string): boolean =>
text.includes('همسر خود') ||
text.includes('با همسر') ||
@@ -35,18 +46,26 @@ const hasUserDirectedSpousePrompt = (text: string): boolean =>
const formatBlock = (block: ContentBlock): string | null => {
if (isPretestExercise(block)) {
return `[silent-skip exercise] «پیش‌آزمون» is an in-app assessment, NOT course teaching. NEVER mention پیش‌آزمون, پیش آزمون, pre-test, or PCQ to the user. Skip this block silently and continue the educational sequence.`;
return `[silent-skip exercise] Pre-test is OUTSIDE chat and already done before Session 1. NEVER say پیش‌آزمون / پیش آزمون / pre-test / PCQ. NEVER announce it as «بخش بعدی». NEVER say it is done separately in the app. Skip silently; after the last educational section, go to session wrap-up — do not invent a fake next teaching section about assessment.`;
}
switch (block.type) {
case 'callout':
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
case 'heading':
if (mentionsPretest(block.text)) {
// Omit from spoken roadmap/content — listing it causes the model to announce it.
return null;
}
return `[heading h${block.level ?? 3}] ${block.text}`;
case 'text': {
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
if (body.includes('پیش‌آزمون') || body.includes('پیش آزمون')) {
return `[text — OMIT پیش‌آزمون when speaking; it is not part of the educational conversation] ${body}`;
if (mentionsPretest(body)) {
const cleaned = stripPretestFromSpeech(body);
if (!cleaned || cleaned.length < 8) {
return null;
}
return `[text] ${cleaned}`;
}
return `[text] ${body}`;
}
@@ -131,6 +150,8 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str
if (b.type !== 'heading') return null;
const text = b.text.replace(/\s+/g, ' ').trim();
if (titleNorm && text === titleNorm) return null;
// Never list pre-test in the spoken roadmap (learner already completed it in-app).
if (mentionsPretest(text)) return null;
if (seen.has(text)) return null;
seen.add(text);
return `- ${text}`;
@@ -183,10 +204,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory):
• that learning happens through conversation and interaction along a guided path
- Introduce yourself as «نویا» at most ONCE in this first greeting. After that, never repeat identity lines.
- Then introduce Session 1 «${context.title}», list THIS session's contents from SESSION OUTLINE, and begin teaching the first section.
- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow and is NOT part of the educational conversation.
- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user. When you reach it internally: skip it silently and continue Session 1 teaching.
- If a Session 1 text block mentions that a pre-test is administered: OMIT that phrase when speaking. Teach orientation only.
- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue the lesson.
- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow done BEFORE chat and is NOT part of the educational conversation.
- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user.
- CRITICAL: Do NOT announce pre-test as the next section. FORBIDDEN meta lines (and any paraphrase): «بخش بعدی پیش‌آزمون است»، «پیش‌آزمون به صورت جداگانه در اپلیکیشن انجام می‌شود»، «ما در اینجا به آن نمی‌پردازیم»، «پیش‌آزمون را اینجا انجام نمی‌دهیم». If you skip it, skip with ZERO words about it.
- After the last educational section («ضوابط مربوط به درمان»), Session 1 teaching is done. Silent-skip pre-test internally, then move to wrap-up / close confirmation — do not invent another teaching section about assessment.
- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue or wrap up the lesson.
- Follow Session 1 blocks in their defined order (orientation → topic sections → later items). Do NOT ask the user to choose between «نقش‌ها / مسئولیت‌ها» and «چارچوب برنامه» or any other lesson branches.
- CRITICAL THIN-CONTENT RULE: Several Session 1 sections are topic markers only (e.g. «نقش‌های روان‌شناس»، «مسئولیت‌های شرکت‌کنندگان»، «ضوابط مربوط به درمان»). Body text may only say that these are explained — without listing them. In that case: briefly restate the available wording / purpose of the section, ask ONE focused check-in when useful, then advance. Do NOT invent role lists, responsibility lists, treatment rules, clinical duties, ethics codes, or therapy-frame details from general psychological knowledge.
- Preserve course framing words that appear in the blocks (e.g. «درمان»، «روان‌شناس») when teaching, but still present yourself as an educational coach delivering this program — not as a licensed clinician providing psychotherapy.
@@ -327,7 +349,9 @@ STRICT COURSE GROUNDING (mandatory — highest priority after safety):
ASSESSMENT BOUNDARY (mandatory — Chat is NOT an assessment tool):
- PCQ-24 pre-test and post-test are separate in-app assessment flows handled outside Chat. They are NOT educational course content.
- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, or say it was already completed.
- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, disclaimer it, or say it was already completed.
- FORBIDDEN disclaimers / transitions (never say these or paraphrases): talking about پیش‌آزمون as «بخش بعدی», saying it happens separately in the app, saying you will not cover it here, or explaining why you skip it. Silent skip means ZERO user-facing words about it.
- SESSION OUTLINE must never include پیش‌آزمون. Do not list it in the roadmap. After «ضوابط مربوط به درمان», wrap up the session — do not invent a next educational block about assessment.
- You must NEVER in chat:
• administer another psychological assessment or screening
• invent questionnaire items or substitute pre-test/post-test activities
@@ -476,7 +500,7 @@ TEACHING LOOP:
6. Then advance to the next block in order without asking them to pick the path.
7. NEVER dump the entire lesson in one message. Listing the outline is not dumping the lesson.
8. NEVER invent exercises, reflections, quotes, role lists, questionnaires, rating scales, or topics not present in the session blocks above.
9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip — NEVER mention it to the user. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached.
9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip with ZERO words about it — do not announce «بخش بعدی» as pre-test; after educational sections, wrap up. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached.
10. NEVER convert a teaching question into a multi-item psychological survey or symptom checklist.
11. When criteria in SESSION COMPLETION SIGNAL are fully met, emit the structured completion block (see below). The application validates it and saves progress — you do NOT save progress yourself.
12. Stay on this session until completion is validated; do not teach locked/future sessions.
+110 -14
View File
@@ -196,16 +196,79 @@ export function validateCoachSessionCompletionSignal(
return { valid: true, signal: s as CoachSessionCompletionSignal };
}
/** Final session: coach directed learner to in-app post-test without emitting the hidden block. */
const looksLikeFinalPostTestHandoff = (text: string): boolean => {
const normalized = text.replace(/\u200c/g, '').replace(/\s+/g, ' ');
/** Strip ZWNJ and collapse whitespace for Persian phrase matching. */
const normalizePersian = (text: string): string =>
text.replace(/\u200c/g, '').replace(/\s+/g, ' ').trim();
/**
* Final session: coach wrapped up the review and directed the learner to the in-app post-test,
* with or without a hidden completion block.
*/
export const looksLikeFinalPostTestHandoff = (text: string): boolean => {
const normalized = normalizePersian(text);
if (!normalized) return false;
const mentionsPostTest = /پس[\s\-]?آزمون/.test(normalized);
const directsToApp =
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|انجام بده|بزن)/.test(normalized) ||
/(جلسه|دوره).{0,40}(تمام|تموم|پایان|تمام شده)/.test(normalized);
return mentionsPostTest && directsToApp;
const courseFinished =
/(جلسه|جلسات|دوره|آموزش|برنامه).{0,50}(تمام|تموم|پایان|به پایان|تمام شده|تموم شده|به اتمام)/.test(
normalized
) ||
/(تمام|تموم|پایان).{0,40}(جلسه|جلسات|دوره|آموزش|برنامه)/.test(normalized) ||
/مرور.{0,30}(تمام|تموم|پایان)/.test(normalized);
const directsToAppOrTest =
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|بزن|انجام بده|انجام دهید|بدهید|هدایت)/.test(normalized) ||
/(حالا|الان|اکنون).{0,30}(پس[\s\-]?آزمون|آزمون)/.test(normalized) ||
/(نوبت|وقت).{0,20}(پس[\s\-]?آزمون|آزمون)/.test(normalized);
if (mentionsPostTest && (directsToAppOrTest || courseFinished)) {
return true;
}
if (mentionsPostTest && /(جمع[\s\-]?بندی نهایی|ختم دوره|خداحافظ|موفق باشید)/.test(normalized)) {
return true;
}
return false;
};
/** Repair common AI mistakes on final-session completion payloads. */
export function normalizeFinalSessionSignal(
signal: Partial<CoachSessionCompletionSignal>,
context: AiCoachSessionContext,
reason?: string
): CoachSessionCompletionSignal {
const base = exampleCompletionSignal(context);
const headings = extractSessionHeadings(context.blocks);
const minTopicsRequired = Math.max(1, Math.ceil(headings.length * 0.6));
const incomingTopics =
isStringArray(signal.coveredTopics) && signal.coveredTopics.length > 0
? signal.coveredTopics
: [];
const matchedHeadings = headings.filter((heading) => listMatchesAny(incomingTopics, heading));
const coveredTopics =
matchedHeadings.length >= minTopicsRequired ? incomingTopics : base.coveredTopics;
const completedExercises = isStringArray(signal.completedExercises)
? signal.completedExercises
: base.completedExercises;
const completedReflections = isStringArray(signal.completedReflections)
? signal.completedReflections
: base.completedReflections;
return {
sessionComplete: true,
sessionId: context.sessionId,
sessionOrder: context.order,
reason: (isNonEmptyString(signal.reason) ? signal.reason : null) ?? reason ?? base.reason,
coveredTopics,
completedExercises: completedExercises.length > 0 ? completedExercises : base.completedExercises,
completedReflections,
userConfirmed: true,
readyForNextSession: false
};
}
/** Parse assistant text, validate signal, log in DEV */
export function processCoachAssistantResponse(
rawAssistantText: string,
@@ -216,26 +279,59 @@ export function processCoachAssistantResponse(
}
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
const alreadyCompleted = isSessionCompleted(options.userId, context.order);
const handoff =
context.isFinalSession && looksLikeFinalPostTestHandoff(parsed.displayContent);
let signal = parsed.signal;
// Light repair only: fix identity/flags AI often gets wrong. Do not invent coverage.
if (signal && context.isFinalSession) {
signal = {
...signal,
sessionComplete: true,
sessionId: context.sessionId,
sessionOrder: context.order,
readyForNextSession: false,
userConfirmed: signal.userConfirmed === true || handoff
};
}
if (
!signal &&
context.isFinalSession &&
!alreadyCompleted &&
options.userTurnCount >= 2 &&
looksLikeFinalPostTestHandoff(parsed.displayContent)
handoff
) {
signal = {
...exampleCompletionSignal(context),
reason:
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
};
signal = normalizeFinalSessionSignal(
{},
context,
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).'
);
}
if (!signal) {
return { ...parsed, validation: null };
}
const validation = validateCoachSessionCompletionSignal(signal, context, options);
let validation = validateCoachSessionCompletionSignal(signal, context, options);
// Full catalog recovery only after a clear post-test handoff in the visible message.
if (
!validation.valid &&
context.isFinalSession &&
!alreadyCompleted &&
options.userTurnCount >= 2 &&
handoff
) {
signal = normalizeFinalSessionSignal(
signal,
context,
'Final-session completion recovered with catalog defaults after wrap-up/handoff.'
);
validation = validateCoachSessionCompletionSignal(signal, context, options);
}
if (import.meta.env.DEV) {
console.debug('[AI Coach] Session completion signal received:', {
+1 -1
View File
@@ -1,7 +1,7 @@
import { supabaseHost } from '@/services/supabase';
export function getSupabaseNetworkErrorMessage(): string {
return `سرور احراز هویت (${supabaseHost}) پاسخ نمی‌دهد. مشکل از اینترنت شما نیست — می‌توانید «ادامه بدون ورود» را بزنید.`;
return `سرور احراز هویت (${supabaseHost}) پاسخ نمی‌دهد. لطفاً چند لحظه بعد دوباره تلاش کنید.`;
}
export function isSupabaseNetworkError(message: string): boolean {
+69 -9
View File
@@ -12,6 +12,12 @@ export function isBackendNetworkError(error: unknown): boolean {
return false;
}
const CONFLICT_ALREADY_EXISTS =
/already exists|account with this email|ایمیل.*وجود|قبلاً ثبت/i;
const INVALID_CREDENTIALS_MESSAGE =
'ایمیل یا رمز عبور اشتباه است. اگر حساب ندارید، ابتدا ثبت‌نام کنید.';
export function getBackendErrorMessage(
error: unknown,
fallback = 'خطا در ارتباط با سرور. لطفاً دوباره تلاش کنید.'
@@ -20,6 +26,10 @@ export function getBackendErrorMessage(
if (error.status === 0 || error.code === 'NETWORK_ERROR') {
return 'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.';
}
// Must run before generic 401 — login failures also return status 401.
if (error.code === 'INVALID_CREDENTIALS') {
return INVALID_CREDENTIALS_MESSAGE;
}
if (error.status === 401 || error.code === 'UNAUTHORIZED') {
return 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.';
}
@@ -29,19 +39,19 @@ export function getBackendErrorMessage(
}
return 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.';
}
if (error.code === 'INVALID_CREDENTIALS') {
return 'ایمیل یا رمز عبور اشتباه است. لطفاً دوباره تلاش کنید یا ثبت‌نام کنید.';
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return 'تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کمی صبر کنید.';
return 'تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کمی صبر کنید و دوباره تلاش کنید.';
}
if (error.code === 'CONFLICT') {
if (error.code === 'CONFLICT' || error.status === 409) {
if (CONFLICT_ALREADY_EXISTS.test(error.message)) {
return 'حسابی با این ایمیل از قبل وجود دارد. لطفاً وارد شوید.';
}
return error.message || 'این عملیات قبلاً انجام شده است.';
}
if (error.code === 'VALIDATION_ERROR') {
return error.message || 'اطلاعات واردشده نامعتبر است.';
if (error.code === 'VALIDATION_ERROR' || error.status === 422) {
return localizeValidationMessage(error.message) || 'اطلاعات واردشده نامعتبر است.';
}
return error.message || fallback;
return localizeKnownEnglishMessage(error.message) || fallback;
}
if (error instanceof Error && NETWORK_PATTERN.test(error.message)) {
@@ -49,8 +59,58 @@ export function getBackendErrorMessage(
}
if (error instanceof Error && error.message) {
return error.message;
return localizeKnownEnglishMessage(error.message) || error.message;
}
return fallback;
}
/** Login-specific copy; keeps credential failures distinct from session expiry. */
export function getLoginErrorMessage(error: unknown): string {
if (error instanceof BackendError) {
if (error.code === 'INVALID_CREDENTIALS') {
return INVALID_CREDENTIALS_MESSAGE;
}
if (error.status === 401 && /invalid email or password/i.test(error.message)) {
return INVALID_CREDENTIALS_MESSAGE;
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return 'تعداد تلاش‌های ورود زیاد بوده است. لطفاً چند دقیقه صبر کنید و دوباره تلاش کنید.';
}
if (error.status === 0 || error.code === 'NETWORK_ERROR') {
return 'سرور در دسترس نیست. لطفاً اتصال اینترنت را بررسی کنید و دوباره تلاش کنید.';
}
if (error.code === 'VALIDATION_ERROR' || error.status === 422) {
return localizeValidationMessage(error.message) || 'ایمیل یا رمز عبور واردشده نامعتبر است.';
}
}
return getBackendErrorMessage(error, 'ورود انجام نشد. لطفاً دوباره تلاش کنید.');
}
function localizeValidationMessage(message: string | undefined): string | null {
if (!message) return null;
const lower = message.toLowerCase();
if (lower.includes('email')) {
return 'فرمت ایمیل نامعتبر است.';
}
if (lower.includes('password')) {
return 'رمز عبور نامعتبر است.';
}
return localizeKnownEnglishMessage(message);
}
function localizeKnownEnglishMessage(message: string | undefined): string | null {
if (!message) return null;
if (/invalid email or password/i.test(message)) {
return INVALID_CREDENTIALS_MESSAGE;
}
if (CONFLICT_ALREADY_EXISTS.test(message)) {
return 'حسابی با این ایمیل از قبل وجود دارد. لطفاً وارد شوید.';
}
// Prefer Persian UI copy over raw English API text.
if (/^[A-Za-z0-9\s.,'"\-_:!?()]+$/.test(message.trim())) {
return null;
}
return message;
}
+39 -19
View File
@@ -6,11 +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, Loader2, Lock } 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';
type StepState = 'open' | 'locked' | 'done';
@@ -19,17 +20,14 @@ type StepState = 'open' | 'locked' | 'done';
* Journey status only — no settings, scores, or assessment results.
*/
export default function AccountPage() {
const { effectiveUserId, isGuest, guestSessionHydrated } = useAuth();
const { effectiveUserId, isGuest, guestSessionHydrated, user } = useAuth();
const { progress, loading, permissions } = useLearningJourney(effectiveUserId ?? undefined);
const { currentSession, isProgramComplete, programProgress } = useCurrentSession(effectiveUserId);
const navigate = useNavigate();
const usernameLabel = isGuest ? 'مهمان' : user?.email?.trim() || null;
if (!guestSessionHydrated || loading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
</div>
);
return <CourseStatusSkeleton />;
}
if (!effectiveUserId) {
@@ -48,6 +46,10 @@ export default function AccountPage() {
: Math.round((completedSessions / PROGRAM_SESSION_COUNT) * 100);
const preDone = Boolean(progress?.pre_test_completed_at);
const reachedFinalSession =
Boolean(progress?.training_completed_at) ||
completedSessions >= PROGRAM_SESSION_COUNT - 1 ||
programDone;
const trainingDone = Boolean(progress?.training_completed_at) || programDone;
const postDone = Boolean(progress?.post_test_completed_at);
@@ -63,16 +65,23 @@ export default function AccountPage() {
: 'open';
const postState: StepState = postDone
? 'done'
: permissions.canTakePostTest || (trainingDone && preDone && !postDone)
: permissions.canTakePostTest || (reachedFinalSession && preDone && !postDone)
? 'open'
: 'locked';
return (
<div className="h-full min-h-0 flex-1 overflow-y-auto px-4 py-6">
<div className="h-full min-h-0 flex-1 overflow-y-auto bg-slate-50 px-4 py-6 transition-colors duration-300 dark:bg-slate-900">
<div className="mx-auto w-full max-w-md space-y-4">
<AnimationWrapper immediate className="space-y-1 pb-2">
<h1 className="text-2xl font-bold tracking-tight text-foreground">وضعیت دوره</h1>
{isGuest && <p className="text-sm text-body">جلسه مهمان</p>}
{usernameLabel && (
<p className="text-sm text-body">
نام کاربری:{' '}
<span className="font-medium text-foreground" dir="ltr">
{usernameLabel}
</span>
</p>
)}
</AnimationWrapper>
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
@@ -114,7 +123,7 @@ export default function AccountPage() {
</p>
<div className="flex items-center gap-3">
<Progress value={progressPercent} className="h-2.5 flex-1" />
<span className="min-w-[2.5rem] text-left text-sm tabular-nums text-body">
<span className="min-w-[2.75rem] text-left text-sm font-semibold tabular-nums text-foreground">
{progressPercent}٪
</span>
</div>
@@ -131,14 +140,14 @@ export default function AccountPage() {
lockedHint={
postDone
? 'انجام شده — قابل تکرار نیست'
: 'پس از پایان دوره آموزشی فعال می‌شود'
: 'پس از رسیدن به جلسه پایانی فعال می‌شود'
}
>
{postState === 'done' && <StatusDone label="انجام شده" />}
{postState === 'open' && (
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full bg-neon-teal shadow-[0_0_8px_rgba(52,184,150,0.7)]"
className="inline-block h-2 w-2 shrink-0 rounded-full bg-teal-500 dark:bg-teal-400"
aria-hidden
/>
آماده انجام
@@ -147,7 +156,7 @@ export default function AccountPage() {
{postState === 'locked' && (
<p className="flex items-start gap-2 text-sm text-body">
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span>پس از پایان دوره فعال می‌شود</span>
<span>پس از رسیدن به جلسه پایانی فعال می‌شود</span>
</p>
)}
</StatusRow>
@@ -180,12 +189,12 @@ function StatusRow({
{children}
</div>
{isInteractive ? (
<ChevronLeft className="mt-0.5 h-5 w-5 shrink-0 text-neon-cyan" aria-hidden />
<ChevronLeft className="mt-0.5 h-5 w-5 shrink-0 text-sky-600 dark:text-sky-400" aria-hidden />
) : (
<Lock
className={cn(
'mt-0.5 h-4 w-4 shrink-0',
state === 'done' ? 'text-neon-emerald/70' : 'text-muted-foreground'
state === 'done' ? 'text-emerald-600/80 dark:text-emerald-400/70' : 'text-muted-foreground'
)}
aria-hidden
/>
@@ -201,7 +210,14 @@ function StatusRow({
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={fastTransition}
className="glass-panel w-full px-4 py-4 text-right transition-colors duration-200 hover:border-neon-cyan/55 hover:shadow-[0_0_18px_rgba(72,180,200,0.22)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neon-cyan/50"
className={cn(
'status-card w-full rounded-2xl px-4 py-4 text-right transition-all duration-200',
'border border-slate-200/90 bg-gradient-to-b from-white to-slate-50/80',
'hover:border-sky-300/80 hover:from-white hover:to-sky-50/40',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40',
'dark:border-slate-700/50 dark:from-slate-800/90 dark:to-slate-900/80',
'dark:hover:border-slate-600 dark:hover:from-slate-800 dark:hover:to-slate-800/90'
)}
aria-label={`${title} — باز کردن`}
>
{content}
@@ -211,7 +227,11 @@ function StatusRow({
return (
<div
className="glass-panel w-full px-4 py-4 opacity-90"
className={cn(
'status-card w-full rounded-2xl px-4 py-4 opacity-90',
'border border-slate-200/80 bg-gradient-to-b from-white to-slate-50/70',
'dark:border-slate-700/50 dark:from-slate-800/70 dark:to-slate-900/60'
)}
aria-disabled="true"
title={lockedHint}
>
@@ -223,7 +243,7 @@ function StatusRow({
function StatusDone({ label }: { label: string }) {
return (
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
<Check className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
{label}
</p>
);
+15 -9
View File
@@ -13,6 +13,8 @@ import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendErro
import { useToast } from '@/components/ui/use-toast';
import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
import { cn } from '@/lib/utils';
import { AssessmentSkeleton } from '@/components/skeletons';
import { LoadingOverlay } from '@/components/ui/loading-overlay';
const FADE_MS = 220;
@@ -71,6 +73,10 @@ export default function AssessmentPage() {
}
}
if (loading) {
return <AssessmentSkeleton />;
}
const advanceWithFade = (delta: number) => {
if (isAnimating || submitting || isRedirecting) return;
setIsAnimating(true);
@@ -229,6 +235,10 @@ export default function AssessmentPage() {
return (
<div className="relative h-full min-h-0 flex-1 overflow-hidden p-4">
<LoadingOverlay
open={isRedirecting}
message={successMessage ?? 'در حال انتقال…'}
/>
{/* Intro overlay — dark veil on text area; bottom almost visible */}
{showIntro && (
<div
@@ -314,10 +324,12 @@ export default function AssessmentPage() {
disabled={isAnimating || submitting || isRedirecting || showIntro}
onClick={() => onSelect(currentQuestion.id, option.value)}
className={cn(
'flex h-11 w-full items-center justify-start rounded-xl border px-4 text-right text-sm transition-colors duration-200',
'flex h-11 w-full items-center justify-start rounded-xl border px-4 text-right text-sm transition-all duration-200',
'shadow-[0_2px_4px_rgba(15,23,42,0.05)] focus-visible:outline-none',
'focus-visible:border-[#26d4ca] focus-visible:shadow-[0_0_0_1px_rgba(38,212,202,0.35),0_3px_8px_rgba(15,23,42,0.08)]',
selected
? 'border-cyan-500 bg-cyan-100 text-cyan-950 shadow-sm dark:border-neon-cyan dark:bg-neon-blue/25 dark:text-white dark:shadow-glow-sm'
: 'border-slate-200 bg-white/80 text-body hover:border-cyan-400 hover:bg-white hover:text-foreground dark:border-neon-cyan/20 dark:bg-slate-900/35 dark:hover:border-neon-cyan/50 dark:hover:bg-slate-800/45 dark:hover:text-white'
? 'border-[#26d4ca] bg-[rgb(38_212_202/0.12)] text-foreground shadow-[0_0_0_1px_rgba(38,212,202,0.25),0_3px_8px_rgba(15,23,42,0.08)] dark:border-neon-cyan dark:bg-neon-cyan/15 dark:text-white dark:shadow-glow-sm'
: 'border-slate-200 bg-white/80 text-body hover:border-[#26d4ca]/70 hover:bg-white hover:text-foreground dark:border-neon-cyan/20 dark:bg-slate-900/35 dark:hover:border-neon-cyan/55 dark:hover:bg-slate-800/45 dark:hover:text-white'
)}
>
{option.label}
@@ -357,12 +369,6 @@ export default function AssessmentPage() {
</Button>
)}
</div>
{successMessage && (
<p className="shrink-0 rounded-xl border border-neon-emerald/35 bg-neon-emerald/10 px-3 py-2 text-center text-sm text-neon-teal">
{successMessage}
</p>
)}
</div>
</div>
);
+53 -48
View File
@@ -7,19 +7,20 @@ import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { useToast } from '@/components/ui/use-toast';
import { getBackendErrorMessage } from '@/lib/backendError';
import { Loader2, Mail, Lock, Moon, Sun, Sparkles } from 'lucide-react';
import { getLoginErrorMessage } from '@/lib/backendError';
import { Loader2, Mail, Lock, Moon, Sun, Eye, EyeOff } from 'lucide-react';
import { AnimationWrapper } from '@/components/motion';
import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants';
import { BrandLogo } from '@/components/BrandLogo';
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { signIn, isAuthenticated, continueAsGuest } = useAuth();
const { signIn, isAuthenticated } = useAuth();
const { toggleTheme, isDark } = useTheme();
const { toast } = useToast();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -39,10 +40,37 @@ export default function LoginPage() {
const nextEmail = String(form.get('email') ?? email).trim();
const nextPassword = String(form.get('password') ?? password);
if (!nextEmail || !nextPassword) {
if (!nextEmail && !nextPassword) {
toast({
title: 'خطا',
description: 'لطفا ایمیل و رمز عبور را وارد کنید',
description: 'لطفاً ایمیل و رمز عبور را وارد کنید.',
variant: 'destructive',
});
return;
}
if (!nextEmail) {
toast({
title: 'خطا',
description: 'لطفاً ایمیل خود را وارد کنید.',
variant: 'destructive',
});
return;
}
if (!nextPassword) {
toast({
title: 'خطا',
description: 'لطفاً رمز عبور را وارد کنید.',
variant: 'destructive',
});
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nextEmail)) {
toast({
title: 'خطا',
description: 'فرمت ایمیل نامعتبر است. لطفاً یک ایمیل معتبر وارد کنید.',
variant: 'destructive',
});
return;
@@ -58,8 +86,8 @@ export default function LoginPage() {
} catch (error: unknown) {
console.error('Login error:', error);
toast({
title: 'خطا',
description: getBackendErrorMessage(error, 'خطا در ورود. لطفاً دوباره تلاش کنید.'),
title: 'خطا در ورود',
description: getLoginErrorMessage(error),
variant: 'destructive',
});
} finally {
@@ -67,23 +95,6 @@ export default function LoginPage() {
}
};
const handleContinueWithoutLogin = async () => {
setIsLoading(true);
try {
await continueAsGuest();
const fromState = (location.state as { from?: { pathname?: string } | string } | null)?.from;
const from =
typeof fromState === 'string' ? fromState : fromState?.pathname;
const safeFrom =
from && from !== '/login' && from !== '/signup' ? from : '/';
navigate(safeFrom, { replace: true });
} catch {
toast({ title: 'خطا', description: 'خطا در ایجاد حساب مهمان', variant: 'destructive' });
} finally {
setIsLoading(false);
}
};
return (
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
<button
@@ -101,18 +112,12 @@ export default function LoginPage() {
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
aria-hidden
/>
<CardHeader className="relative space-y-2">
<div className="mb-2 flex justify-center">
<div className="avatar-glow">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white text-cyan-700 shadow-sm dark:bg-navy dark:text-neon-cyan dark:shadow-none">
<Sparkles className="h-7 w-7" />
</div>
</div>
<CardHeader className="relative space-y-3">
<div className="mb-1 flex justify-center">
<BrandLogo imgClassName="h-12" />
</div>
<CardTitle className="text-center text-3xl font-bold tracking-tight text-foreground">{APP_NAME}</CardTitle>
<CardDescription className="text-center text-body">
ورود به {APP_NAME} — {APP_DESCRIPTION}
</CardDescription>
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">ورود</CardTitle>
<CardDescription className="text-center text-body">وارد شوید</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
@@ -145,14 +150,24 @@ export default function LoginPage() {
<Input
id="password"
name="password"
type="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pr-10"
className="px-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword((open) => !open)}
disabled={isLoading}
className="absolute left-2 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
aria-pressed={showPassword}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
@@ -167,16 +182,6 @@ export default function LoginPage() {
</Button>
</form>
<Button
type="button"
variant="glass"
className="w-full"
onClick={handleContinueWithoutLogin}
disabled={isLoading}
>
ادامه بدون ورود
</Button>
<div className="text-center text-sm text-body">
حساب کاربری ندارید؟{' '}
<Link to="/signup" className="text-neon-cyan hover:text-neon-teal hover:underline">
+52 -27
View File
@@ -8,8 +8,9 @@ import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { useToast } from '@/components/ui/use-toast';
import { getBackendErrorMessage } from '@/lib/backendError';
import { Loader2, UserPlus, Moon, Sun } from 'lucide-react';
import { Loader2, Moon, Sun, Eye, EyeOff } from 'lucide-react';
import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants';
import { BrandLogo } from '@/components/BrandLogo';
export default function SignupPage() {
const navigate = useNavigate();
@@ -19,6 +20,8 @@ export default function SignupPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSignup = async (e: React.FormEvent) => {
@@ -83,15 +86,11 @@ export default function SignupPage() {
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
aria-hidden
/>
<CardHeader className="relative space-y-2">
<div className="mb-2 flex justify-center">
<div className="avatar-glow">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white text-cyan-700 shadow-sm dark:bg-navy dark:text-neon-cyan dark:shadow-none">
<UserPlus className="h-7 w-7" />
</div>
</div>
<CardHeader className="relative space-y-3">
<div className="mb-1 flex justify-center">
<BrandLogo imgClassName="h-12" />
</div>
<CardTitle className="text-center text-3xl font-bold tracking-tight text-foreground">ثبت نام</CardTitle>
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">ثبت نام</CardTitle>
<CardDescription className="text-center text-body">
ایجاد حساب کاربری در {APP_NAME} — {APP_DESCRIPTION}
</CardDescription>
@@ -113,27 +112,53 @@ export default function SignupPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-body">رمز عبور</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete="new-password"
/>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword((open) => !open)}
disabled={isLoading}
className="absolute left-2 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
aria-pressed={showPassword}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-body">تکرار رمز عبور</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
autoComplete="new-password"
/>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowConfirmPassword((open) => !open)}
disabled={isLoading}
className="absolute left-2 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={showConfirmPassword ? 'مخفی کردن تکرار رمز عبور' : 'نمایش تکرار رمز عبور'}
aria-pressed={showConfirmPassword}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
{isLoading ? (
+210 -50
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, Fragment, type FormEvent } from 'react';
import { useState, useRef, useEffect, useLayoutEffect, useCallback, Fragment, type FormEvent } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { MessageBubble } from '@/components/chat/MessageBubble';
@@ -10,10 +10,14 @@ import { useCurrentSession } from '@/hooks/useCurrentSession';
import { completeSessionWithBackend, getProgramProgress } from '@/services/programService';
import { formatRemainingUnlockTime } from '@/lib/sessionUnlock';
import { PROGRAM_SESSION_COUNT, getSessionByOrder } from '@/data/program/sessions';
import { buildAiCoachSessionContext } from '@/lib/aiCoach/sessionContext';
import { exampleCompletionSignal, looksLikeFinalPostTestHandoff } from '@/lib/aiCoach/sessionCompletion';
import { useToast } from '@/components/ui/use-toast';
import { getFetchErrorMessage } from '@/lib/fetchError';
import { APP_NAME } from '@/utils/constants';
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
import { LoadingOverlay } from '@/components/ui/loading-overlay';
import { ChatSkeleton, PageSkeleton } from '@/components/skeletons';
/**
* Chat = Educational Environment.
@@ -86,10 +90,14 @@ export default function ChatPage() {
await refreshCurrentSession();
await reloadProgress();
const isFinalSession = Boolean(updated.completedAt);
const isFinalSession =
payload.sessionOrder >= PROGRAM_SESSION_COUNT ||
Boolean(updated.completedAt) ||
updated.completedSessions.length >= PROGRAM_SESSION_COUNT;
if (isFinalSession) {
await markTrainingDone();
await reloadProgress();
toast({
title: 'دوره آموزشی تکمیل شد',
description: 'پس‌آزمون برای شما فعال شد.',
@@ -136,21 +144,50 @@ export default function ChatPage() {
) {
return;
}
void markTrainingDone();
let cancelled = false;
void (async () => {
try {
const finalSession = getSessionByOrder(PROGRAM_SESSION_COUNT);
if (finalSession) {
const signal = exampleCompletionSignal(buildAiCoachSessionContext(finalSession));
await completeSessionWithBackend(effectiveUserId, signal);
}
if (cancelled) return;
await markTrainingDone();
await reloadProgress();
if (cancelled) return;
toast({
title: 'دوره آموزشی تکمیل شد',
description: 'پس‌آزمون برای شما فعال شد.',
});
navigate('/assessment/post', { replace: true });
} catch (error) {
console.error('Failed to finalize training after program completion:', error);
}
})();
return () => {
cancelled = true;
};
}, [
effectiveUserId,
isProgramComplete,
markTrainingDone,
navigate,
progress?.post_test_completed_at,
progress?.pre_test_completed_at,
progress?.training_completed_at,
progressLoading,
reloadProgress,
toast,
]);
const {
messages,
loading,
isAiThinking,
isSessionCompletionQueued,
isSessionTransitionPending,
sendMessage,
startCoachingSession,
@@ -168,6 +205,72 @@ export default function ChatPage() {
const currentSessionAnchorRef = useRef<HTMLDivElement>(null);
const streamScrollRafRef = useRef<number | null>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
const finalHandoffRecoveryRef = useRef(false);
// Recover stuck final sessions: AI already handed off to post-test but completion never persisted.
useEffect(() => {
if (
!effectiveUserId ||
loading ||
progressLoading ||
!coachContext?.isFinalSession ||
progress?.training_completed_at ||
progress?.post_test_completed_at ||
isAiThinking ||
isSessionCompletionQueued ||
isSessionTransitionPending ||
finalHandoffRecoveryRef.current
) {
return;
}
const sessionMessages = messages.filter(
(msg) => msg.sessionOrder === coachContext.order || msg.sessionOrder == null
);
const userTurns = sessionMessages.filter((msg) => msg.role === 'user').length;
if (userTurns < 2) return;
const lastAssistant = [...sessionMessages].reverse().find((msg) => msg.role === 'assistant');
if (!lastAssistant?.content || !looksLikeFinalPostTestHandoff(lastAssistant.content)) {
return;
}
finalHandoffRecoveryRef.current = true;
let cancelled = false;
void (async () => {
try {
await handleCoachSessionCompleted({
sessionId: coachContext.sessionId,
sessionOrder: coachContext.order,
reason:
'Recovered final-session handoff from existing assistant message; routing to post-test.',
readyForNextSession: false,
signal: exampleCompletionSignal(coachContext),
});
} catch (error) {
if (!cancelled) {
finalHandoffRecoveryRef.current = false;
console.error('Failed to recover final-session handoff:', error);
}
}
})();
return () => {
cancelled = true;
};
}, [
coachContext,
effectiveUserId,
handleCoachSessionCompleted,
isAiThinking,
isSessionCompletionQueued,
isSessionTransitionPending,
loading,
messages,
progress?.post_test_completed_at,
progress?.training_completed_at,
progressLoading,
]);
const resizeComposer = useCallback(() => {
const el = composerRef.current;
@@ -182,57 +285,88 @@ export default function ChatPage() {
el.style.height = `${next}px`;
}, []);
const scrollToBottom = useCallback(() => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const scroller = messagesScrollRef.current;
if (!scroller) return;
scroller.scrollTop = scroller.scrollHeight;
const end = messagesEndRef.current;
if (end) {
end.scrollIntoView({ behavior, block: 'end' });
}
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
}, []);
useEffect(() => {
if (loading || isSessionTransitionPending) return;
// On enter / session change: jump to the latest message.
let cancelled = false;
const frame = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!cancelled) scrollToBottom();
});
});
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
};
}, [loading, coachContext?.order, messages.length, scrollToBottom, isSessionTransitionPending]);
const isStreamOrThinking =
isAiThinking || messages.some((msg) => msg.status === 'streaming');
// Entering chat / session change / new messages: pin to latest.
useLayoutEffect(() => {
if (loading && messages.length === 0) return;
if (isSessionTransitionPending) return;
scrollToBottom('auto');
}, [
loading,
coachContext?.order,
messages.length,
isAiThinking,
isSessionTransitionPending,
scrollToBottom,
]);
// While AI thinks or streams, keep the latest bubble (incl. dots) in view as height grows.
useEffect(() => {
if (loading) return;
if (!isAiThinking && messages.every((msg) => msg.status !== 'streaming')) {
return;
if (loading || !isStreamOrThinking) return;
const scroller = messagesScrollRef.current;
if (!scroller) return;
const stick = () => scrollToBottom('auto');
stick();
const content = scroller.firstElementChild;
const resizeObserver =
typeof ResizeObserver !== 'undefined' && content
? new ResizeObserver(() => stick())
: null;
if (content && resizeObserver) resizeObserver.observe(content);
const mutationObserver =
typeof MutationObserver !== 'undefined'
? new MutationObserver(() => stick())
: null;
if (mutationObserver) {
mutationObserver.observe(scroller, { childList: true, subtree: true, characterData: true });
}
scrollToBottom();
}, [messages, isAiThinking, loading, scrollToBottom]);
const frame = window.requestAnimationFrame(stick);
const timeout = window.setTimeout(stick, 50);
return () => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
window.cancelAnimationFrame(frame);
window.clearTimeout(timeout);
};
}, [isStreamOrThinking, loading, scrollToBottom]);
const handleStreamProgress = useCallback(() => {
if (streamScrollRafRef.current !== null) return;
streamScrollRafRef.current = window.requestAnimationFrame(() => {
streamScrollRafRef.current = null;
scrollToBottom();
scrollToBottom('auto');
});
}, [scrollToBottom]);
const handleStreamComplete = useCallback(
(messageId: string) => {
markAssistantStreamComplete(messageId);
scrollToBottom();
scrollToBottom('auto');
},
[markAssistantStreamComplete, scrollToBottom]
);
if (!guestSessionHydrated) {
return (
<div className="flex h-full min-h-0 flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
</div>
);
return <PageSkeleton />;
}
if (!effectiveUserId) {
@@ -257,7 +391,11 @@ export default function ChatPage() {
const completedSessions =
programProgress?.completedSessions.length ??
getProgramProgress(effectiveUserId).completedSessions.length;
const progressPercent = Math.round((completedSessions / PROGRAM_SESSION_COUNT) * 100);
const displayCompletedSessions =
isProgramComplete || (isSessionTransitionPending && coachContext?.isFinalSession)
? PROGRAM_SESSION_COUNT
: completedSessions;
const progressPercent = Math.round((displayCompletedSessions / PROGRAM_SESSION_COUNT) * 100);
const sessionOrder =
currentSession?.order ??
coachContext?.order ??
@@ -281,10 +419,12 @@ export default function ChatPage() {
!isAiThinking &&
!hasCurrentSessionMessages &&
!isWaitingForUnlock &&
!isSessionCompletionQueued &&
!isSessionTransitionPending;
const isStreamBusy =
isAiThinking ||
isSessionCompletionQueued ||
isSessionTransitionPending ||
messages.some((msg) => msg.status === 'streaming');
@@ -294,11 +434,9 @@ export default function ChatPage() {
? 'در حال بارگذاری...'
: isWaitingForUnlock
? 'جلسه بعدی به‌زودی باز می‌شود'
: isSessionTransitionPending
? 'در حال جمع‌بندی جلسه...'
: showStartPrompt
? 'برای شروع جلسه بعد، دکمه بالا را بزن'
: 'پاسخ خود را بنویسید...';
: showStartPrompt
? 'برای شروع جلسه بعد، دکمه بالا را بزن'
: 'پاسخ خود را بنویسید...';
const handleSend = async (e?: FormEvent, initialMessage?: string, skipUserMessage = false) => {
e?.preventDefault();
@@ -380,6 +518,14 @@ 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 gap-4 overflow-hidden p-4">
<LoadingOverlay
open={isSessionTransitionPending}
message={
coachContext?.isFinalSession
? 'در حال آماده‌سازی پس‌آزمون…'
: 'در حال آماده‌سازی جلسه بعد…'
}
/>
<div className="relative z-10 shrink-0">
{isProgramComplete ? (
<p className="text-center text-xs text-muted-foreground">دوره آموزشی تکمیل شده</p>
@@ -392,13 +538,16 @@ export default function ChatPage() {
) : (
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-foreground">
<span className="shrink-0 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-foreground dark:border-slate-700/50 dark:bg-slate-800">
{sessionOrder}/{PROGRAM_SESSION_COUNT}
</span>
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{sessionTitle}</p>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-muted-foreground">
{progressPercent}٪
</span>
</div>
<div
className="h-[2px] w-full overflow-hidden rounded-full bg-black/10 dark:bg-white/[0.06]"
className="h-1.5 w-full overflow-hidden rounded-full border border-slate-200/80 bg-slate-100 dark:border-slate-700/50 dark:bg-slate-800"
role="progressbar"
aria-valuenow={progressPercent}
aria-valuemin={0}
@@ -406,10 +555,23 @@ export default function ChatPage() {
aria-label="پیشرفت دوره"
>
<div
className="h-full rounded-full bg-gradient-to-l from-neon-cyan/80 to-neon-emerald/70 transition-all duration-500"
className="h-full rounded-full bg-sky-500 transition-all duration-500 ease-out dark:bg-sky-600"
style={{ width: `${progressPercent}%` }}
/>
</div>
{Boolean(coachContext?.isFinalSession && permissions.canTakePostTest) && (
<div className="flex justify-end pt-0.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => navigate('/assessment/post')}
>
رفتن به پس‌آزمون
</Button>
</div>
)}
</div>
)}
</div>
@@ -418,10 +580,8 @@ export default function ChatPage() {
ref={messagesScrollRef}
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
{loading ? (
<div className="flex h-full items-center justify-center py-6">
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
</div>
{loading && messages.length === 0 ? (
<ChatSkeleton />
) : messages.length === 0 && !isAiThinking ? (
<div className="flex h-full min-h-0 flex-col items-center justify-center px-1 py-6 text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-white/20 text-neon-cyan backdrop-blur-md dark:bg-white/10">
@@ -503,7 +663,7 @@ export default function ChatPage() {
className={`shrink-0 text-[11px] font-medium ${
isPastSession
? 'text-muted-foreground/80'
: 'text-neon-cyan'
: 'text-sky-700 dark:text-sky-400'
}`}
>
جلسه {msgSessionOrder}
@@ -561,7 +721,7 @@ export default function ChatPage() {
)}
</div>
<div className="relative z-10 w-full shrink-0">
<div className="relative z-10 w-full shrink-0 pb-1 pt-1">
<form onSubmit={handleSend} className="w-full">
<div className="composer-glass">
<textarea
@@ -589,14 +749,14 @@ export default function ChatPage() {
type="submit"
size="icon"
variant="neon"
className="mb-0.5 h-10 w-10 shrink-0 rounded-xl p-0 disabled:opacity-70 [&_svg]:h-6 [&_svg]:w-6"
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-6 w-6 animate-spin" />
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-6 w-6" strokeWidth={2.75} />
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
)}
</Button>
</div>
+3 -6
View File
@@ -1,18 +1,15 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import { Check, CheckCircle2 } from 'lucide-react';
import { FinishedSkeleton } from '@/components/skeletons';
export default function FinishedPage() {
const { effectiveUserId, guestSessionHydrated } = useAuth();
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
if (!guestSessionHydrated || loading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
</div>
);
return <FinishedSkeleton />;
}
if (!effectiveUserId) {
+5 -8
View File
@@ -9,8 +9,9 @@ import { useToast } from '@/components/ui/use-toast';
import { hasSessionToken } from '@/services/backend';
import { getBackendErrorMessage } from '@/lib/backendError';
import { AnimationWrapper } from '@/components/motion';
import { Loader2, Sparkles } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { ROUTES } from '@/utils/constants';
import { BrandLogo } from '@/components/BrandLogo';
const NAME_MAX_LENGTH = 100;
@@ -99,13 +100,9 @@ export default function ProfileCompletionPage() {
className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent"
aria-hidden
/>
<CardHeader className="relative space-y-2">
<div className="mb-2 flex justify-center">
<div className="avatar-glow">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white text-cyan-700 shadow-sm dark:bg-navy dark:text-neon-cyan dark:shadow-none">
<Sparkles className="h-7 w-7" />
</div>
</div>
<CardHeader className="relative space-y-3">
<div className="mb-1 flex justify-center">
<BrandLogo imgClassName="h-12" />
</div>
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">
قبل از شروع، کمی با هم آشنا بشیم
+7 -3
View File
@@ -176,12 +176,16 @@ export default function ProgramPage() {
})}
</div>
{/* Program Complete Banner */}
{programCompleted && (
{/* Post-test available from Session 11 onward */}
{(programCompleted || permissions.canTakePostTest) && (
<Card className="mt-4 border-green-500/30 bg-green-500/5">
<CardContent className="p-4 text-center">
<CheckCircle2 className="h-8 w-8 text-green-600 mx-auto mb-2" />
<p className="font-medium text-sm">تبریک! برنامه آموزشی را به پایان رساندید.</p>
<p className="font-medium text-sm">
{programCompleted
? 'تبریک! برنامه آموزشی را به پایان رساندید.'
: 'به جلسه پایانی رسیده‌اید.'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
اکنون می‌توانید پس‌آزمون را انجام دهید.
</p>
+4 -1
View File
@@ -312,7 +312,10 @@ export function isSessionCompleted(userId: string, sessionOrder: number): boolea
* Check if the entire program is completed.
*/
export function isProgramCompleted(userId: string): boolean {
return getProgramProgress(userId).completedAt !== null;
const progress = getProgramProgress(userId);
return (
progress.completedAt !== null || progress.completedSessions.length >= PROGRAM_SESSION_COUNT
);
}
/**
+440 -205
View File
@@ -4,41 +4,50 @@
@layer base {
:root {
/* Light is the product default */
--background: 210 40% 98%;
/* Light is the product default — calm off-white wellness palette */
--background: 210 40% 98%; /* #F8FAFC */
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 216 100% 50%;
--primary: 199 89% 48%; /* logo blue */
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 25% 28%;
--accent: 191 90% 42%;
--muted-foreground: 215 25% 32%;
--accent: 176 70% 42%; /* logo teal/green */
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 45%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 88%;
--border: 214 32% 90%;
--input: 214 32% 91%;
--ring: 216 100% 50%;
--ring: 176 70% 42%;
--radius: 1rem;
--neon-blue: #0066ff;
--neon-cyan: #00d2ff;
--neon-teal: #00ffcc;
/* Brand from mainlogo.png — teal-green → azure blue */
--logo-teal: #26d4ca;
--logo-blue: #00a8e8;
--neon-blue: #00a8e8;
--neon-cyan: #26d4ca;
--neon-teal: #26d4ca;
--neon-emerald: #10b981;
--navy-deep: #070a14;
--navy: #0a0f1d;
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-bg-strong: rgba(255, 255, 255, 0.92);
--body-text: #1e3a4c;
--glow-blue: 0 0 12px rgba(47, 127, 212, 0.12);
--glow-teal: 0 0 12px rgba(52, 184, 150, 0.12);
--glow-border: 0 0 8px rgba(47, 127, 212, 0.1);
--gradient-neon: linear-gradient(135deg, #2f7fd4 0%, #2bb8c9 50%, #34b896 100%);
--navy-deep: #0f172a;
--navy: #1e293b;
--glass-bg: rgba(255, 255, 255, 0.88);
--glass-bg-strong: rgba(255, 255, 255, 0.94);
--body-text: #334155;
--glow-blue: 0 0 16px rgba(0, 168, 232, 0.35);
--glow-teal: 0 0 16px rgba(38, 212, 202, 0.4);
--glow-border: 0 0 12px rgba(38, 212, 202, 0.25);
--gradient-neon: linear-gradient(135deg, #26d4ca 0%, #14c4db 48%, #00a8e8 100%);
--chat-bg: #f8fafc;
--bubble-user-bg: #00a8e8;
--bubble-ai-bg: #ffffff;
--bubble-ai-border: #e2e8f0;
--theme-transition: background-color 0.35s ease, color 0.35s ease, border-color 0.35s ease,
box-shadow 0.35s ease;
}
.light {
@@ -48,76 +57,96 @@
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 216 100% 50%;
--primary: 199 89% 48%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 25% 28%;
--accent: 191 90% 42%;
--muted-foreground: 215 25% 32%;
--accent: 176 70% 42%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 45%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 88%;
--border: 214 32% 90%;
--input: 214 32% 91%;
--ring: 216 100% 50%;
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-bg-strong: rgba(255, 255, 255, 0.92);
--body-text: #1e3a4c;
--glow-blue: 0 0 12px rgba(47, 127, 212, 0.12);
--glow-teal: 0 0 12px rgba(52, 184, 150, 0.12);
--glow-border: 0 0 8px rgba(47, 127, 212, 0.1);
--ring: 176 70% 42%;
--logo-teal: #26d4ca;
--logo-blue: #00a8e8;
--neon-blue: #00a8e8;
--neon-cyan: #0d9488;
--neon-teal: #0f766e;
--glass-bg: rgba(255, 255, 255, 0.88);
--glass-bg-strong: rgba(255, 255, 255, 0.94);
--body-text: #334155;
--glow-blue: 0 2px 8px rgba(0, 168, 232, 0.18);
--glow-teal: 0 2px 8px rgba(38, 212, 202, 0.16);
--glow-border: none;
--gradient-neon: linear-gradient(135deg, #26d4ca 0%, #14c4db 48%, #00a8e8 100%);
--chat-bg: #f8fafc;
--bubble-user-bg: #00a8e8;
--bubble-ai-bg: #ffffff;
--bubble-ai-border: #e2e8f0;
}
/* Keep .dark as alias of product dark tokens */
.dark {
--background: 222 48% 6%;
--foreground: 0 0% 100%;
--card: 222 47% 11%;
--card-foreground: 0 0% 100%;
--popover: 222 47% 9%;
--popover-foreground: 0 0% 100%;
--primary: 216 100% 50%;
--background: 222 47% 11%; /* #0F172A */
--foreground: 210 40% 98%;
--card: 217 33% 17%;
--card-foreground: 210 40% 98%;
--popover: 217 33% 14%;
--popover-foreground: 210 40% 98%;
--primary: 193 100% 45%; /* logo blue #00A8E8 */
--primary-foreground: 0 0% 100%;
--secondary: 217 33% 14%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 96%;
--muted: 217 33% 12%;
--muted-foreground: 210 25% 82%;
--accent: 191 100% 50%;
--accent-foreground: 222 48% 6%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 72%;
--accent: 176 70% 49%; /* logo teal #26D4CA */
--accent-foreground: 222 47% 11%;
--destructive: 0 72% 42%;
--destructive-foreground: 0 0% 100%;
--border: 210 40% 40%;
--border: 215 25% 27%;
--input: 217 33% 16%;
--ring: 191 100% 50%;
--glass-bg: rgba(12, 20, 36, 0.72);
--glass-bg-strong: rgba(14, 24, 42, 0.86);
--body-text: #e8eef6;
--glow-blue: 0 0 14px rgba(56, 140, 220, 0.22), 0 0 28px rgba(0, 180, 200, 0.1);
--glow-teal: 0 0 14px rgba(45, 180, 160, 0.2), 0 0 28px rgba(52, 168, 130, 0.1);
--glow-border: 0 0 10px rgba(72, 180, 200, 0.18), 0 0 22px rgba(60, 170, 150, 0.1);
--ring: 176 70% 49%;
--logo-teal: #26d4ca;
--logo-blue: #00a8e8;
--neon-blue: #00a8e8;
--neon-cyan: #26d4ca;
--neon-teal: #26d4ca;
--glass-bg: rgba(30, 41, 59, 0.82);
--glass-bg-strong: rgba(30, 41, 59, 0.92);
--body-text: #e2e8f0;
--glow-blue: 0 0 18px rgba(0, 168, 232, 0.45), 0 0 36px rgba(0, 168, 232, 0.2);
--glow-teal: 0 0 18px rgba(38, 212, 202, 0.5), 0 0 36px rgba(38, 212, 202, 0.22);
--glow-border: 0 0 14px rgba(38, 212, 202, 0.35), 0 0 28px rgba(0, 168, 232, 0.2);
--gradient-neon: linear-gradient(135deg, #26d4ca 0%, #14c4db 48%, #00a8e8 100%);
--chat-bg: #0f172a;
--bubble-user-bg: #00a8e8;
--bubble-ai-bg: #334155;
--bubble-ai-border: rgba(51, 65, 85, 0.85);
}
}
@font-face {
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam.ttf') format('truetype');
font-family: 'IRANSansX';
src: url('/fonts/iransansx/IRANSansX-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam-Bold.ttf') format('truetype');
font-family: 'IRANSansX';
src: url('/fonts/iransansx/IRANSansX-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANSansX';
src: url('/fonts/iransansx/IRANSansX-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
@@ -165,13 +194,29 @@
body {
@apply text-foreground antialiased;
font-family: 'Vazirmatn', 'Shabnam', 'Tahoma', system-ui, sans-serif;
background-color: #e6eef6;
font-family: 'IRANSansX', 'Tahoma', system-ui, sans-serif;
background-color: #f1f5f9;
margin: 0;
transition: var(--theme-transition);
}
html.dark body {
background-color: #070b14;
background-color: #0f172a;
}
/* Smooth theme toggling across surfaces */
html,
.app-mobile-shell,
.app-header,
.glass-panel,
.glass-panel-strong,
.glass-panel-chat,
.composer-glass,
.bubble-user,
.bubble-ai,
.progress-neon,
.progress-neon-fill {
transition: var(--theme-transition);
}
h1,
@@ -191,16 +236,15 @@
}
@layer components {
/* Desktop stage: soft wellness ambience (muted blue + soft teal) */
/* Desktop stage: soft wellness ambience */
.app-desktop-stage {
@apply relative flex w-full flex-1 justify-center overflow-hidden;
min-height: 0;
background-color: #070b14;
background-color: #0f172a;
background-image:
radial-gradient(ellipse 50% 40% at 16% 20%, rgba(56, 130, 200, 0.14), transparent 72%),
radial-gradient(ellipse 45% 36% at 84% 16%, rgba(52, 168, 140, 0.12), transparent 70%),
radial-gradient(ellipse 40% 30% at 72% 82%, rgba(64, 170, 180, 0.08), transparent 72%),
radial-gradient(ellipse 55% 40% at 50% 108%, rgba(40, 90, 140, 0.08), transparent 65%);
radial-gradient(ellipse 50% 40% at 16% 20%, rgba(14, 165, 233, 0.08), transparent 72%),
radial-gradient(ellipse 45% 36% at 84% 16%, rgba(20, 184, 166, 0.06), transparent 70%),
radial-gradient(ellipse 40% 30% at 72% 82%, rgba(51, 65, 85, 0.35), transparent 72%);
}
.app-desktop-stage::before,
@@ -211,7 +255,7 @@
border-radius: 9999px;
filter: blur(90px);
z-index: 0;
opacity: 0.55;
opacity: 0.4;
}
.app-desktop-stage::before {
@@ -219,7 +263,7 @@
height: min(40vw, 320px);
top: -6%;
left: 10%;
background: rgba(56, 130, 200, 0.22);
background: rgba(14, 165, 233, 0.14);
}
.app-desktop-stage::after {
@@ -227,22 +271,19 @@
height: min(36vw, 280px);
bottom: -4%;
right: 8%;
background: rgba(52, 168, 140, 0.2);
background: rgba(20, 184, 166, 0.1);
}
/* Front phone surface: clearer glass for readable content */
/* Front phone surface: calm opaque shell for readable content */
.app-mobile-shell {
@apply relative z-10 flex w-full max-w-[430px] flex-1 flex-col overflow-hidden;
min-height: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: rgba(9, 14, 26, 0.62);
backdrop-filter: blur(32px) saturate(1.15);
-webkit-backdrop-filter: blur(32px) saturate(1.15);
border: 1px solid rgba(90, 190, 210, 0.28);
background: #0f172a;
border: 1px solid rgb(51 65 85 / 0.55);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 1px rgba(70, 170, 160, 0.08),
0 20px 60px rgba(0, 0, 0, 0.4);
0 1px 2px rgb(0 0 0 / 0.2),
0 20px 50px rgb(0 0 0 / 0.35);
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -258,49 +299,45 @@
border: none;
border-radius: 0;
box-shadow: none;
background: rgba(10, 15, 29, 0.55);
background: #0f172a;
}
html.light .app-mobile-shell {
background: rgba(255, 255, 255, 0.88);
background: #f8fafc;
}
}
html.light .app-desktop-stage {
background-color: #e6eef6;
background-color: #e2e8f0;
background-image:
radial-gradient(ellipse 55% 45% at 14% 18%, rgba(56, 160, 230, 0.22), transparent 72%),
radial-gradient(ellipse 50% 40% at 86% 14%, rgba(52, 184, 150, 0.18), transparent 70%),
radial-gradient(ellipse 45% 35% at 70% 84%, rgba(64, 180, 210, 0.14), transparent 72%);
radial-gradient(ellipse 50% 40% at 16% 20%, rgba(14, 165, 233, 0.1), transparent 72%),
radial-gradient(ellipse 45% 36% at 84% 16%, rgba(20, 184, 166, 0.08), transparent 70%),
radial-gradient(ellipse 55% 40% at 50% 108%, rgba(148, 163, 184, 0.2), transparent 65%);
}
html.light .app-desktop-stage::before {
background: rgba(56, 160, 230, 0.28);
opacity: 0.7;
background: rgba(14, 165, 233, 0.16);
opacity: 0.55;
}
html.light .app-desktop-stage::after {
background: rgba(52, 184, 150, 0.24);
opacity: 0.65;
background: rgba(20, 184, 166, 0.14);
opacity: 0.5;
}
html.light .app-mobile-shell {
background: rgba(255, 255, 255, 0.62);
border-color: rgba(56, 160, 200, 0.28);
background: #f8fafc;
border-color: rgb(226 232 240 / 0.95);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 0 0 1px rgba(56, 160, 200, 0.08),
0 20px 50px rgba(47, 127, 212, 0.12);
0 1px 2px rgb(15 23 42 / 0.04),
0 20px 40px rgb(15 23 42 / 0.08);
}
.glass-panel {
@apply relative rounded-2xl backdrop-blur-xl;
background: var(--glass-bg);
border: 1px solid rgba(90, 200, 210, 0.38);
box-shadow:
0 0 0 1px rgba(70, 170, 160, 0.12),
var(--glow-border),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
border: 1px solid rgb(226 232 240 / 0.9);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
/* Chat messages surface — high-intensity glass, no extra border */
@@ -343,16 +380,12 @@
}
/* Chat composer */
/* Chat composer — single flat background (no nested input fill) */
.composer-glass {
@apply relative flex min-h-12 w-full items-end gap-2 rounded-2xl p-1.5;
background: rgba(255, 255, 255, 0.28);
backdrop-filter: blur(28px) saturate(1.5);
-webkit-backdrop-filter: blur(28px) saturate(1.5);
border: 1px solid rgb(47 127 212 / 0.2);
box-shadow:
0 8px 18px rgba(47, 127, 212, 0.12),
0 6px 14px rgba(52, 184, 150, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
background: #ffffff;
border: 1px solid rgb(226 232 240);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
}
.composer-glass textarea.input-neon {
@@ -364,69 +397,94 @@
}
.composer-glass:focus-within {
border-color: rgb(38 212 202 / 0.55);
box-shadow:
0 12px 32px rgba(47, 127, 212, 0.22),
0 0 0 1px rgba(56, 160, 230, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
0 0 0 1px rgb(38 212 202 / 0.3),
0 3px 8px rgb(15 23 42 / 0.08);
}
html.dark .composer-glass,
html.dark .composer-glass:focus-within {
background: rgba(12, 20, 36, 0.45);
border-color: rgb(59 158 255 / 0.2);
background: #1e293b;
border-color: rgb(51 65 85 / 0.9);
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
html.dark .composer-glass:focus-within {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.45),
0 3px 10px rgb(0 0 0 / 0.35),
0 0 14px rgb(38 212 202 / 0.35),
0 0 22px rgb(0 168 232 / 0.2);
}
html.light .composer-glass,
html.light .composer-glass:focus-within {
background: rgba(255, 255, 255, 0.55);
border-color: rgb(47 127 212 / 0.2);
background: #ffffff;
border-color: rgb(203 213 225 / 0.95);
}
.composer-glass .input-neon {
background: transparent;
border: none;
html.light .composer-glass:focus-within {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.28),
0 3px 8px rgb(15 23 42 / 0.08),
0 0 8px rgb(38 212 202 / 0.16);
}
.composer-glass .input-neon,
html.light .composer-glass .input-neon,
html.dark .composer-glass .input-neon {
background: transparent !important;
border: none !important;
color: hsl(var(--foreground));
box-shadow: none;
box-shadow: none !important;
backdrop-filter: none;
}
.composer-glass .input-neon::placeholder {
color: hsl(var(--muted-foreground));
.composer-glass .input-neon::placeholder,
html.light .composer-glass .input-neon::placeholder,
html.dark .composer-glass .input-neon::placeholder {
color: hsl(var(--muted-foreground) / 0.85);
}
.composer-glass .input-neon:focus-visible {
box-shadow: none;
}
html.light .composer-glass .input-neon::placeholder {
color: hsl(var(--muted-foreground));
box-shadow: none !important;
}
.glass-panel-strong {
@apply relative rounded-2xl backdrop-blur-2xl;
@apply relative rounded-2xl backdrop-blur-md;
background: var(--glass-bg-strong);
border: 1px solid rgba(90, 200, 210, 0.45);
border: 1px solid rgb(226 232 240 / 0.95);
box-shadow:
0 0 0 1px rgba(70, 170, 160, 0.14),
var(--glow-border),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
0 8px 24px rgb(15 23 42 / 0.08),
0 1px 2px rgb(15 23 42 / 0.04);
}
.neon-border {
border: 1px solid transparent;
background:
linear-gradient(var(--glass-bg), var(--glass-bg)) padding-box,
linear-gradient(135deg, rgba(47, 127, 212, 0.65), rgba(43, 184, 201, 0.55), rgba(52, 184, 150, 0.55))
border-box;
box-shadow: var(--glow-border);
border: 1px solid rgb(226 232 240);
background: var(--glass-bg);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
html.light .glass-panel,
html.light .glass-panel-strong {
border-color: rgba(47, 127, 212, 0.28);
border-color: rgb(226 232 240 / 0.95);
box-shadow:
0 0 0 1px rgba(52, 184, 150, 0.08),
var(--glow-border),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
0 1px 2px rgb(15 23 42 / 0.04),
0 4px 12px rgb(15 23 42 / 0.03);
}
html.dark .glass-panel,
html.dark .glass-panel-strong {
border-color: rgb(51 65 85 / 0.55);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
}
html.dark .neon-border {
border-color: rgb(51 65 85 / 0.55);
background: var(--glass-bg);
}
.neon-glow {
@@ -441,63 +499,118 @@
@apply relative inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition-all duration-300;
background: var(--gradient-neon);
background-size: 160% 160%;
box-shadow:
0 0 18px rgba(0, 102, 255, 0.45),
0 0 36px rgba(0, 255, 204, 0.2);
border: 1px solid transparent;
box-shadow: 0 2px 4px rgb(15 23 42 / 0.08), 0 2px 8px rgb(38 212 202 / 0.2);
}
.btn-neon:hover:not(:disabled) {
filter: brightness(1.08);
box-shadow:
0 0 24px rgba(0, 102, 255, 0.55),
0 0 48px rgba(0, 255, 204, 0.28);
filter: brightness(1.05);
border-color: rgb(38 212 202 / 0.55);
box-shadow: 0 3px 8px rgb(15 23 42 / 0.1), 0 4px 12px rgb(38 212 202 / 0.28);
}
html.light .btn-neon {
box-shadow:
0 0 14px rgba(47, 127, 212, 0.28),
0 0 28px rgba(52, 184, 150, 0.14);
box-shadow: 0 2px 4px rgb(15 23 42 / 0.06), 0 2px 8px rgb(38 212 202 / 0.16);
}
html.light .btn-neon:hover:not(:disabled) {
filter: brightness(1.04);
box-shadow:
0 0 18px rgba(47, 127, 212, 0.36),
0 0 36px rgba(52, 184, 150, 0.18);
border-color: rgb(38 212 202 / 0.5);
box-shadow: 0 3px 8px rgb(15 23 42 / 0.08), 0 4px 12px rgb(38 212 202 / 0.22);
}
/* Dark mode: logo teal/blue neon glow */
html.dark .btn-neon {
box-shadow:
0 2px 6px rgb(0 0 0 / 0.35),
0 0 12px rgb(38 212 202 / 0.45),
0 0 28px rgb(0 168 232 / 0.25);
}
html.dark .btn-neon:hover:not(:disabled) {
filter: brightness(1.08);
border-color: #26d4ca;
box-shadow:
0 3px 10px rgb(0 0 0 / 0.4),
0 0 16px rgb(38 212 202 / 0.6),
0 0 36px rgb(0 168 232 / 0.32);
}
.btn-neon:focus,
.btn-neon:focus-visible {
outline: none;
border-width: 1px;
border-color: #26d4ca;
box-shadow:
0 0 0 2px var(--navy),
0 0 0 4px var(--neon-cyan),
0 0 24px rgba(0, 210, 255, 0.5);
0 0 0 1px rgb(38 212 202 / 0.35),
0 3px 8px rgb(15 23 42 / 0.1),
0 0 10px rgb(38 212 202 / 0.22);
}
html.dark .btn-neon:focus,
html.dark .btn-neon:focus-visible {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.5),
0 3px 10px rgb(0 0 0 / 0.4),
0 0 14px rgb(38 212 202 / 0.45),
0 0 24px rgb(0 168 232 / 0.25);
}
.btn-glass {
@apply inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-300;
background: rgba(15, 23, 42, 0.45);
border: 1px solid rgba(0, 210, 255, 0.35);
background: rgb(241 245 249 / 0.9);
border: 1px solid rgb(203 213 225 / 0.95);
color: var(--body-text);
backdrop-filter: blur(12px);
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
}
.btn-glass:hover:not(:disabled) {
border-color: rgba(0, 255, 204, 0.55);
box-shadow: var(--glow-teal);
border-color: rgb(38 212 202 / 0.5);
background: rgb(248 250 252);
color: hsl(var(--foreground));
box-shadow: 0 3px 8px rgb(15 23 42 / 0.07);
}
html.dark .btn-glass {
background: rgb(30 41 59 / 0.85);
border-color: rgb(38 212 202 / 0.28);
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
html.dark .btn-glass:hover:not(:disabled) {
border-color: #26d4ca;
background: rgb(51 65 85 / 0.75);
box-shadow:
0 3px 10px rgb(0 0 0 / 0.35),
0 0 12px rgb(38 212 202 / 0.28),
0 0 22px rgb(0 168 232 / 0.15);
}
html.light .btn-glass {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(47, 127, 212, 0.28);
background: rgba(255, 255, 255, 0.92);
border-color: rgb(203 213 225 / 0.95);
}
.btn-glass:focus,
.btn-glass:focus-visible {
outline: none;
border-width: 1px;
border-color: #26d4ca;
box-shadow:
0 0 0 2px var(--navy),
0 0 0 4px var(--neon-cyan);
0 0 0 1px rgb(38 212 202 / 0.3),
0 3px 8px rgb(15 23 42 / 0.08),
0 0 8px rgb(38 212 202 / 0.16);
}
html.dark .btn-glass:focus,
html.dark .btn-glass:focus-visible {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.45),
0 3px 10px rgb(0 0 0 / 0.35),
0 0 12px rgb(38 212 202 / 0.35),
0 0 20px rgb(0 168 232 / 0.18);
}
.btn-danger-neon {
@@ -505,54 +618,95 @@
background: rgba(127, 29, 29, 0.35);
border: 1px solid rgba(248, 113, 113, 0.45);
color: #fecaca;
backdrop-filter: blur(12px);
box-shadow: 0 2px 4px rgb(0 0 0 / 0.2);
}
html.light .btn-danger-neon {
background: rgba(254, 226, 226, 0.9);
border-color: rgba(220, 38, 38, 0.35);
color: #b91c1c;
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
}
.btn-danger-neon:hover:not(:disabled) {
border-color: rgba(248, 113, 113, 0.75);
box-shadow: 0 0 18px rgba(239, 68, 68, 0.35);
box-shadow: 0 3px 8px rgb(0 0 0 / 0.25), 0 0 14px rgba(239, 68, 68, 0.3);
color: #fff;
}
html.light .btn-danger-neon:hover:not(:disabled) {
color: #991b1b;
background: rgba(254, 202, 202, 0.95);
box-shadow: 0 3px 8px rgb(15 23 42 / 0.08);
}
.btn-danger-neon:focus,
.btn-danger-neon:focus-visible {
outline: none;
border-width: 1px;
border-color: #f87171;
box-shadow:
0 0 0 1px rgb(248 113 113 / 0.4),
0 3px 8px rgb(15 23 42 / 0.1);
}
.input-neon {
@apply flex h-11 w-full items-center rounded-xl px-3 py-0 text-sm leading-normal transition-all duration-300;
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(0, 210, 255, 0.28);
@apply flex h-11 w-full items-center rounded-xl px-3 py-0 text-sm leading-relaxed transition-all duration-300;
background: rgb(248 250 252);
border: 1px solid rgb(203 213 225 / 0.9);
color: hsl(var(--foreground));
backdrop-filter: blur(10px);
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
}
.input-neon::placeholder {
color: rgba(226, 232, 240, 0.45);
color: hsl(var(--muted-foreground) / 0.8);
}
html.dark .input-neon {
background: rgb(30 41 59 / 0.9);
border-color: rgb(71 85 105 / 0.75);
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
html.light .input-neon {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(47, 127, 212, 0.28);
background: rgba(255, 255, 255, 0.95);
border-color: rgb(203 213 225 / 0.95);
color: hsl(var(--foreground));
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
}
html.light .input-neon::placeholder {
color: rgba(51, 65, 85, 0.45);
color: hsl(var(--muted-foreground) / 0.8);
}
.input-neon:focus,
.input-neon:focus-visible {
outline: none;
border-color: var(--neon-cyan);
border-width: 1px;
border-color: #26d4ca;
box-shadow:
0 0 0 1px var(--neon-cyan),
0 0 20px rgba(0, 210, 255, 0.35);
0 0 0 1px rgb(38 212 202 / 0.35),
0 3px 8px rgb(15 23 42 / 0.08),
0 0 10px rgb(38 212 202 / 0.2);
}
html.dark .input-neon:focus,
html.dark .input-neon:focus-visible {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.45),
0 3px 10px rgb(0 0 0 / 0.35),
0 0 14px rgb(38 212 202 / 0.4),
0 0 22px rgb(0 168 232 / 0.22);
}
html.light .input-neon:focus,
html.light .input-neon:focus-visible {
border-color: #26d4ca;
box-shadow:
0 0 0 1px rgb(38 212 202 / 0.28),
0 3px 8px rgb(15 23 42 / 0.08),
0 0 8px rgb(38 212 202 / 0.16);
}
.input-neon:disabled {
@@ -561,20 +715,30 @@
}
.progress-neon {
@apply relative h-2 w-full overflow-hidden rounded-full;
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(0, 210, 255, 0.2);
@apply relative h-2.5 w-full overflow-hidden rounded-full;
background: rgb(226 232 240 / 0.85);
border: 1px solid rgb(203 213 225 / 0.6);
}
html.light .progress-neon {
background: rgba(15, 23, 42, 0.08);
border-color: rgba(47, 127, 212, 0.2);
background: rgb(241 245 249);
border-color: rgb(226 232 240);
}
html.dark .progress-neon {
background: rgb(30 41 59);
border-color: rgb(51 65 85 / 0.7);
}
.progress-neon-fill {
@apply h-full rounded-full transition-all duration-300 ease-out;
background: var(--gradient-neon);
box-shadow: 0 0 12px rgba(0, 210, 255, 0.55);
@apply h-full rounded-full transition-all duration-500 ease-out;
background: linear-gradient(90deg, #26d4ca 0%, #00a8e8 100%);
box-shadow: none;
}
html.dark .progress-neon-fill {
background: linear-gradient(90deg, #26d4ca 0%, #00a8e8 100%);
box-shadow: 0 0 10px rgb(38 212 202 / 0.45);
}
.ambient-glow {
@@ -661,13 +825,13 @@
box-shadow: 0 8px 24px rgba(47, 127, 212, 0.12);
}
/* Readable cyan accents on light surfaces */
/* Readable brand accents on light surfaces */
html.light .text-neon-cyan {
color: #0e7490;
color: #0f766e;
}
html.light .text-neon-teal {
color: #0f766e;
color: #0d9488;
}
html.light .text-neon-emerald {
@@ -723,19 +887,23 @@
font-weight: 600;
}
/* App chrome — only rendered when authenticated (not on login/signup). */
/* App chrome — sticky top bar */
.app-header {
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
padding-top: env(safe-area-inset-top, 0px);
background: rgba(255, 255, 255, 0.28);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow: 0 8px 24px rgba(47, 127, 212, 0.08), 0 6px 16px rgba(52, 184, 150, 0.06);
background: rgb(248 250 252 / 0.85);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border-bottom: 1px solid rgb(226 232 240 / 0.8);
box-shadow: none;
}
html.dark .app-header {
background: rgba(12, 20, 36, 0.42);
background: rgb(15 23 42 / 0.88);
border-bottom-color: rgb(51 65 85 / 0.55);
}
/* Scrollable route area below header */
@@ -748,6 +916,64 @@
padding-top: env(safe-area-inset-top, 0px);
}
.header-menu-panel {
@apply absolute left-0 z-[70] mt-2 w-56 origin-top-left overflow-hidden rounded-2xl p-1.5;
background: #ffffff;
border: 1px solid rgb(226 232 240);
box-shadow:
0 12px 32px rgb(15 23 42 / 0.18),
0 1px 2px rgb(15 23 42 / 0.06);
}
html.dark .header-menu-panel {
background: #1e293b;
border-color: rgb(51 65 85 / 0.9);
box-shadow:
0 12px 32px rgb(0 0 0 / 0.45),
0 1px 2px rgb(0 0 0 / 0.25);
}
.header-menu-identity {
@apply mb-1 border-b border-slate-200 px-3 py-2.5 dark:border-slate-700/60;
}
.header-menu-item {
@apply flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-body transition-colors duration-200;
}
.header-menu-item:hover,
.header-menu-item:focus-visible {
background: rgb(241 245 249);
color: hsl(var(--foreground));
outline: none;
}
html.dark .header-menu-item:hover,
html.dark .header-menu-item:focus-visible {
background: rgb(51 65 85 / 0.65);
color: #f8fafc;
}
.header-menu-item-danger {
@apply text-red-600;
}
.header-menu-item-danger:hover,
.header-menu-item-danger:focus-visible {
background: rgb(254 226 226 / 0.7);
color: #b91c1c;
}
html.dark .header-menu-item-danger {
color: #fca5a5;
}
html.dark .header-menu-item-danger:hover,
html.dark .header-menu-item-danger:focus-visible {
background: rgb(127 29 29 / 0.35);
color: #fecaca;
}
.ai-thinking-bubble {
@apply rounded-2xl;
background: rgba(255, 255, 255, 0.16);
@@ -769,18 +995,23 @@
width: 6px;
height: 6px;
border-radius: 9999px;
background: #00d2ff;
box-shadow: 0 0 8px rgba(0, 210, 255, 0.75), 0 0 14px rgba(0, 255, 204, 0.45);
background: #26d4ca;
box-shadow: 0 0 8px rgba(38, 212, 202, 0.55);
animation: ai-dot-bounce 1.15s ease-in-out infinite;
}
html.dark .ai-thinking-dot {
box-shadow: 0 0 8px rgba(38, 212, 202, 0.75), 0 0 14px rgba(0, 168, 232, 0.4);
}
.ai-thinking-dot:nth-child(2) {
animation-delay: 0.16s;
background: #00ffcc;
background: #00a8e8;
}
.ai-thinking-dot:nth-child(3) {
animation-delay: 0.32s;
background: #26d4ca;
}
.ai-stream-cursor {
@@ -790,13 +1021,17 @@
margin-inline-start: 3px;
vertical-align: text-bottom;
border-radius: 1px;
background: linear-gradient(180deg, #00d2ff 0%, #00ffcc 100%);
box-shadow:
0 0 8px rgba(0, 210, 255, 0.85),
0 0 16px rgba(0, 255, 204, 0.55);
background: linear-gradient(180deg, #26d4ca 0%, #00a8e8 100%);
box-shadow: none;
animation: ai-cursor-blink 0.95s step-end infinite;
}
html.dark .ai-stream-cursor {
box-shadow:
0 0 8px rgba(38, 212, 202, 0.75),
0 0 14px rgba(0, 168, 232, 0.45);
}
@keyframes ai-dot-bounce {
0%,
80%,
+8 -8
View File
@@ -7,7 +7,7 @@ export default {
theme: {
extend: {
fontFamily: {
sans: ['Vazirmatn', 'Shabnam', 'Tahoma', ...defaultTheme.fontFamily.sans],
sans: ['IRANSansX', 'Tahoma', ...defaultTheme.fontFamily.sans],
},
colors: {
border: 'hsl(var(--border))',
@@ -48,9 +48,9 @@ export default {
deep: '#070a14',
},
neon: {
blue: '#0066ff',
cyan: '#00d2ff',
teal: '#00ffcc',
blue: '#00a8e8',
cyan: '#26d4ca',
teal: '#26d4ca',
emerald: '#10b981',
},
},
@@ -60,12 +60,12 @@ export default {
sm: 'calc(var(--radius) - 4px)',
},
boxShadow: {
glow: '0 0 20px rgba(0, 102, 255, 0.35), 0 0 40px rgba(0, 210, 255, 0.15)',
'glow-teal': '0 0 20px rgba(0, 255, 204, 0.3), 0 0 40px rgba(16, 185, 129, 0.15)',
'glow-sm': '0 0 12px rgba(0, 210, 255, 0.25)',
glow: '0 0 18px rgba(0, 168, 232, 0.4), 0 0 36px rgba(38, 212, 202, 0.2)',
'glow-teal': '0 0 18px rgba(38, 212, 202, 0.5), 0 0 36px rgba(38, 212, 202, 0.22)',
'glow-sm': '0 0 12px rgba(38, 212, 202, 0.4)',
},
backgroundImage: {
'neon-gradient': 'linear-gradient(135deg, #0066ff 0%, #00d2ff 50%, #00ffcc 100%)',
'neon-gradient': 'linear-gradient(135deg, #26d4ca 0%, #14c4db 48%, #00a8e8 100%)',
},
keyframes: {
'pulse-glow': {
+7 -1
View File
@@ -23,7 +23,13 @@ export default defineConfig(({ mode }) => {
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
includeAssets: [
'favicon.ico',
'favicon-16x16.png',
'favicon-32x32.png',
'apple-touch-icon.png',
'logo/mainlogo.png',
],
manifest: {
name: 'نویا',
short_name: 'نویا',