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