Colors keep their role: headers/banners darken to readable contrast (same hue), lessons uses the teal token, packages use indigo; +XP pop, streak milestone card, lazy confetti
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|||||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { shade } from '@/lib/color';
|
import { onWhite, shade } from '@/lib/color';
|
||||||
|
|
||||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -26,10 +26,11 @@ export function ScreenHeader({
|
|||||||
}) {
|
}) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const onBack = typeof back === 'function' ? back : nav.back;
|
const onBack = typeof back === 'function' ? back : nav.back;
|
||||||
|
const bg = onWhite(color);
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||||||
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
|
style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -0.06)} 100%)` }}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||||
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
||||||
|
|||||||
@@ -10,3 +10,22 @@ export function shade(hex: string, amount: number): string {
|
|||||||
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
||||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return 1;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
||||||
|
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان رنگ، فقط تا جایی تیره میشود که متنِ سفید رویش کنتراستِ دستکم `min` بگیرد
|
||||||
|
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار میماند و فقط پسزمینهی متنِ سفید از این رد میشود،
|
||||||
|
* پس هیچ رنگِ تازهای وارد اپ نمیشود، فقط سایهی تیرهترِ همان.
|
||||||
|
*/
|
||||||
|
export function onWhite(hex: string, min = 3.2): string {
|
||||||
|
let c = hex;
|
||||||
|
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|||||||
import { IslandPath } from '@/components/IslandPath';
|
import { IslandPath } from '@/components/IslandPath';
|
||||||
import { IslandSheet } from '@/components/IslandSheet';
|
import { IslandSheet } from '@/components/IslandSheet';
|
||||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||||
import { shade } from '@/lib/color';
|
import { onWhite, shade } from '@/lib/color';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export function HomeScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||||
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
||||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[11px] font-bold text-white/80">
|
<div className="text-[11px] font-bold text-white/80">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Brain, Check, ChevronDown, Lock, Play, Sparkles } from 'lucide-react';
|
|||||||
import { api, type HomeNode, type HomeUnit } from '@/api';
|
import { api, type HomeNode, type HomeUnit } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
|
import { onWhite } from '@/lib/color';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||||
@@ -36,7 +37,7 @@ export function LessonsScreen() {
|
|||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="درسها"
|
title="درسها"
|
||||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||||
color="#2FB36B"
|
color="#97C0C0"
|
||||||
mascot={{ char: 'sigma', mood: 'happy' }}
|
mascot={{ char: 'sigma', mood: 'happy' }}
|
||||||
>
|
>
|
||||||
{home && totalNodes > 0 && (
|
{home && totalNodes > 0 && (
|
||||||
@@ -62,7 +63,7 @@ export function LessonsScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
|
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
|
||||||
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
|
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
|
||||||
style={{ backgroundColor: unitColor(current.unitIndex), boxShadow: `0 4px 0 ${unitColor(current.unitIndex)}99` }}
|
style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
|
||||||
>
|
>
|
||||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
|
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
|
||||||
<Play size={20} fill="white" />
|
<Play size={20} fill="white" />
|
||||||
@@ -181,7 +182,7 @@ function NodeRow({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
|
||||||
style={done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
|
style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
|
||||||
>
|
>
|
||||||
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
|
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
|
||||||
</span>
|
</span>
|
||||||
@@ -219,7 +220,7 @@ function NodeRow({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
|
||||||
style={l.done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
|
style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
|
||||||
>
|
>
|
||||||
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
|
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
|
||||||
</span>
|
</span>
|
||||||
@@ -232,7 +233,7 @@ function NodeRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
|
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||||
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
|
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
|
||||||
style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }}
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
|
||||||
>
|
>
|
||||||
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
|
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
|
||||||
{done ? 'دوباره تمرین کن' : 'درسها رو خوندی؟ برو آزمون'}
|
{done ? 'دوباره تمرین کن' : 'درسها رو خوندی؟ برو آزمون'}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import confetti from 'canvas-confetti';
|
|
||||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
||||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
@@ -165,7 +164,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||||
setPhase({ kind: 'result', result });
|
setPhase({ kind: 'result', result });
|
||||||
sounds.finish();
|
sounds.finish();
|
||||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
|
||||||
void refreshHome();
|
void refreshHome();
|
||||||
void refreshSummary();
|
void refreshSummary();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,6 +282,17 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||||
}`}>
|
}`}>
|
||||||
{bubble}
|
{bubble}
|
||||||
|
{answer?.correct && answer.xpEarned > 0 && (
|
||||||
|
<motion.span
|
||||||
|
key={question.id}
|
||||||
|
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||||||
|
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
|
||||||
|
>
|
||||||
|
+{toFa(answer.xpEarned)} ⭐
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function StreakScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StreakGoal current={calendar.data.currentStreak} />
|
||||||
|
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
||||||
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
||||||
@@ -90,3 +92,24 @@ export function StreakScreen() {
|
|||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||||
|
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
||||||
|
|
||||||
|
function StreakGoal({ current }: { current: number }) {
|
||||||
|
const next = MILESTONES.find((m) => m > current);
|
||||||
|
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||||
|
<p className="text-sm font-black leading-7 text-ink">
|
||||||
|
{reached ? `🔥 ${toFa(current)} روزهای! ` : ''}
|
||||||
|
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||||
|
</p>
|
||||||
|
{next && (
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||||
|
<div className="h-full rounded-full bg-orange transition-[width]" style={{ width: `${Math.round((current / next) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function SubscriptionScreen() {
|
|||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#4C5FD5" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user