Polish Novya UI with brand colors, first-load skeletons, and session transition overlay. #1
@@ -121,6 +121,8 @@ Timestamps are authoritative. Derived steps:
|
|||||||
|
|
||||||
Permissions on `GET /journey`: `canTakePreTest`, `canOpenTraining`, `canTakePostTest`, `isCompleted`.
|
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
|
## API Endpoints
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
@@ -21,12 +21,23 @@ const stripHtml = (html: string): string =>
|
|||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const mentionsPretest = (text: string): boolean =>
|
||||||
|
text.includes('پیشآزمون') || text.includes('پیش آزمون');
|
||||||
|
|
||||||
const isPretestExercise = (block: ContentBlock): boolean => {
|
const isPretestExercise = (block: ContentBlock): boolean => {
|
||||||
if (block.type !== 'exercise') return false;
|
if (block.type !== 'exercise') return false;
|
||||||
const haystack = `${block.title ?? ''} ${block.prompt ?? ''}`;
|
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 =>
|
const hasUserDirectedSpousePrompt = (text: string): boolean =>
|
||||||
text.includes('همسر خود') ||
|
text.includes('همسر خود') ||
|
||||||
text.includes('با همسر') ||
|
text.includes('با همسر') ||
|
||||||
@@ -35,18 +46,26 @@ const hasUserDirectedSpousePrompt = (text: string): boolean =>
|
|||||||
|
|
||||||
const formatBlock = (block: ContentBlock): string | null => {
|
const formatBlock = (block: ContentBlock): string | null => {
|
||||||
if (isPretestExercise(block)) {
|
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) {
|
switch (block.type) {
|
||||||
case 'callout':
|
case 'callout':
|
||||||
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
|
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
|
||||||
case 'heading':
|
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}`;
|
return `[heading h${block.level ?? 3}] ${block.text}`;
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
|
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
|
||||||
if (body.includes('پیشآزمون') || body.includes('پیش آزمون')) {
|
if (mentionsPretest(body)) {
|
||||||
return `[text — OMIT پیشآزمون when speaking; it is not part of the educational conversation] ${body}`;
|
const cleaned = stripPretestFromSpeech(body);
|
||||||
|
if (!cleaned || cleaned.length < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `[text] ${cleaned}`;
|
||||||
}
|
}
|
||||||
return `[text] ${body}`;
|
return `[text] ${body}`;
|
||||||
}
|
}
|
||||||
@@ -131,6 +150,8 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str
|
|||||||
if (b.type !== 'heading') return null;
|
if (b.type !== 'heading') return null;
|
||||||
const text = b.text.replace(/\s+/g, ' ').trim();
|
const text = b.text.replace(/\s+/g, ' ').trim();
|
||||||
if (titleNorm && text === titleNorm) return null;
|
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;
|
if (seen.has(text)) return null;
|
||||||
seen.add(text);
|
seen.add(text);
|
||||||
return `- ${text}`;
|
return `- ${text}`;
|
||||||
@@ -183,10 +204,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory):
|
|||||||
• that learning happens through conversation and interaction along a guided path
|
• 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.
|
- 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.
|
- 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.
|
- 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. When you reach it internally: skip it silently and continue Session 1 teaching.
|
- 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.
|
||||||
- If a Session 1 text block mentions that a pre-test is administered: OMIT that phrase when speaking. Teach orientation only.
|
- 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.
|
||||||
- If the user asks about پیشآزمون: do not explain it; say this chat is for the educational sessions, then continue the lesson.
|
- 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.
|
- 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.
|
- 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.
|
- 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):
|
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.
|
- 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:
|
- You must NEVER in chat:
|
||||||
• administer another psychological assessment or screening
|
• administer another psychological assessment or screening
|
||||||
• invent questionnaire items or substitute pre-test/post-test activities
|
• 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
12. Stay on this session until completion is validated; do not teach locked/future sessions.
|
||||||
|
|||||||
@@ -193,16 +193,84 @@ export function validateCoachSessionCompletionSignal(
|
|||||||
return { valid: true, signal: s as CoachSessionCompletionSignal };
|
return { valid: true, signal: s as CoachSessionCompletionSignal };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Final session: coach directed learner to in-app post-test without emitting the hidden block. */
|
/** Strip ZWNJ and collapse whitespace for Persian phrase matching. */
|
||||||
const looksLikeFinalPostTestHandoff = (text: string): boolean => {
|
const normalizePersian = (text: string): string =>
|
||||||
const normalized = text.replace(/\u200c/g, '').replace(/\s+/g, ' ');
|
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 mentionsPostTest = /پس[\s\-]?آزمون/.test(normalized);
|
||||||
const directsToApp =
|
const courseFinished =
|
||||||
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|انجام بده|بزن)/.test(normalized) ||
|
/(جلسه|جلسات|دوره|آموزش|برنامه).{0,50}(تمام|تموم|پایان|به پایان|تمام شده|تموم شده|به اتمام)/.test(
|
||||||
/(جلسه|دوره).{0,40}(تمام|تموم|پایان|تمام شده)/.test(normalized);
|
normalized,
|
||||||
return mentionsPostTest && directsToApp;
|
) ||
|
||||||
|
/(تمام|تموم|پایان).{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(
|
export function processCoachAssistantResponse(
|
||||||
rawAssistantText: string,
|
rawAssistantText: string,
|
||||||
context: AiCoachSessionContext,
|
context: AiCoachSessionContext,
|
||||||
@@ -212,27 +280,59 @@ export function processCoachAssistantResponse(
|
|||||||
},
|
},
|
||||||
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
|
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
|
||||||
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
|
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
|
||||||
|
const handoff =
|
||||||
|
context.isFinalSession && looksLikeFinalPostTestHandoff(parsed.displayContent);
|
||||||
|
|
||||||
let signal = parsed.signal;
|
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 (
|
if (
|
||||||
!signal &&
|
!signal &&
|
||||||
context.isFinalSession &&
|
context.isFinalSession &&
|
||||||
!options.alreadyCompleted &&
|
!options.alreadyCompleted &&
|
||||||
options.userTurnCount >= 2 &&
|
options.userTurnCount >= 2 &&
|
||||||
looksLikeFinalPostTestHandoff(parsed.displayContent)
|
handoff
|
||||||
) {
|
) {
|
||||||
signal = {
|
signal = normalizeFinalSessionSignal(
|
||||||
...exampleCompletionSignal(context),
|
{},
|
||||||
reason:
|
context,
|
||||||
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
|
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return { ...parsed, validation: null };
|
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 };
|
return { ...parsed, signal, validation };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getOrCreateJourney,
|
getOrCreateJourney,
|
||||||
deriveCurrentStep,
|
deriveCurrentStep,
|
||||||
derivePermissions,
|
derivePermissions,
|
||||||
|
countCompletedSessions,
|
||||||
} from '../journey/journey.service.js';
|
} from '../journey/journey.service.js';
|
||||||
import {
|
import {
|
||||||
PCQ_QUESTION_IDS,
|
PCQ_QUESTION_IDS,
|
||||||
@@ -94,8 +95,11 @@ export function getPcq24Scale() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function submitAttempt(userId: bigint, input: SubmitAttemptInput) {
|
export async function submitAttempt(userId: bigint, input: SubmitAttemptInput) {
|
||||||
const journey = await getOrCreateJourney(userId);
|
const [journey, completedSessionCount] = await Promise.all([
|
||||||
const permissions = derivePermissions(journey);
|
getOrCreateJourney(userId),
|
||||||
|
countCompletedSessions(userId),
|
||||||
|
]);
|
||||||
|
const permissions = derivePermissions(journey, { completedSessionCount });
|
||||||
|
|
||||||
if (input.phase === 'pre' && !permissions.canTakePreTest) {
|
if (input.phase === 'pre' && !permissions.canTakePreTest) {
|
||||||
throw new ConflictError('Pre-test already completed');
|
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) {
|
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.pre),
|
||||||
getLatestAttempt(userId, AttemptPhase.post),
|
getLatestAttempt(userId, AttemptPhase.post),
|
||||||
getOrCreateJourney(userId),
|
getOrCreateJourney(userId),
|
||||||
|
countCompletedSessions(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -223,7 +228,7 @@ export async function getAssessmentSummary(userId: bigint) {
|
|||||||
post,
|
post,
|
||||||
journey: {
|
journey: {
|
||||||
currentStep: deriveCurrentStep(journey),
|
currentStep: deriveCurrentStep(journey),
|
||||||
permissions: derivePermissions(journey),
|
permissions: derivePermissions(journey, { completedSessionCount }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../shared/errors/index.js';
|
} from '../../shared/errors/index.js';
|
||||||
import { getOrCreateJourney, derivePermissions } from '../journey/journey.service.js';
|
import { getOrCreateJourney, derivePermissions } from '../journey/journey.service.js';
|
||||||
import {
|
import {
|
||||||
|
completeSession,
|
||||||
getCompletedOrders,
|
getCompletedOrders,
|
||||||
getSessionCompletionTimes,
|
getSessionCompletionTimes,
|
||||||
isSessionUnlockedForProgress,
|
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 {
|
return {
|
||||||
message: serializeMessage(assistant),
|
message: serializeMessage(assistant),
|
||||||
completion: processed.validation?.valid
|
completion: processed.validation?.valid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { SessionProgressStatus, type LearningJourney } from '@prisma/client';
|
||||||
import { prisma } from '../../lib/prisma.js';
|
import { prisma } from '../../lib/prisma.js';
|
||||||
import type { LearningJourney } from '@prisma/client';
|
import { PROGRAM_SESSION_COUNT } from '../sessions/catalog.js';
|
||||||
|
|
||||||
export type LearningStep =
|
export type LearningStep =
|
||||||
| 'registered'
|
| 'registered'
|
||||||
@@ -26,6 +27,9 @@ export type PublicJourney = {
|
|||||||
permissions: JourneyPermissions;
|
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:
|
* Timestamps are authoritative. currentStep is a denormalized cache of this derivation:
|
||||||
* registered → pre_test_done → training_in_progress → training_completed → completed
|
* registered → pre_test_done → training_in_progress → training_completed → completed
|
||||||
@@ -47,12 +51,25 @@ export function deriveCurrentStep(journey: LearningJourney): LearningStep {
|
|||||||
return 'registered';
|
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 {
|
return {
|
||||||
canTakePreTest: !journey.preTestCompletedAt,
|
canTakePreTest: !journey.preTestCompletedAt,
|
||||||
canOpenTraining: Boolean(journey.preTestCompletedAt),
|
canOpenTraining: Boolean(journey.preTestCompletedAt),
|
||||||
canTakePostTest: Boolean(
|
canTakePostTest: Boolean(
|
||||||
journey.preTestCompletedAt && journey.trainingCompletedAt && !journey.postTestCompletedAt,
|
journey.preTestCompletedAt && reachedFinalSession && !journey.postTestCompletedAt,
|
||||||
),
|
),
|
||||||
isCompleted: Boolean(journey.postTestCompletedAt),
|
isCompleted: Boolean(journey.postTestCompletedAt),
|
||||||
};
|
};
|
||||||
@@ -62,7 +79,16 @@ function toIso(value: Date | null): string | null {
|
|||||||
return value ? value.toISOString() : 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);
|
const currentStep = deriveCurrentStep(journey);
|
||||||
return {
|
return {
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -71,7 +97,7 @@ export function toPublicJourney(journey: LearningJourney): PublicJourney {
|
|||||||
trainingCompletedAt: toIso(journey.trainingCompletedAt),
|
trainingCompletedAt: toIso(journey.trainingCompletedAt),
|
||||||
postTestCompletedAt: toIso(journey.postTestCompletedAt),
|
postTestCompletedAt: toIso(journey.postTestCompletedAt),
|
||||||
lastActivityAt: journey.lastActivityAt.toISOString(),
|
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> {
|
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);
|
const currentStep = deriveCurrentStep(journey);
|
||||||
if (journey.currentStep !== currentStep) {
|
if (journey.currentStep !== currentStep) {
|
||||||
const healed = await prisma.learningJourney.update({
|
const healed = await prisma.learningJourney.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: { currentStep },
|
data: { currentStep },
|
||||||
});
|
});
|
||||||
return toPublicJourney(healed);
|
return toPublicJourney(healed, { completedSessionCount });
|
||||||
}
|
}
|
||||||
return toPublicJourney(journey);
|
return toPublicJourney(journey, { completedSessionCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncCurrentStep(journey: LearningJourney): LearningStep {
|
export function syncCurrentStep(journey: LearningJourney): LearningStep {
|
||||||
|
|||||||
@@ -253,6 +253,19 @@ export async function completeSession(
|
|||||||
where: { userId, sessionOrder: order, endedAt: null },
|
where: { userId, sessionOrder: order, endedAt: null },
|
||||||
data: { endedAt: now },
|
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);
|
return getSessionProgress(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ describe('full E2E learning journey QA', () => {
|
|||||||
const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(beforeFinal.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest,
|
(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);
|
await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT, owner.user.id);
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
<html lang="fa" dir="rtl" class="light">
|
<html lang="fa" dir="rtl" class="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#e6eef6" />
|
<meta name="theme-color" content="#e6eef6" />
|
||||||
<meta name="description" content="پلتفرم روانشناسی و سلامت روان نویا" />
|
<meta name="description" content="پلتفرم روانشناسی و سلامت روان نویا" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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>
|
<title>نویا</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -20,7 +17,3 @@
|
|||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 654 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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 |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -63,9 +63,13 @@ const checks = {
|
|||||||
thinContentRule: prompt.includes('CRITICAL THIN-CONTENT RULE'),
|
thinContentRule: prompt.includes('CRITICAL THIN-CONTENT RULE'),
|
||||||
noSubstitutePretest: prompt.includes('Do NOT retake, recreate, simulate, or substitute'),
|
noSubstitutePretest: prompt.includes('Do NOT retake, recreate, simulate, or substitute'),
|
||||||
noRepeatPretestInChat: prompt.includes('Do NOT repeat the pre-test inside Chat'),
|
noRepeatPretestInChat: prompt.includes('Do NOT repeat the pre-test inside Chat'),
|
||||||
silentSkipPretest: prompt.includes('silent skip'),
|
silentSkipPretest: prompt.includes('silent skip') || prompt.includes('silent-skip'),
|
||||||
omitPretestWhenSpeaking: prompt.includes('OMIT that phrase when speaking'),
|
omitPretestWhenSpeaking: prompt.includes('ZERO words about it'),
|
||||||
neverMentionPretestToUser: prompt.includes('NEVER mention «پیشآزمون»'),
|
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'),
|
assessmentResultsRule: prompt.includes('do NOT invent interpretation'),
|
||||||
completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'),
|
completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'),
|
||||||
completionMarker: prompt.includes('COACH_SESSION_COMPLETE'),
|
completionMarker: prompt.includes('COACH_SESSION_COMPLETE'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute';
|
|||||||
import { AppHeader } from '@/components/AppHeader';
|
import { AppHeader } from '@/components/AppHeader';
|
||||||
import { HomeRedirect } from '@/components/HomeRedirect';
|
import { HomeRedirect } from '@/components/HomeRedirect';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { PageSkeleton } from '@/components/skeletons';
|
||||||
|
|
||||||
const AccountPage = lazy(() => import('@/pages/Account/AccountPage'));
|
const AccountPage = lazy(() => import('@/pages/Account/AccountPage'));
|
||||||
const ChatPage = lazy(() => import('@/pages/Chat/ChatPage'));
|
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 SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage'));
|
||||||
const ProfileCompletionPage = lazy(() => import('@/pages/Profile/ProfileCompletionPage'));
|
const ProfileCompletionPage = lazy(() => import('@/pages/Profile/ProfileCompletionPage'));
|
||||||
|
|
||||||
const PageLoader = () => (
|
const PageLoader = () => <PageSkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { APP_NAME } from '@/utils/constants';
|
import { APP_NAME } from '@/utils/constants';
|
||||||
|
import { BrandLogo } from '@/components/BrandLogo';
|
||||||
import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react';
|
import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react';
|
||||||
import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion';
|
import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal top shell: product mark + profile menu (Course Status, Logout).
|
* Minimal top shell: product mark + profile menu (Course Status, Logout).
|
||||||
* Replaces the former bottom navigation.
|
* Replaces the former bottom navigation.
|
||||||
*/
|
*/
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { isAuthenticated, signOut, isGuest } = useAuth();
|
const { isAuthenticated, signOut, isGuest, user } = useAuth();
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -21,6 +23,9 @@ export function AppHeader() {
|
|||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isAuthPage = location.pathname === '/login' || location.pathname === '/signup';
|
const isAuthPage = location.pathname === '/login' || location.pathname === '/signup';
|
||||||
|
const usernameLabel = isGuest
|
||||||
|
? 'مهمان'
|
||||||
|
: user?.email?.trim() || 'کاربر';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -35,8 +40,16 @@ export function AppHeader() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', onPointerDown);
|
document.addEventListener('mousedown', onPointerDown);
|
||||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
@@ -52,24 +65,31 @@ export function AppHeader() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="mx-auto flex h-14 w-full items-center justify-between gap-3 px-4">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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}
|
aria-label={APP_NAME}
|
||||||
>
|
>
|
||||||
{APP_NAME}
|
<BrandLogo imgClassName="h-7 sm:h-8" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMenuOpen((open) => !open)}
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.04 }}
|
||||||
whileTap={{ scale: 0.96 }}
|
whileTap={{ scale: 0.96 }}
|
||||||
transition={fastTransition}
|
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-label="منوی پروفایل"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
@@ -80,39 +100,50 @@ export function AppHeader() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="menu-panel"
|
||||||
role="menu"
|
role="menu"
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.96, y: -4 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.96, y: -4 }}
|
||||||
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
|
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
|
<Link
|
||||||
to="/course-status"
|
to="/course-status"
|
||||||
role="menuitem"
|
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)}
|
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>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
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}
|
onClick={toggleTheme}
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{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 ? 'حالت روشن' : 'حالت تاریک'}
|
{isDark ? 'حالت روشن' : 'حالت تاریک'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
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={() => {
|
onClick={() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setShowLogoutConfirm(true);
|
setShowLogoutConfirm(true);
|
||||||
@@ -129,10 +160,10 @@ export function AppHeader() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{showLogoutConfirm && (
|
{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">
|
<div className="glass-panel-strong w-full max-w-sm space-y-4 p-6">
|
||||||
<h3 className="text-lg font-semibold text-foreground">خروج از حساب کاربری</h3>
|
<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">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
||||||
|
import { PageSkeleton } from '@/components/skeletons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry router at "/".
|
* Entry router at "/".
|
||||||
@@ -11,11 +12,7 @@ export function HomeRedirect() {
|
|||||||
const { progress, loading } = useLearningJourney(effectiveUserId ?? undefined);
|
const { progress, loading } = useLearningJourney(effectiveUserId ?? undefined);
|
||||||
|
|
||||||
if (!guestSessionHydrated || (effectiveUserId && loading)) {
|
if (!guestSessionHydrated || (effectiveUserId && loading)) {
|
||||||
return (
|
return <PageSkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveUserId) {
|
if (!effectiveUserId) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/atoms/auth';
|
} from '@/atoms/auth';
|
||||||
import { hasSessionToken } from '@/services/backend';
|
import { hasSessionToken } from '@/services/backend';
|
||||||
import { ROUTES } from '@/utils/constants';
|
import { ROUTES } from '@/utils/constants';
|
||||||
|
import { PageSkeleton } from '@/components/skeletons';
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -17,12 +18,6 @@ interface ProtectedRouteProps {
|
|||||||
allowIncompleteProfile?: boolean;
|
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) {
|
export function ProtectedRoute({ children, allowIncompleteProfile = false }: ProtectedRouteProps) {
|
||||||
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
|
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
|
||||||
const [backendUser] = useAtom(backendUserAtom);
|
const [backendUser] = useAtom(backendUserAtom);
|
||||||
@@ -40,11 +35,11 @@ export function ProtectedRoute({ children, allowIncompleteProfile = false }: Pro
|
|||||||
const canUseBackendUser = Boolean(backendUser && hasToken);
|
const canUseBackendUser = Boolean(backendUser && hasToken);
|
||||||
|
|
||||||
if (!authInitialCheckDone) {
|
if (!authInitialCheckDone) {
|
||||||
return <AuthBootSpinner />;
|
return <PageSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!guestSessionHydrated) {
|
if (!guestSessionHydrated) {
|
||||||
return <AuthBootSpinner />;
|
return <PageSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated && !guestSession) {
|
if (!isAuthenticated && !guestSession) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -10,15 +10,15 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'btn-neon border-0',
|
default: 'btn-neon',
|
||||||
destructive: 'btn-danger-neon',
|
destructive: 'btn-danger-neon',
|
||||||
outline: 'btn-glass',
|
outline: 'btn-glass',
|
||||||
secondary:
|
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:
|
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',
|
link: 'text-neon-cyan underline-offset-4 hover:underline hover:text-neon-teal',
|
||||||
neon: 'btn-neon border-0',
|
neon: 'btn-neon',
|
||||||
glass: 'btn-glass',
|
glass: 'btn-glass',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ const createLocalId = () => {
|
|||||||
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pause after the closing message finishes typing before session completion UI. */
|
/** Brief pause after the closing message finishes typing before completing the session. */
|
||||||
export const SESSION_COMPLETION_POST_STREAM_DELAY_MS = 10_000;
|
export const SESSION_COMPLETION_POST_STREAM_DELAY_MS = 1_200;
|
||||||
|
|
||||||
type PendingCompletion = {
|
type PendingCompletion = {
|
||||||
completion: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] };
|
completion: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] };
|
||||||
@@ -68,6 +68,9 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAiThinking, setIsAiThinking] = useState(false);
|
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 [isSessionTransitionPending, setIsSessionTransitionPending] = useState(false);
|
||||||
const [chatSessionId, setChatSessionId] = useState<string | null>(null);
|
const [chatSessionId, setChatSessionId] = useState<string | null>(null);
|
||||||
const coachContextRef = useRef<AiCoachSessionContext | null>(coachContext);
|
const coachContextRef = useRef<AiCoachSessionContext | null>(coachContext);
|
||||||
@@ -76,6 +79,8 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const loadedSessionOrderRef = useRef<number | null>(null);
|
const loadedSessionOrderRef = useRef<number | null>(null);
|
||||||
const messagesRef = useRef<ChatMessage[]>([]);
|
const messagesRef = useRef<ChatMessage[]>([]);
|
||||||
|
/** After first successful bootstrap, never flash the full-page chat skeleton again. */
|
||||||
|
const hasHydratedChatRef = useRef(false);
|
||||||
|
|
||||||
coachContextRef.current = coachContext;
|
coachContextRef.current = coachContext;
|
||||||
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
|
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
|
||||||
@@ -150,12 +155,13 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only queue — do NOT show transition loading while the closing message is still typing.
|
||||||
pendingCompletionRef.current = {
|
pendingCompletionRef.current = {
|
||||||
completion,
|
completion,
|
||||||
sessionId,
|
sessionId,
|
||||||
messageId: assistantMessageId,
|
messageId: assistantMessageId,
|
||||||
};
|
};
|
||||||
setIsSessionTransitionPending(true);
|
setIsSessionCompletionQueued(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -178,11 +184,14 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show overlay only after the closing message finished typing (and brief pause).
|
||||||
|
setIsSessionTransitionPending(true);
|
||||||
try {
|
try {
|
||||||
await emitCompletionIfValid(current.completion, current.sessionId);
|
await emitCompletionIfValid(current.completion, current.sessionId);
|
||||||
} finally {
|
} finally {
|
||||||
pendingCompletionRef.current = null;
|
pendingCompletionRef.current = null;
|
||||||
completionTimerRef.current = null;
|
completionTimerRef.current = null;
|
||||||
|
setIsSessionCompletionQueued(false);
|
||||||
// Defer unlock until the next session UI has painted to avoid layout jump / freeze.
|
// Defer unlock until the next session UI has painted to avoid layout jump / freeze.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
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]
|
[emitCompletionIfValid]
|
||||||
);
|
);
|
||||||
@@ -226,6 +237,8 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setChatSessionId(null);
|
setChatSessionId(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadedSessionOrderRef.current = null;
|
||||||
|
hasHydratedChatRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +250,12 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!coachContextRef.current?.order) {
|
if (!coachContextRef.current?.order) {
|
||||||
setMessages([]);
|
// Wait for session context on first entry only — never wipe an active chat.
|
||||||
setChatSessionId(null);
|
if (!hasHydratedChatRef.current) {
|
||||||
setLoading(false);
|
setMessages([]);
|
||||||
|
setChatSessionId(null);
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +266,13 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
loadedSessionOrderRef.current !== sessionOrder &&
|
loadedSessionOrderRef.current !== sessionOrder &&
|
||||||
messagesRef.current.length > 0;
|
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);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,13 +345,12 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
setMessages(timeline);
|
setMessages(timeline);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error bootstrapping chat session:', error);
|
console.error('Error bootstrapping chat session:', error);
|
||||||
if (!isSoftSessionSwitch) {
|
if (!isSoftSessionSwitch && messagesRef.current.length === 0) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!isSoftSessionSwitch) {
|
hasHydratedChatRef.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,6 +481,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||||||
messages,
|
messages,
|
||||||
loading,
|
loading,
|
||||||
isAiThinking,
|
isAiThinking,
|
||||||
|
isSessionCompletionQueued,
|
||||||
isSessionTransitionPending,
|
isSessionTransitionPending,
|
||||||
chatSessionId,
|
chatSessionId,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
syncProgramSessions,
|
syncProgramSessions,
|
||||||
type ProgramProgress,
|
type ProgramProgress,
|
||||||
} from '@/services/programService';
|
} from '@/services/programService';
|
||||||
|
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
||||||
|
|
||||||
export interface WaitingForNextSession {
|
export interface WaitingForNextSession {
|
||||||
sessionOrder: number;
|
sessionOrder: number;
|
||||||
@@ -101,7 +102,9 @@ export function useCurrentSession(userId: string | null | undefined): UseCurrent
|
|||||||
|
|
||||||
const isProgramComplete = Boolean(
|
const isProgramComplete = Boolean(
|
||||||
userId &&
|
userId &&
|
||||||
(programProgress?.completedAt !== null || isProgramCompleted(userId))
|
(Boolean(programProgress?.completedAt) ||
|
||||||
|
(programProgress?.completedSessions.length ?? 0) >= PROGRAM_SESSION_COUNT ||
|
||||||
|
isProgramCompleted(userId))
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 { api, hasSessionToken } from '@/services/backend';
|
||||||
import { getLatestByPhase } from '@/services/assessmentService';
|
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 =
|
export type LearningStep =
|
||||||
| 'registered'
|
| 'registered'
|
||||||
@@ -65,6 +70,8 @@ export function useLearningJourney(userId?: string) {
|
|||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
/** Skeleton / blocking load only on first fetch — silent on reloadProgress. */
|
||||||
|
const hasLoadedOnceRef = useRef(false);
|
||||||
|
|
||||||
const mapBackendJourney = useCallback((journey: {
|
const mapBackendJourney = useCallback((journey: {
|
||||||
currentStep: LearningStep;
|
currentStep: LearningStep;
|
||||||
@@ -118,12 +125,16 @@ export function useLearningJourney(userId?: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
const isInitialLoad = !hasLoadedOnceRef.current;
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (useLocalOnly) {
|
if (useLocalOnly) {
|
||||||
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
|
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
|
||||||
setProgress(local);
|
setProgress(local);
|
||||||
setBackendPermissions(null);
|
setBackendPermissions(null);
|
||||||
|
hasLoadedOnceRef.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -154,10 +165,15 @@ export function useLearningJourney(userId?: string) {
|
|||||||
setProgress(merged);
|
setProgress(merged);
|
||||||
setBackendPermissions(null);
|
setBackendPermissions(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
hasLoadedOnceRef.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
|
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasLoadedOnceRef.current = false;
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProgress();
|
loadProgress();
|
||||||
}, [loadProgress]);
|
}, [loadProgress]);
|
||||||
@@ -239,17 +255,24 @@ export function useLearningJourney(userId?: string) {
|
|||||||
}, [progress, saveProgress, userId]);
|
}, [progress, saveProgress, userId]);
|
||||||
|
|
||||||
const derivedPermissions = useMemo(() => {
|
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 {
|
return {
|
||||||
canTakePreTest: !progress?.pre_test_completed_at,
|
canTakePreTest: !progress?.pre_test_completed_at,
|
||||||
canOpenTraining: Boolean(progress?.pre_test_completed_at),
|
canOpenTraining: Boolean(progress?.pre_test_completed_at),
|
||||||
canTakePostTest: Boolean(
|
canTakePostTest: Boolean(
|
||||||
progress?.pre_test_completed_at &&
|
progress?.pre_test_completed_at &&
|
||||||
progress?.training_completed_at &&
|
reachedFinalSession &&
|
||||||
!progress?.post_test_completed_at
|
!progress?.post_test_completed_at
|
||||||
),
|
),
|
||||||
isCompleted: Boolean(progress?.post_test_completed_at)
|
isCompleted: Boolean(progress?.post_test_completed_at)
|
||||||
};
|
};
|
||||||
}, [progress]);
|
}, [progress, userId]);
|
||||||
|
|
||||||
const permissions = backendPermissions ?? derivedPermissions;
|
const permissions = backendPermissions ?? derivedPermissions;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const applyThemeClass = (theme: Theme) => {
|
|||||||
}
|
}
|
||||||
const themeColor = document.querySelector('meta[name="theme-color"]');
|
const themeColor = document.querySelector('meta[name="theme-color"]');
|
||||||
if (themeColor) {
|
if (themeColor) {
|
||||||
themeColor.setAttribute('content', theme === 'dark' ? '#0a0f1d' : '#e6eef6');
|
themeColor.setAttribute('content', theme === 'dark' ? '#0F172A' : '#F8FAFC');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,23 @@ const stripHtml = (html: string): string =>
|
|||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const mentionsPretest = (text: string): boolean =>
|
||||||
|
text.includes('پیشآزمون') || text.includes('پیش آزمون');
|
||||||
|
|
||||||
const isPretestExercise = (block: ContentBlock): boolean => {
|
const isPretestExercise = (block: ContentBlock): boolean => {
|
||||||
if (block.type !== 'exercise') return false;
|
if (block.type !== 'exercise') return false;
|
||||||
const haystack = `${block.title ?? ''} ${block.prompt ?? ''}`;
|
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 =>
|
const hasUserDirectedSpousePrompt = (text: string): boolean =>
|
||||||
text.includes('همسر خود') ||
|
text.includes('همسر خود') ||
|
||||||
text.includes('با همسر') ||
|
text.includes('با همسر') ||
|
||||||
@@ -35,18 +46,26 @@ const hasUserDirectedSpousePrompt = (text: string): boolean =>
|
|||||||
|
|
||||||
const formatBlock = (block: ContentBlock): string | null => {
|
const formatBlock = (block: ContentBlock): string | null => {
|
||||||
if (isPretestExercise(block)) {
|
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) {
|
switch (block.type) {
|
||||||
case 'callout':
|
case 'callout':
|
||||||
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
|
return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`;
|
||||||
case 'heading':
|
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}`;
|
return `[heading h${block.level ?? 3}] ${block.text}`;
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
|
const body = block.format === 'html' ? stripHtml(block.body) : block.body;
|
||||||
if (body.includes('پیشآزمون') || body.includes('پیش آزمون')) {
|
if (mentionsPretest(body)) {
|
||||||
return `[text — OMIT پیشآزمون when speaking; it is not part of the educational conversation] ${body}`;
|
const cleaned = stripPretestFromSpeech(body);
|
||||||
|
if (!cleaned || cleaned.length < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `[text] ${cleaned}`;
|
||||||
}
|
}
|
||||||
return `[text] ${body}`;
|
return `[text] ${body}`;
|
||||||
}
|
}
|
||||||
@@ -131,6 +150,8 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str
|
|||||||
if (b.type !== 'heading') return null;
|
if (b.type !== 'heading') return null;
|
||||||
const text = b.text.replace(/\s+/g, ' ').trim();
|
const text = b.text.replace(/\s+/g, ' ').trim();
|
||||||
if (titleNorm && text === titleNorm) return null;
|
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;
|
if (seen.has(text)) return null;
|
||||||
seen.add(text);
|
seen.add(text);
|
||||||
return `- ${text}`;
|
return `- ${text}`;
|
||||||
@@ -183,10 +204,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory):
|
|||||||
• that learning happens through conversation and interaction along a guided path
|
• 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.
|
- 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.
|
- 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.
|
- 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. When you reach it internally: skip it silently and continue Session 1 teaching.
|
- 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.
|
||||||
- If a Session 1 text block mentions that a pre-test is administered: OMIT that phrase when speaking. Teach orientation only.
|
- 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.
|
||||||
- If the user asks about پیشآزمون: do not explain it; say this chat is for the educational sessions, then continue the lesson.
|
- 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.
|
- 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.
|
- 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.
|
- 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):
|
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.
|
- 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:
|
- You must NEVER in chat:
|
||||||
• administer another psychological assessment or screening
|
• administer another psychological assessment or screening
|
||||||
• invent questionnaire items or substitute pre-test/post-test activities
|
• 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
12. Stay on this session until completion is validated; do not teach locked/future sessions.
|
||||||
|
|||||||
@@ -196,16 +196,79 @@ export function validateCoachSessionCompletionSignal(
|
|||||||
return { valid: true, signal: s as CoachSessionCompletionSignal };
|
return { valid: true, signal: s as CoachSessionCompletionSignal };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Final session: coach directed learner to in-app post-test without emitting the hidden block. */
|
/** Strip ZWNJ and collapse whitespace for Persian phrase matching. */
|
||||||
const looksLikeFinalPostTestHandoff = (text: string): boolean => {
|
const normalizePersian = (text: string): string =>
|
||||||
const normalized = text.replace(/\u200c/g, '').replace(/\s+/g, ' ');
|
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 mentionsPostTest = /پس[\s\-]?آزمون/.test(normalized);
|
||||||
const directsToApp =
|
const courseFinished =
|
||||||
/(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|انجام بده|بزن)/.test(normalized) ||
|
/(جلسه|جلسات|دوره|آموزش|برنامه).{0,50}(تمام|تموم|پایان|به پایان|تمام شده|تموم شده|به اتمام)/.test(
|
||||||
/(جلسه|دوره).{0,40}(تمام|تموم|پایان|تمام شده)/.test(normalized);
|
normalized
|
||||||
return mentionsPostTest && directsToApp;
|
) ||
|
||||||
|
/(تمام|تموم|پایان).{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 */
|
/** Parse assistant text, validate signal, log in DEV */
|
||||||
export function processCoachAssistantResponse(
|
export function processCoachAssistantResponse(
|
||||||
rawAssistantText: string,
|
rawAssistantText: string,
|
||||||
@@ -216,26 +279,59 @@ export function processCoachAssistantResponse(
|
|||||||
}
|
}
|
||||||
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
|
): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } {
|
||||||
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
|
const parsed = parseCoachCompletionFromAssistantText(rawAssistantText);
|
||||||
|
const alreadyCompleted = isSessionCompleted(options.userId, context.order);
|
||||||
|
const handoff =
|
||||||
|
context.isFinalSession && looksLikeFinalPostTestHandoff(parsed.displayContent);
|
||||||
|
|
||||||
let signal = parsed.signal;
|
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 (
|
if (
|
||||||
!signal &&
|
!signal &&
|
||||||
context.isFinalSession &&
|
context.isFinalSession &&
|
||||||
|
!alreadyCompleted &&
|
||||||
options.userTurnCount >= 2 &&
|
options.userTurnCount >= 2 &&
|
||||||
looksLikeFinalPostTestHandoff(parsed.displayContent)
|
handoff
|
||||||
) {
|
) {
|
||||||
signal = {
|
signal = normalizeFinalSessionSignal(
|
||||||
...exampleCompletionSignal(context),
|
{},
|
||||||
reason:
|
context,
|
||||||
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).',
|
'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).'
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return { ...parsed, validation: null };
|
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) {
|
if (import.meta.env.DEV) {
|
||||||
console.debug('[AI Coach] Session completion signal received:', {
|
console.debug('[AI Coach] Session completion signal received:', {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { supabaseHost } from '@/services/supabase';
|
import { supabaseHost } from '@/services/supabase';
|
||||||
|
|
||||||
export function getSupabaseNetworkErrorMessage(): string {
|
export function getSupabaseNetworkErrorMessage(): string {
|
||||||
return `سرور احراز هویت (${supabaseHost}) پاسخ نمیدهد. مشکل از اینترنت شما نیست — میتوانید «ادامه بدون ورود» را بزنید.`;
|
return `سرور احراز هویت (${supabaseHost}) پاسخ نمیدهد. لطفاً چند لحظه بعد دوباره تلاش کنید.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSupabaseNetworkError(message: string): boolean {
|
export function isSupabaseNetworkError(message: string): boolean {
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export function isBackendNetworkError(error: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONFLICT_ALREADY_EXISTS =
|
||||||
|
/already exists|account with this email|ایمیل.*وجود|قبلاً ثبت/i;
|
||||||
|
|
||||||
|
const INVALID_CREDENTIALS_MESSAGE =
|
||||||
|
'ایمیل یا رمز عبور اشتباه است. اگر حساب ندارید، ابتدا ثبتنام کنید.';
|
||||||
|
|
||||||
export function getBackendErrorMessage(
|
export function getBackendErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallback = 'خطا در ارتباط با سرور. لطفاً دوباره تلاش کنید.'
|
fallback = 'خطا در ارتباط با سرور. لطفاً دوباره تلاش کنید.'
|
||||||
@@ -20,6 +26,10 @@ export function getBackendErrorMessage(
|
|||||||
if (error.status === 0 || error.code === 'NETWORK_ERROR') {
|
if (error.status === 0 || error.code === 'NETWORK_ERROR') {
|
||||||
return 'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.';
|
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') {
|
if (error.status === 401 || error.code === 'UNAUTHORIZED') {
|
||||||
return 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.';
|
return 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.';
|
||||||
}
|
}
|
||||||
@@ -29,19 +39,19 @@ export function getBackendErrorMessage(
|
|||||||
}
|
}
|
||||||
return 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.';
|
return 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.';
|
||||||
}
|
}
|
||||||
if (error.code === 'INVALID_CREDENTIALS') {
|
|
||||||
return 'ایمیل یا رمز عبور اشتباه است. لطفاً دوباره تلاش کنید یا ثبتنام کنید.';
|
|
||||||
}
|
|
||||||
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
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 || 'این عملیات قبلاً انجام شده است.';
|
return error.message || 'این عملیات قبلاً انجام شده است.';
|
||||||
}
|
}
|
||||||
if (error.code === 'VALIDATION_ERROR') {
|
if (error.code === 'VALIDATION_ERROR' || error.status === 422) {
|
||||||
return error.message || 'اطلاعات واردشده نامعتبر است.';
|
return localizeValidationMessage(error.message) || 'اطلاعات واردشده نامعتبر است.';
|
||||||
}
|
}
|
||||||
return error.message || fallback;
|
return localizeKnownEnglishMessage(error.message) || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error && NETWORK_PATTERN.test(error.message)) {
|
if (error instanceof Error && NETWORK_PATTERN.test(error.message)) {
|
||||||
@@ -49,8 +59,58 @@ export function getBackendErrorMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message;
|
return localizeKnownEnglishMessage(error.message) || error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
|||||||
import { getProgramProgress } from '@/services/programService';
|
import { getProgramProgress } from '@/services/programService';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
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 { cn } from '@/lib/utils';
|
||||||
import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion';
|
import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { fastTransition } from '@/lib/motion';
|
import { fastTransition } from '@/lib/motion';
|
||||||
|
import { CourseStatusSkeleton } from '@/components/skeletons';
|
||||||
|
|
||||||
type StepState = 'open' | 'locked' | 'done';
|
type StepState = 'open' | 'locked' | 'done';
|
||||||
|
|
||||||
@@ -19,17 +20,14 @@ type StepState = 'open' | 'locked' | 'done';
|
|||||||
* Journey status only — no settings, scores, or assessment results.
|
* Journey status only — no settings, scores, or assessment results.
|
||||||
*/
|
*/
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const { effectiveUserId, isGuest, guestSessionHydrated } = useAuth();
|
const { effectiveUserId, isGuest, guestSessionHydrated, user } = useAuth();
|
||||||
const { progress, loading, permissions } = useLearningJourney(effectiveUserId ?? undefined);
|
const { progress, loading, permissions } = useLearningJourney(effectiveUserId ?? undefined);
|
||||||
const { currentSession, isProgramComplete, programProgress } = useCurrentSession(effectiveUserId);
|
const { currentSession, isProgramComplete, programProgress } = useCurrentSession(effectiveUserId);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const usernameLabel = isGuest ? 'مهمان' : user?.email?.trim() || null;
|
||||||
|
|
||||||
if (!guestSessionHydrated || loading) {
|
if (!guestSessionHydrated || loading) {
|
||||||
return (
|
return <CourseStatusSkeleton />;
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveUserId) {
|
if (!effectiveUserId) {
|
||||||
@@ -48,6 +46,10 @@ export default function AccountPage() {
|
|||||||
: Math.round((completedSessions / PROGRAM_SESSION_COUNT) * 100);
|
: Math.round((completedSessions / PROGRAM_SESSION_COUNT) * 100);
|
||||||
|
|
||||||
const preDone = Boolean(progress?.pre_test_completed_at);
|
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 trainingDone = Boolean(progress?.training_completed_at) || programDone;
|
||||||
const postDone = Boolean(progress?.post_test_completed_at);
|
const postDone = Boolean(progress?.post_test_completed_at);
|
||||||
|
|
||||||
@@ -63,16 +65,23 @@ export default function AccountPage() {
|
|||||||
: 'open';
|
: 'open';
|
||||||
const postState: StepState = postDone
|
const postState: StepState = postDone
|
||||||
? 'done'
|
? 'done'
|
||||||
: permissions.canTakePostTest || (trainingDone && preDone && !postDone)
|
: permissions.canTakePostTest || (reachedFinalSession && preDone && !postDone)
|
||||||
? 'open'
|
? 'open'
|
||||||
: 'locked';
|
: 'locked';
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mx-auto w-full max-w-md space-y-4">
|
||||||
<AnimationWrapper immediate className="space-y-1 pb-2">
|
<AnimationWrapper immediate className="space-y-1 pb-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">وضعیت دوره</h1>
|
<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>
|
</AnimationWrapper>
|
||||||
|
|
||||||
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
|
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
|
||||||
@@ -114,7 +123,7 @@ export default function AccountPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Progress value={progressPercent} className="h-2.5 flex-1" />
|
<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}٪
|
{progressPercent}٪
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,14 +140,14 @@ export default function AccountPage() {
|
|||||||
lockedHint={
|
lockedHint={
|
||||||
postDone
|
postDone
|
||||||
? 'انجام شده — قابل تکرار نیست'
|
? 'انجام شده — قابل تکرار نیست'
|
||||||
: 'پس از پایان دوره آموزشی فعال میشود'
|
: 'پس از رسیدن به جلسه پایانی فعال میشود'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{postState === 'done' && <StatusDone label="انجام شده" />}
|
{postState === 'done' && <StatusDone label="انجام شده" />}
|
||||||
{postState === 'open' && (
|
{postState === 'open' && (
|
||||||
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<span
|
<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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
آماده انجام
|
آماده انجام
|
||||||
@@ -147,7 +156,7 @@ export default function AccountPage() {
|
|||||||
{postState === 'locked' && (
|
{postState === 'locked' && (
|
||||||
<p className="flex items-start gap-2 text-sm text-body">
|
<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 />
|
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<span>پس از پایان دوره فعال میشود</span>
|
<span>پس از رسیدن به جلسه پایانی فعال میشود</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</StatusRow>
|
</StatusRow>
|
||||||
@@ -180,12 +189,12 @@ function StatusRow({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{isInteractive ? (
|
{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
|
<Lock
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-0.5 h-4 w-4 shrink-0',
|
'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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -201,7 +210,14 @@ function StatusRow({
|
|||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
transition={fastTransition}
|
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} — باز کردن`}
|
aria-label={`${title} — باز کردن`}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -211,7 +227,11 @@ function StatusRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
aria-disabled="true"
|
||||||
title={lockedHint}
|
title={lockedHint}
|
||||||
>
|
>
|
||||||
@@ -223,7 +243,7 @@ function StatusRow({
|
|||||||
function StatusDone({ label }: { label: string }) {
|
function StatusDone({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
<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}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendErro
|
|||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
|
import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AssessmentSkeleton } from '@/components/skeletons';
|
||||||
|
import { LoadingOverlay } from '@/components/ui/loading-overlay';
|
||||||
|
|
||||||
const FADE_MS = 220;
|
const FADE_MS = 220;
|
||||||
|
|
||||||
@@ -71,6 +73,10 @@ export default function AssessmentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <AssessmentSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
const advanceWithFade = (delta: number) => {
|
const advanceWithFade = (delta: number) => {
|
||||||
if (isAnimating || submitting || isRedirecting) return;
|
if (isAnimating || submitting || isRedirecting) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
@@ -229,6 +235,10 @@ export default function AssessmentPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full min-h-0 flex-1 overflow-hidden p-4">
|
<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 */}
|
{/* Intro overlay — dark veil on text area; bottom almost visible */}
|
||||||
{showIntro && (
|
{showIntro && (
|
||||||
<div
|
<div
|
||||||
@@ -314,10 +324,12 @@ export default function AssessmentPage() {
|
|||||||
disabled={isAnimating || submitting || isRedirecting || showIntro}
|
disabled={isAnimating || submitting || isRedirecting || showIntro}
|
||||||
onClick={() => onSelect(currentQuestion.id, option.value)}
|
onClick={() => onSelect(currentQuestion.id, option.value)}
|
||||||
className={cn(
|
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
|
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-[#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-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-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}
|
{option.label}
|
||||||
@@ -357,12 +369,6 @@ export default function AssessmentPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,19 +7,20 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
import { getLoginErrorMessage } from '@/lib/backendError';
|
||||||
import { Loader2, Mail, Lock, Moon, Sun, Sparkles } from 'lucide-react';
|
import { Loader2, Mail, Lock, Moon, Sun, Eye, EyeOff } from 'lucide-react';
|
||||||
import { AnimationWrapper } from '@/components/motion';
|
import { AnimationWrapper } from '@/components/motion';
|
||||||
import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants';
|
import { BrandLogo } from '@/components/BrandLogo';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { signIn, isAuthenticated, continueAsGuest } = useAuth();
|
const { signIn, isAuthenticated } = useAuth();
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,10 +40,37 @@ export default function LoginPage() {
|
|||||||
const nextEmail = String(form.get('email') ?? email).trim();
|
const nextEmail = String(form.get('email') ?? email).trim();
|
||||||
const nextPassword = String(form.get('password') ?? password);
|
const nextPassword = String(form.get('password') ?? password);
|
||||||
|
|
||||||
if (!nextEmail || !nextPassword) {
|
if (!nextEmail && !nextPassword) {
|
||||||
toast({
|
toast({
|
||||||
title: 'خطا',
|
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',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -58,8 +86,8 @@ export default function LoginPage() {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'خطا',
|
title: 'خطا در ورود',
|
||||||
description: getBackendErrorMessage(error, 'خطا در ورود. لطفاً دوباره تلاش کنید.'),
|
description: getLoginErrorMessage(error),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
|
<div className="app-auth-screen ambient-glow relative flex h-full min-h-0 items-center justify-center overflow-y-auto p-4">
|
||||||
<button
|
<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"
|
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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<CardHeader className="relative space-y-2">
|
<CardHeader className="relative space-y-3">
|
||||||
<div className="mb-2 flex justify-center">
|
<div className="mb-1 flex justify-center">
|
||||||
<div className="avatar-glow">
|
<BrandLogo imgClassName="h-12" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-center text-3xl font-bold tracking-tight text-foreground">{APP_NAME}</CardTitle>
|
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">ورود</CardTitle>
|
||||||
<CardDescription className="text-center text-body">
|
<CardDescription className="text-center text-body">وارد شوید</CardDescription>
|
||||||
ورود به {APP_NAME} — {APP_DESCRIPTION}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative space-y-4">
|
<CardContent className="relative space-y-4">
|
||||||
@@ -145,14 +150,24 @@ export default function LoginPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="pr-10"
|
className="px-10"
|
||||||
autoComplete="current-password"
|
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>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
|
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
|
||||||
@@ -167,16 +182,6 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="glass"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleContinueWithoutLogin}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
ادامه بدون ورود
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-body">
|
<div className="text-center text-sm text-body">
|
||||||
حساب کاربری ندارید؟{' '}
|
حساب کاربری ندارید؟{' '}
|
||||||
<Link to="/signup" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
<Link to="/signup" className="text-neon-cyan hover:text-neon-teal hover:underline">
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
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 { APP_NAME, APP_DESCRIPTION } from '@/utils/constants';
|
||||||
|
import { BrandLogo } from '@/components/BrandLogo';
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,6 +20,8 @@ export default function SignupPage() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleSignup = async (e: React.FormEvent) => {
|
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"
|
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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<CardHeader className="relative space-y-2">
|
<CardHeader className="relative space-y-3">
|
||||||
<div className="mb-2 flex justify-center">
|
<div className="mb-1 flex justify-center">
|
||||||
<div className="avatar-glow">
|
<BrandLogo imgClassName="h-12" />
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<CardDescription className="text-center text-body">
|
||||||
ایجاد حساب کاربری در {APP_NAME} — {APP_DESCRIPTION}
|
ایجاد حساب کاربری در {APP_NAME} — {APP_DESCRIPTION}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -113,27 +112,53 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-body">رمز عبور</Label>
|
<Label htmlFor="password" className="text-body">رمز عبور</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="password"
|
<Input
|
||||||
type="password"
|
id="password"
|
||||||
placeholder="••••••••"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
placeholder="••••••••"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
disabled={isLoading}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword" className="text-body">تکرار رمز عبور</Label>
|
<Label htmlFor="confirmPassword" className="text-body">تکرار رمز عبور</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="confirmPassword"
|
<Input
|
||||||
type="password"
|
id="confirmPassword"
|
||||||
placeholder="••••••••"
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
placeholder="••••••••"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
value={confirmPassword}
|
||||||
disabled={isLoading}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
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>
|
</div>
|
||||||
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
|
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -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 { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||||
@@ -10,10 +10,14 @@ import { useCurrentSession } from '@/hooks/useCurrentSession';
|
|||||||
import { completeSessionWithBackend, getProgramProgress } from '@/services/programService';
|
import { completeSessionWithBackend, getProgramProgress } from '@/services/programService';
|
||||||
import { formatRemainingUnlockTime } from '@/lib/sessionUnlock';
|
import { formatRemainingUnlockTime } from '@/lib/sessionUnlock';
|
||||||
import { PROGRAM_SESSION_COUNT, getSessionByOrder } from '@/data/program/sessions';
|
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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { getFetchErrorMessage } from '@/lib/fetchError';
|
import { getFetchErrorMessage } from '@/lib/fetchError';
|
||||||
import { APP_NAME } from '@/utils/constants';
|
import { APP_NAME } from '@/utils/constants';
|
||||||
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
|
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { LoadingOverlay } from '@/components/ui/loading-overlay';
|
||||||
|
import { ChatSkeleton, PageSkeleton } from '@/components/skeletons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat = Educational Environment.
|
* Chat = Educational Environment.
|
||||||
@@ -86,10 +90,14 @@ export default function ChatPage() {
|
|||||||
await refreshCurrentSession();
|
await refreshCurrentSession();
|
||||||
await reloadProgress();
|
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) {
|
if (isFinalSession) {
|
||||||
await markTrainingDone();
|
await markTrainingDone();
|
||||||
|
await reloadProgress();
|
||||||
toast({
|
toast({
|
||||||
title: 'دوره آموزشی تکمیل شد',
|
title: 'دوره آموزشی تکمیل شد',
|
||||||
description: 'پسآزمون برای شما فعال شد.',
|
description: 'پسآزمون برای شما فعال شد.',
|
||||||
@@ -136,21 +144,50 @@ export default function ChatPage() {
|
|||||||
) {
|
) {
|
||||||
return;
|
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,
|
effectiveUserId,
|
||||||
isProgramComplete,
|
isProgramComplete,
|
||||||
markTrainingDone,
|
markTrainingDone,
|
||||||
|
navigate,
|
||||||
progress?.post_test_completed_at,
|
progress?.post_test_completed_at,
|
||||||
progress?.pre_test_completed_at,
|
progress?.pre_test_completed_at,
|
||||||
progress?.training_completed_at,
|
progress?.training_completed_at,
|
||||||
progressLoading,
|
progressLoading,
|
||||||
|
reloadProgress,
|
||||||
|
toast,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
loading,
|
loading,
|
||||||
isAiThinking,
|
isAiThinking,
|
||||||
|
isSessionCompletionQueued,
|
||||||
isSessionTransitionPending,
|
isSessionTransitionPending,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
startCoachingSession,
|
startCoachingSession,
|
||||||
@@ -168,6 +205,72 @@ export default function ChatPage() {
|
|||||||
const currentSessionAnchorRef = useRef<HTMLDivElement>(null);
|
const currentSessionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const streamScrollRafRef = useRef<number | null>(null);
|
const streamScrollRafRef = useRef<number | null>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(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 resizeComposer = useCallback(() => {
|
||||||
const el = composerRef.current;
|
const el = composerRef.current;
|
||||||
@@ -182,57 +285,88 @@ export default function ChatPage() {
|
|||||||
el.style.height = `${next}px`;
|
el.style.height = `${next}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
|
||||||
const scroller = messagesScrollRef.current;
|
const scroller = messagesScrollRef.current;
|
||||||
if (!scroller) return;
|
const end = messagesEndRef.current;
|
||||||
scroller.scrollTop = scroller.scrollHeight;
|
if (end) {
|
||||||
|
end.scrollIntoView({ behavior, block: 'end' });
|
||||||
|
}
|
||||||
|
if (scroller) {
|
||||||
|
scroller.scrollTop = scroller.scrollHeight;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const isStreamOrThinking =
|
||||||
if (loading || isSessionTransitionPending) return;
|
isAiThinking || messages.some((msg) => msg.status === 'streaming');
|
||||||
// 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]);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading || !isStreamOrThinking) return;
|
||||||
if (!isAiThinking && messages.every((msg) => msg.status !== 'streaming')) {
|
|
||||||
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(() => {
|
const handleStreamProgress = useCallback(() => {
|
||||||
if (streamScrollRafRef.current !== null) return;
|
if (streamScrollRafRef.current !== null) return;
|
||||||
streamScrollRafRef.current = window.requestAnimationFrame(() => {
|
streamScrollRafRef.current = window.requestAnimationFrame(() => {
|
||||||
streamScrollRafRef.current = null;
|
streamScrollRafRef.current = null;
|
||||||
scrollToBottom();
|
scrollToBottom('auto');
|
||||||
});
|
});
|
||||||
}, [scrollToBottom]);
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
const handleStreamComplete = useCallback(
|
const handleStreamComplete = useCallback(
|
||||||
(messageId: string) => {
|
(messageId: string) => {
|
||||||
markAssistantStreamComplete(messageId);
|
markAssistantStreamComplete(messageId);
|
||||||
scrollToBottom();
|
scrollToBottom('auto');
|
||||||
},
|
},
|
||||||
[markAssistantStreamComplete, scrollToBottom]
|
[markAssistantStreamComplete, scrollToBottom]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!guestSessionHydrated) {
|
if (!guestSessionHydrated) {
|
||||||
return (
|
return <PageSkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveUserId) {
|
if (!effectiveUserId) {
|
||||||
@@ -257,7 +391,11 @@ export default function ChatPage() {
|
|||||||
const completedSessions =
|
const completedSessions =
|
||||||
programProgress?.completedSessions.length ??
|
programProgress?.completedSessions.length ??
|
||||||
getProgramProgress(effectiveUserId).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 =
|
const sessionOrder =
|
||||||
currentSession?.order ??
|
currentSession?.order ??
|
||||||
coachContext?.order ??
|
coachContext?.order ??
|
||||||
@@ -281,10 +419,12 @@ export default function ChatPage() {
|
|||||||
!isAiThinking &&
|
!isAiThinking &&
|
||||||
!hasCurrentSessionMessages &&
|
!hasCurrentSessionMessages &&
|
||||||
!isWaitingForUnlock &&
|
!isWaitingForUnlock &&
|
||||||
|
!isSessionCompletionQueued &&
|
||||||
!isSessionTransitionPending;
|
!isSessionTransitionPending;
|
||||||
|
|
||||||
const isStreamBusy =
|
const isStreamBusy =
|
||||||
isAiThinking ||
|
isAiThinking ||
|
||||||
|
isSessionCompletionQueued ||
|
||||||
isSessionTransitionPending ||
|
isSessionTransitionPending ||
|
||||||
messages.some((msg) => msg.status === 'streaming');
|
messages.some((msg) => msg.status === 'streaming');
|
||||||
|
|
||||||
@@ -294,11 +434,9 @@ export default function ChatPage() {
|
|||||||
? 'در حال بارگذاری...'
|
? 'در حال بارگذاری...'
|
||||||
: isWaitingForUnlock
|
: isWaitingForUnlock
|
||||||
? 'جلسه بعدی بهزودی باز میشود'
|
? 'جلسه بعدی بهزودی باز میشود'
|
||||||
: isSessionTransitionPending
|
: showStartPrompt
|
||||||
? 'در حال جمعبندی جلسه...'
|
? 'برای شروع جلسه بعد، دکمه بالا را بزن'
|
||||||
: showStartPrompt
|
: 'پاسخ خود را بنویسید...';
|
||||||
? 'برای شروع جلسه بعد، دکمه بالا را بزن'
|
|
||||||
: 'پاسخ خود را بنویسید...';
|
|
||||||
|
|
||||||
const handleSend = async (e?: FormEvent, initialMessage?: string, skipUserMessage = false) => {
|
const handleSend = async (e?: FormEvent, initialMessage?: string, skipUserMessage = false) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -380,6 +518,14 @@ export default function ChatPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<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">
|
<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">
|
<div className="relative z-10 shrink-0">
|
||||||
{isProgramComplete ? (
|
{isProgramComplete ? (
|
||||||
<p className="text-center text-xs text-muted-foreground">دوره آموزشی تکمیل شده</p>
|
<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="space-y-2">
|
||||||
<div className="flex items-center gap-2.5">
|
<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}
|
{sessionOrder}/{PROGRAM_SESSION_COUNT}
|
||||||
</span>
|
</span>
|
||||||
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{sessionTitle}</p>
|
<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>
|
||||||
<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"
|
role="progressbar"
|
||||||
aria-valuenow={progressPercent}
|
aria-valuenow={progressPercent}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
@@ -406,10 +555,23 @@ export default function ChatPage() {
|
|||||||
aria-label="پیشرفت دوره"
|
aria-label="پیشرفت دوره"
|
||||||
>
|
>
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,10 +580,8 @@ export default function ChatPage() {
|
|||||||
ref={messagesScrollRef}
|
ref={messagesScrollRef}
|
||||||
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading && messages.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center py-6">
|
<ChatSkeleton />
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
|
|
||||||
</div>
|
|
||||||
) : messages.length === 0 && !isAiThinking ? (
|
) : 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="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">
|
<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 ${
|
className={`shrink-0 text-[11px] font-medium ${
|
||||||
isPastSession
|
isPastSession
|
||||||
? 'text-muted-foreground/80'
|
? 'text-muted-foreground/80'
|
||||||
: 'text-neon-cyan'
|
: 'text-sky-700 dark:text-sky-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
جلسه {msgSessionOrder}
|
جلسه {msgSessionOrder}
|
||||||
@@ -561,7 +721,7 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<form onSubmit={handleSend} className="w-full">
|
||||||
<div className="composer-glass">
|
<div className="composer-glass">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -589,14 +749,14 @@ export default function ChatPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="neon"
|
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()}
|
disabled={composerDisabled || !message.trim()}
|
||||||
aria-label="ارسال"
|
aria-label="ارسال"
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
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() {
|
export default function FinishedPage() {
|
||||||
const { effectiveUserId, guestSessionHydrated } = useAuth();
|
const { effectiveUserId, guestSessionHydrated } = useAuth();
|
||||||
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
|
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
|
||||||
|
|
||||||
if (!guestSessionHydrated || loading) {
|
if (!guestSessionHydrated || loading) {
|
||||||
return (
|
return <FinishedSkeleton />;
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-neon-cyan" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveUserId) {
|
if (!effectiveUserId) {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { useToast } from '@/components/ui/use-toast';
|
|||||||
import { hasSessionToken } from '@/services/backend';
|
import { hasSessionToken } from '@/services/backend';
|
||||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
import { getBackendErrorMessage } from '@/lib/backendError';
|
||||||
import { AnimationWrapper } from '@/components/motion';
|
import { AnimationWrapper } from '@/components/motion';
|
||||||
import { Loader2, Sparkles } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { ROUTES } from '@/utils/constants';
|
import { ROUTES } from '@/utils/constants';
|
||||||
|
import { BrandLogo } from '@/components/BrandLogo';
|
||||||
|
|
||||||
const NAME_MAX_LENGTH = 100;
|
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"
|
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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<CardHeader className="relative space-y-2">
|
<CardHeader className="relative space-y-3">
|
||||||
<div className="mb-2 flex justify-center">
|
<div className="mb-1 flex justify-center">
|
||||||
<div className="avatar-glow">
|
<BrandLogo imgClassName="h-12" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">
|
<CardTitle className="text-center text-2xl font-bold tracking-tight text-foreground">
|
||||||
قبل از شروع، کمی با هم آشنا بشیم
|
قبل از شروع، کمی با هم آشنا بشیم
|
||||||
|
|||||||
@@ -176,12 +176,16 @@ export default function ProgramPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Program Complete Banner */}
|
{/* Post-test available from Session 11 onward */}
|
||||||
{programCompleted && (
|
{(programCompleted || permissions.canTakePostTest) && (
|
||||||
<Card className="mt-4 border-green-500/30 bg-green-500/5">
|
<Card className="mt-4 border-green-500/30 bg-green-500/5">
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600 mx-auto mb-2" />
|
<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 className="mt-1 text-xs text-muted-foreground">
|
||||||
اکنون میتوانید پسآزمون را انجام دهید.
|
اکنون میتوانید پسآزمون را انجام دهید.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -312,7 +312,10 @@ export function isSessionCompleted(userId: string, sessionOrder: number): boolea
|
|||||||
* Check if the entire program is completed.
|
* Check if the entire program is completed.
|
||||||
*/
|
*/
|
||||||
export function isProgramCompleted(userId: string): boolean {
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,41 +4,50 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Light is the product default */
|
/* Light is the product default — calm off-white wellness palette */
|
||||||
--background: 210 40% 98%;
|
--background: 210 40% 98%; /* #F8FAFC */
|
||||||
--foreground: 222 47% 11%;
|
--foreground: 222 47% 11%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222 47% 11%;
|
--card-foreground: 222 47% 11%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222 47% 11%;
|
--popover-foreground: 222 47% 11%;
|
||||||
--primary: 216 100% 50%;
|
--primary: 199 89% 48%; /* logo blue */
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96%;
|
--secondary: 210 40% 96%;
|
||||||
--secondary-foreground: 222 47% 11%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
--muted: 210 40% 96%;
|
--muted: 210 40% 96%;
|
||||||
--muted-foreground: 215 25% 28%;
|
--muted-foreground: 215 25% 32%;
|
||||||
--accent: 191 90% 42%;
|
--accent: 176 70% 42%; /* logo teal/green */
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 72% 45%;
|
--destructive: 0 72% 45%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214 32% 88%;
|
--border: 214 32% 90%;
|
||||||
--input: 214 32% 91%;
|
--input: 214 32% 91%;
|
||||||
--ring: 216 100% 50%;
|
--ring: 176 70% 42%;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
|
|
||||||
--neon-blue: #0066ff;
|
/* Brand from mainlogo.png — teal-green → azure blue */
|
||||||
--neon-cyan: #00d2ff;
|
--logo-teal: #26d4ca;
|
||||||
--neon-teal: #00ffcc;
|
--logo-blue: #00a8e8;
|
||||||
|
--neon-blue: #00a8e8;
|
||||||
|
--neon-cyan: #26d4ca;
|
||||||
|
--neon-teal: #26d4ca;
|
||||||
--neon-emerald: #10b981;
|
--neon-emerald: #10b981;
|
||||||
--navy-deep: #070a14;
|
--navy-deep: #0f172a;
|
||||||
--navy: #0a0f1d;
|
--navy: #1e293b;
|
||||||
--glass-bg: rgba(255, 255, 255, 0.82);
|
--glass-bg: rgba(255, 255, 255, 0.88);
|
||||||
--glass-bg-strong: rgba(255, 255, 255, 0.92);
|
--glass-bg-strong: rgba(255, 255, 255, 0.94);
|
||||||
--body-text: #1e3a4c;
|
--body-text: #334155;
|
||||||
--glow-blue: 0 0 12px rgba(47, 127, 212, 0.12);
|
--glow-blue: 0 0 16px rgba(0, 168, 232, 0.35);
|
||||||
--glow-teal: 0 0 12px rgba(52, 184, 150, 0.12);
|
--glow-teal: 0 0 16px rgba(38, 212, 202, 0.4);
|
||||||
--glow-border: 0 0 8px rgba(47, 127, 212, 0.1);
|
--glow-border: 0 0 12px rgba(38, 212, 202, 0.25);
|
||||||
--gradient-neon: linear-gradient(135deg, #2f7fd4 0%, #2bb8c9 50%, #34b896 100%);
|
--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 {
|
.light {
|
||||||
@@ -48,76 +57,96 @@
|
|||||||
--card-foreground: 222 47% 11%;
|
--card-foreground: 222 47% 11%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222 47% 11%;
|
--popover-foreground: 222 47% 11%;
|
||||||
--primary: 216 100% 50%;
|
--primary: 199 89% 48%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96%;
|
--secondary: 210 40% 96%;
|
||||||
--secondary-foreground: 222 47% 11%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
--muted: 210 40% 96%;
|
--muted: 210 40% 96%;
|
||||||
--muted-foreground: 215 25% 28%;
|
--muted-foreground: 215 25% 32%;
|
||||||
--accent: 191 90% 42%;
|
--accent: 176 70% 42%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 72% 45%;
|
--destructive: 0 72% 45%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214 32% 88%;
|
--border: 214 32% 90%;
|
||||||
--input: 214 32% 91%;
|
--input: 214 32% 91%;
|
||||||
--ring: 216 100% 50%;
|
--ring: 176 70% 42%;
|
||||||
--glass-bg: rgba(255, 255, 255, 0.82);
|
--logo-teal: #26d4ca;
|
||||||
--glass-bg-strong: rgba(255, 255, 255, 0.92);
|
--logo-blue: #00a8e8;
|
||||||
--body-text: #1e3a4c;
|
--neon-blue: #00a8e8;
|
||||||
--glow-blue: 0 0 12px rgba(47, 127, 212, 0.12);
|
--neon-cyan: #0d9488;
|
||||||
--glow-teal: 0 0 12px rgba(52, 184, 150, 0.12);
|
--neon-teal: #0f766e;
|
||||||
--glow-border: 0 0 8px rgba(47, 127, 212, 0.1);
|
--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 */
|
/* Keep .dark as alias of product dark tokens */
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222 48% 6%;
|
--background: 222 47% 11%; /* #0F172A */
|
||||||
--foreground: 0 0% 100%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222 47% 11%;
|
--card: 217 33% 17%;
|
||||||
--card-foreground: 0 0% 100%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222 47% 9%;
|
--popover: 217 33% 14%;
|
||||||
--popover-foreground: 0 0% 100%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 216 100% 50%;
|
--primary: 193 100% 45%; /* logo blue #00A8E8 */
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 217 33% 14%;
|
--secondary: 217 33% 17%;
|
||||||
--secondary-foreground: 210 40% 96%;
|
--secondary-foreground: 210 40% 96%;
|
||||||
--muted: 217 33% 12%;
|
--muted: 217 33% 17%;
|
||||||
--muted-foreground: 210 25% 82%;
|
--muted-foreground: 215 20% 72%;
|
||||||
--accent: 191 100% 50%;
|
--accent: 176 70% 49%; /* logo teal #26D4CA */
|
||||||
--accent-foreground: 222 48% 6%;
|
--accent-foreground: 222 47% 11%;
|
||||||
--destructive: 0 72% 42%;
|
--destructive: 0 72% 42%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 210 40% 40%;
|
--border: 215 25% 27%;
|
||||||
--input: 217 33% 16%;
|
--input: 217 33% 16%;
|
||||||
--ring: 191 100% 50%;
|
--ring: 176 70% 49%;
|
||||||
--glass-bg: rgba(12, 20, 36, 0.72);
|
--logo-teal: #26d4ca;
|
||||||
--glass-bg-strong: rgba(14, 24, 42, 0.86);
|
--logo-blue: #00a8e8;
|
||||||
--body-text: #e8eef6;
|
--neon-blue: #00a8e8;
|
||||||
--glow-blue: 0 0 14px rgba(56, 140, 220, 0.22), 0 0 28px rgba(0, 180, 200, 0.1);
|
--neon-cyan: #26d4ca;
|
||||||
--glow-teal: 0 0 14px rgba(45, 180, 160, 0.2), 0 0 28px rgba(52, 168, 130, 0.1);
|
--neon-teal: #26d4ca;
|
||||||
--glow-border: 0 0 10px rgba(72, 180, 200, 0.18), 0 0 22px rgba(60, 170, 150, 0.1);
|
--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-face {
|
||||||
font-family: 'Shabnam';
|
font-family: 'IRANSansX';
|
||||||
src: url('/fonts/shabnam/Shabnam-Light.ttf') format('truetype');
|
src: url('/fonts/iransansx/IRANSansX-Regular.woff2') format('woff2');
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Shabnam';
|
|
||||||
src: url('/fonts/shabnam/Shabnam.ttf') format('truetype');
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Shabnam';
|
font-family: 'IRANSansX';
|
||||||
src: url('/fonts/shabnam/Shabnam-Bold.ttf') format('truetype');
|
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-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -165,13 +194,29 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply text-foreground antialiased;
|
@apply text-foreground antialiased;
|
||||||
font-family: 'Vazirmatn', 'Shabnam', 'Tahoma', system-ui, sans-serif;
|
font-family: 'IRANSansX', 'Tahoma', system-ui, sans-serif;
|
||||||
background-color: #e6eef6;
|
background-color: #f1f5f9;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
transition: var(--theme-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark body {
|
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,
|
h1,
|
||||||
@@ -191,16 +236,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Desktop stage: soft wellness ambience (muted blue + soft teal) */
|
/* Desktop stage: soft wellness ambience */
|
||||||
.app-desktop-stage {
|
.app-desktop-stage {
|
||||||
@apply relative flex w-full flex-1 justify-center overflow-hidden;
|
@apply relative flex w-full flex-1 justify-center overflow-hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background-color: #070b14;
|
background-color: #0f172a;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 50% 40% at 16% 20%, rgba(56, 130, 200, 0.14), transparent 72%),
|
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(52, 168, 140, 0.12), transparent 70%),
|
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(64, 170, 180, 0.08), transparent 72%),
|
radial-gradient(ellipse 40% 30% at 72% 82%, rgba(51, 65, 85, 0.35), transparent 72%);
|
||||||
radial-gradient(ellipse 55% 40% at 50% 108%, rgba(40, 90, 140, 0.08), transparent 65%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-desktop-stage::before,
|
.app-desktop-stage::before,
|
||||||
@@ -211,7 +255,7 @@
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
filter: blur(90px);
|
filter: blur(90px);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.55;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-desktop-stage::before {
|
.app-desktop-stage::before {
|
||||||
@@ -219,7 +263,7 @@
|
|||||||
height: min(40vw, 320px);
|
height: min(40vw, 320px);
|
||||||
top: -6%;
|
top: -6%;
|
||||||
left: 10%;
|
left: 10%;
|
||||||
background: rgba(56, 130, 200, 0.22);
|
background: rgba(14, 165, 233, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-desktop-stage::after {
|
.app-desktop-stage::after {
|
||||||
@@ -227,22 +271,19 @@
|
|||||||
height: min(36vw, 280px);
|
height: min(36vw, 280px);
|
||||||
bottom: -4%;
|
bottom: -4%;
|
||||||
right: 8%;
|
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 {
|
.app-mobile-shell {
|
||||||
@apply relative z-10 flex w-full max-w-[430px] flex-1 flex-col overflow-hidden;
|
@apply relative z-10 flex w-full max-w-[430px] flex-1 flex-col overflow-hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
background: rgba(9, 14, 26, 0.62);
|
background: #0f172a;
|
||||||
backdrop-filter: blur(32px) saturate(1.15);
|
border: 1px solid rgb(51 65 85 / 0.55);
|
||||||
-webkit-backdrop-filter: blur(32px) saturate(1.15);
|
|
||||||
border: 1px solid rgba(90, 190, 210, 0.28);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
0 1px 2px rgb(0 0 0 / 0.2),
|
||||||
0 0 0 1px rgba(70, 170, 160, 0.08),
|
0 20px 50px rgb(0 0 0 / 0.35);
|
||||||
0 20px 60px rgba(0, 0, 0, 0.4);
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
@@ -258,49 +299,45 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: rgba(10, 15, 29, 0.55);
|
background: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .app-mobile-shell {
|
html.light .app-mobile-shell {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .app-desktop-stage {
|
html.light .app-desktop-stage {
|
||||||
background-color: #e6eef6;
|
background-color: #e2e8f0;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 55% 45% at 14% 18%, rgba(56, 160, 230, 0.22), transparent 72%),
|
radial-gradient(ellipse 50% 40% at 16% 20%, rgba(14, 165, 233, 0.1), transparent 72%),
|
||||||
radial-gradient(ellipse 50% 40% at 86% 14%, rgba(52, 184, 150, 0.18), transparent 70%),
|
radial-gradient(ellipse 45% 36% at 84% 16%, rgba(20, 184, 166, 0.08), transparent 70%),
|
||||||
radial-gradient(ellipse 45% 35% at 70% 84%, rgba(64, 180, 210, 0.14), transparent 72%);
|
radial-gradient(ellipse 55% 40% at 50% 108%, rgba(148, 163, 184, 0.2), transparent 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .app-desktop-stage::before {
|
html.light .app-desktop-stage::before {
|
||||||
background: rgba(56, 160, 230, 0.28);
|
background: rgba(14, 165, 233, 0.16);
|
||||||
opacity: 0.7;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .app-desktop-stage::after {
|
html.light .app-desktop-stage::after {
|
||||||
background: rgba(52, 184, 150, 0.24);
|
background: rgba(20, 184, 166, 0.14);
|
||||||
opacity: 0.65;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .app-mobile-shell {
|
html.light .app-mobile-shell {
|
||||||
background: rgba(255, 255, 255, 0.62);
|
background: #f8fafc;
|
||||||
border-color: rgba(56, 160, 200, 0.28);
|
border-color: rgb(226 232 240 / 0.95);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
0 1px 2px rgb(15 23 42 / 0.04),
|
||||||
0 0 0 1px rgba(56, 160, 200, 0.08),
|
0 20px 40px rgb(15 23 42 / 0.08);
|
||||||
0 20px 50px rgba(47, 127, 212, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
@apply relative rounded-2xl backdrop-blur-xl;
|
@apply relative rounded-2xl backdrop-blur-xl;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid rgba(90, 200, 210, 0.38);
|
border: 1px solid rgb(226 232 240 / 0.9);
|
||||||
box-shadow:
|
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||||
0 0 0 1px rgba(70, 170, 160, 0.12),
|
|
||||||
var(--glow-border),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat messages surface — high-intensity glass, no extra border */
|
/* Chat messages surface — high-intensity glass, no extra border */
|
||||||
@@ -343,16 +380,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Chat composer */
|
/* Chat composer */
|
||||||
|
/* Chat composer — single flat background (no nested input fill) */
|
||||||
.composer-glass {
|
.composer-glass {
|
||||||
@apply relative flex min-h-12 w-full items-end gap-2 rounded-2xl p-1.5;
|
@apply relative flex min-h-12 w-full items-end gap-2 rounded-2xl p-1.5;
|
||||||
background: rgba(255, 255, 255, 0.28);
|
background: #ffffff;
|
||||||
backdrop-filter: blur(28px) saturate(1.5);
|
border: 1px solid rgb(226 232 240);
|
||||||
-webkit-backdrop-filter: blur(28px) saturate(1.5);
|
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-glass textarea.input-neon {
|
.composer-glass textarea.input-neon {
|
||||||
@@ -364,69 +397,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-glass:focus-within {
|
.composer-glass:focus-within {
|
||||||
|
border-color: rgb(38 212 202 / 0.55);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 12px 32px rgba(47, 127, 212, 0.22),
|
0 0 0 1px rgb(38 212 202 / 0.3),
|
||||||
0 0 0 1px rgba(56, 160, 230, 0.22),
|
0 3px 8px rgb(15 23 42 / 0.08);
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .composer-glass,
|
html.dark .composer-glass,
|
||||||
html.dark .composer-glass:focus-within {
|
html.dark .composer-glass:focus-within {
|
||||||
background: rgba(12, 20, 36, 0.45);
|
background: #1e293b;
|
||||||
border-color: rgb(59 158 255 / 0.2);
|
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,
|
||||||
html.light .composer-glass:focus-within {
|
html.light .composer-glass:focus-within {
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: #ffffff;
|
||||||
border-color: rgb(47 127 212 / 0.2);
|
border-color: rgb(203 213 225 / 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-glass .input-neon {
|
html.light .composer-glass:focus-within {
|
||||||
background: transparent;
|
border-color: #26d4ca;
|
||||||
border: none;
|
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));
|
color: hsl(var(--foreground));
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-glass .input-neon::placeholder {
|
.composer-glass .input-neon::placeholder,
|
||||||
color: hsl(var(--muted-foreground));
|
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 {
|
.composer-glass .input-neon:focus-visible {
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
}
|
|
||||||
|
|
||||||
html.light .composer-glass .input-neon::placeholder {
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel-strong {
|
.glass-panel-strong {
|
||||||
@apply relative rounded-2xl backdrop-blur-2xl;
|
@apply relative rounded-2xl backdrop-blur-md;
|
||||||
background: var(--glass-bg-strong);
|
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:
|
box-shadow:
|
||||||
0 0 0 1px rgba(70, 170, 160, 0.14),
|
0 8px 24px rgb(15 23 42 / 0.08),
|
||||||
var(--glow-border),
|
0 1px 2px rgb(15 23 42 / 0.04);
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neon-border {
|
.neon-border {
|
||||||
border: 1px solid transparent;
|
border: 1px solid rgb(226 232 240);
|
||||||
background:
|
background: var(--glass-bg);
|
||||||
linear-gradient(var(--glass-bg), var(--glass-bg)) padding-box,
|
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .glass-panel,
|
html.light .glass-panel,
|
||||||
html.light .glass-panel-strong {
|
html.light .glass-panel-strong {
|
||||||
border-color: rgba(47, 127, 212, 0.28);
|
border-color: rgb(226 232 240 / 0.95);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(52, 184, 150, 0.08),
|
0 1px 2px rgb(15 23 42 / 0.04),
|
||||||
var(--glow-border),
|
0 4px 12px rgb(15 23 42 / 0.03);
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
}
|
||||||
|
|
||||||
|
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 {
|
.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;
|
@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: var(--gradient-neon);
|
||||||
background-size: 160% 160%;
|
background-size: 160% 160%;
|
||||||
box-shadow:
|
border: 1px solid transparent;
|
||||||
0 0 18px rgba(0, 102, 255, 0.45),
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.08), 0 2px 8px rgb(38 212 202 / 0.2);
|
||||||
0 0 36px rgba(0, 255, 204, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-neon:hover:not(:disabled) {
|
.btn-neon:hover:not(:disabled) {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.05);
|
||||||
box-shadow:
|
border-color: rgb(38 212 202 / 0.55);
|
||||||
0 0 24px rgba(0, 102, 255, 0.55),
|
box-shadow: 0 3px 8px rgb(15 23 42 / 0.1), 0 4px 12px rgb(38 212 202 / 0.28);
|
||||||
0 0 48px rgba(0, 255, 204, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .btn-neon {
|
html.light .btn-neon {
|
||||||
box-shadow:
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.06), 0 2px 8px rgb(38 212 202 / 0.16);
|
||||||
0 0 14px rgba(47, 127, 212, 0.28),
|
|
||||||
0 0 28px rgba(52, 184, 150, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .btn-neon:hover:not(:disabled) {
|
html.light .btn-neon:hover:not(:disabled) {
|
||||||
filter: brightness(1.04);
|
filter: brightness(1.04);
|
||||||
box-shadow:
|
border-color: rgb(38 212 202 / 0.5);
|
||||||
0 0 18px rgba(47, 127, 212, 0.36),
|
box-shadow: 0 3px 8px rgb(15 23 42 / 0.08), 0 4px 12px rgb(38 212 202 / 0.22);
|
||||||
0 0 36px rgba(52, 184, 150, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.btn-neon:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #26d4ca;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px var(--navy),
|
0 0 0 1px rgb(38 212 202 / 0.35),
|
||||||
0 0 0 4px var(--neon-cyan),
|
0 3px 8px rgb(15 23 42 / 0.1),
|
||||||
0 0 24px rgba(0, 210, 255, 0.5);
|
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 {
|
.btn-glass {
|
||||||
@apply inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-300;
|
@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);
|
background: rgb(241 245 249 / 0.9);
|
||||||
border: 1px solid rgba(0, 210, 255, 0.35);
|
border: 1px solid rgb(203 213 225 / 0.95);
|
||||||
color: var(--body-text);
|
color: var(--body-text);
|
||||||
backdrop-filter: blur(12px);
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-glass:hover:not(:disabled) {
|
.btn-glass:hover:not(:disabled) {
|
||||||
border-color: rgba(0, 255, 204, 0.55);
|
border-color: rgb(38 212 202 / 0.5);
|
||||||
box-shadow: var(--glow-teal);
|
background: rgb(248 250 252);
|
||||||
color: hsl(var(--foreground));
|
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 {
|
html.light .btn-glass {
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border-color: rgba(47, 127, 212, 0.28);
|
border-color: rgb(203 213 225 / 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-glass:focus,
|
||||||
.btn-glass:focus-visible {
|
.btn-glass:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #26d4ca;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px var(--navy),
|
0 0 0 1px rgb(38 212 202 / 0.3),
|
||||||
0 0 0 4px var(--neon-cyan);
|
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 {
|
.btn-danger-neon {
|
||||||
@@ -505,54 +618,95 @@
|
|||||||
background: rgba(127, 29, 29, 0.35);
|
background: rgba(127, 29, 29, 0.35);
|
||||||
border: 1px solid rgba(248, 113, 113, 0.45);
|
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||||
color: #fecaca;
|
color: #fecaca;
|
||||||
backdrop-filter: blur(12px);
|
box-shadow: 0 2px 4px rgb(0 0 0 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .btn-danger-neon {
|
html.light .btn-danger-neon {
|
||||||
background: rgba(254, 226, 226, 0.9);
|
background: rgba(254, 226, 226, 0.9);
|
||||||
border-color: rgba(220, 38, 38, 0.35);
|
border-color: rgba(220, 38, 38, 0.35);
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger-neon:hover:not(:disabled) {
|
.btn-danger-neon:hover:not(:disabled) {
|
||||||
border-color: rgba(248, 113, 113, 0.75);
|
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;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .btn-danger-neon:hover:not(:disabled) {
|
html.light .btn-danger-neon:hover:not(:disabled) {
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
background: rgba(254, 202, 202, 0.95);
|
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 {
|
.input-neon {
|
||||||
@apply flex h-11 w-full items-center rounded-xl px-3 py-0 text-sm leading-normal transition-all duration-300;
|
@apply flex h-11 w-full items-center rounded-xl px-3 py-0 text-sm leading-relaxed transition-all duration-300;
|
||||||
background: rgba(15, 23, 42, 0.55);
|
background: rgb(248 250 252);
|
||||||
border: 1px solid rgba(0, 210, 255, 0.28);
|
border: 1px solid rgb(203 213 225 / 0.9);
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
backdrop-filter: blur(10px);
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-neon::placeholder {
|
.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 {
|
html.light .input-neon {
|
||||||
background: rgba(255, 255, 255, 0.78);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border-color: rgba(47, 127, 212, 0.28);
|
border-color: rgb(203 213 225 / 0.95);
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
|
box-shadow: 0 2px 4px rgb(15 23 42 / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .input-neon::placeholder {
|
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 {
|
.input-neon:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--neon-cyan);
|
border-width: 1px;
|
||||||
|
border-color: #26d4ca;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px var(--neon-cyan),
|
0 0 0 1px rgb(38 212 202 / 0.35),
|
||||||
0 0 20px rgba(0, 210, 255, 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 {
|
.input-neon:disabled {
|
||||||
@@ -561,20 +715,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-neon {
|
.progress-neon {
|
||||||
@apply relative h-2 w-full overflow-hidden rounded-full;
|
@apply relative h-2.5 w-full overflow-hidden rounded-full;
|
||||||
background: rgba(15, 23, 42, 0.7);
|
background: rgb(226 232 240 / 0.85);
|
||||||
border: 1px solid rgba(0, 210, 255, 0.2);
|
border: 1px solid rgb(203 213 225 / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .progress-neon {
|
html.light .progress-neon {
|
||||||
background: rgba(15, 23, 42, 0.08);
|
background: rgb(241 245 249);
|
||||||
border-color: rgba(47, 127, 212, 0.2);
|
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 {
|
.progress-neon-fill {
|
||||||
@apply h-full rounded-full transition-all duration-300 ease-out;
|
@apply h-full rounded-full transition-all duration-500 ease-out;
|
||||||
background: var(--gradient-neon);
|
background: linear-gradient(90deg, #26d4ca 0%, #00a8e8 100%);
|
||||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.55);
|
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 {
|
.ambient-glow {
|
||||||
@@ -661,13 +825,13 @@
|
|||||||
box-shadow: 0 8px 24px rgba(47, 127, 212, 0.12);
|
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 {
|
html.light .text-neon-cyan {
|
||||||
color: #0e7490;
|
color: #0f766e;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .text-neon-teal {
|
html.light .text-neon-teal {
|
||||||
color: #0f766e;
|
color: #0d9488;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light .text-neon-emerald {
|
html.light .text-neon-emerald {
|
||||||
@@ -723,19 +887,23 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* App chrome — only rendered when authenticated (not on login/signup). */
|
/* App chrome — sticky top bar */
|
||||||
.app-header {
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
background: rgba(255, 255, 255, 0.28);
|
background: rgb(248 250 252 / 0.85);
|
||||||
backdrop-filter: blur(24px) saturate(1.4);
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
box-shadow: 0 8px 24px rgba(47, 127, 212, 0.08), 0 6px 16px rgba(52, 184, 150, 0.06);
|
border-bottom: 1px solid rgb(226 232 240 / 0.8);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .app-header {
|
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 */
|
/* Scrollable route area below header */
|
||||||
@@ -748,6 +916,64 @@
|
|||||||
padding-top: env(safe-area-inset-top, 0px);
|
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 {
|
.ai-thinking-bubble {
|
||||||
@apply rounded-2xl;
|
@apply rounded-2xl;
|
||||||
background: rgba(255, 255, 255, 0.16);
|
background: rgba(255, 255, 255, 0.16);
|
||||||
@@ -769,18 +995,23 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #00d2ff;
|
background: #26d4ca;
|
||||||
box-shadow: 0 0 8px rgba(0, 210, 255, 0.75), 0 0 14px rgba(0, 255, 204, 0.45);
|
box-shadow: 0 0 8px rgba(38, 212, 202, 0.55);
|
||||||
animation: ai-dot-bounce 1.15s ease-in-out infinite;
|
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) {
|
.ai-thinking-dot:nth-child(2) {
|
||||||
animation-delay: 0.16s;
|
animation-delay: 0.16s;
|
||||||
background: #00ffcc;
|
background: #00a8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-thinking-dot:nth-child(3) {
|
.ai-thinking-dot:nth-child(3) {
|
||||||
animation-delay: 0.32s;
|
animation-delay: 0.32s;
|
||||||
|
background: #26d4ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-stream-cursor {
|
.ai-stream-cursor {
|
||||||
@@ -790,13 +1021,17 @@
|
|||||||
margin-inline-start: 3px;
|
margin-inline-start: 3px;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
background: linear-gradient(180deg, #00d2ff 0%, #00ffcc 100%);
|
background: linear-gradient(180deg, #26d4ca 0%, #00a8e8 100%);
|
||||||
box-shadow:
|
box-shadow: none;
|
||||||
0 0 8px rgba(0, 210, 255, 0.85),
|
|
||||||
0 0 16px rgba(0, 255, 204, 0.55);
|
|
||||||
animation: ai-cursor-blink 0.95s step-end infinite;
|
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 {
|
@keyframes ai-dot-bounce {
|
||||||
0%,
|
0%,
|
||||||
80%,
|
80%,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Vazirmatn', 'Shabnam', 'Tahoma', ...defaultTheme.fontFamily.sans],
|
sans: ['IRANSansX', 'Tahoma', ...defaultTheme.fontFamily.sans],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
@@ -48,9 +48,9 @@ export default {
|
|||||||
deep: '#070a14',
|
deep: '#070a14',
|
||||||
},
|
},
|
||||||
neon: {
|
neon: {
|
||||||
blue: '#0066ff',
|
blue: '#00a8e8',
|
||||||
cyan: '#00d2ff',
|
cyan: '#26d4ca',
|
||||||
teal: '#00ffcc',
|
teal: '#26d4ca',
|
||||||
emerald: '#10b981',
|
emerald: '#10b981',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -60,12 +60,12 @@ export default {
|
|||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
glow: '0 0 20px rgba(0, 102, 255, 0.35), 0 0 40px rgba(0, 210, 255, 0.15)',
|
glow: '0 0 18px rgba(0, 168, 232, 0.4), 0 0 36px rgba(38, 212, 202, 0.2)',
|
||||||
'glow-teal': '0 0 20px rgba(0, 255, 204, 0.3), 0 0 40px rgba(16, 185, 129, 0.15)',
|
'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(0, 210, 255, 0.25)',
|
'glow-sm': '0 0 12px rgba(38, 212, 202, 0.4)',
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'neon-gradient': 'linear-gradient(135deg, #0066ff 0%, #00d2ff 50%, #00ffcc 100%)',
|
'neon-gradient': 'linear-gradient(135deg, #26d4ca 0%, #14c4db 48%, #00a8e8 100%)',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'pulse-glow': {
|
'pulse-glow': {
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
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: {
|
manifest: {
|
||||||
name: 'نویا',
|
name: 'نویا',
|
||||||
short_name: 'نویا',
|
short_name: 'نویا',
|
||||||
|
|||||||