Round 20: Student Portal, District Management, userId field, styling enhancements, bug fixes

- Added userId field to Student model with User relation
- Created Student Portal view with 6 tabs (competencies, goals, achievements, schedule, homework, feedback)
- Created StudentGoal model + CRUD API with role-based access
- Created District Management view with CRUD, analytics, school assignment
- Enhanced auth page: animated gradient, floating particles, glassmorphism, staggered entrance
- Enhanced dashboard: time-based greeting, animated stat cards, class overview, school branding
- Enhanced student portal: hero section, circular progress, streak counter, celebration particles
- Fixed student-goals API 403 for STUDENT role (name-matching fallback for legacy data)
- Added userId filter to students API with OR fallback
- Added nav.student-portal i18n key
- 100+ new i18n keys in DE and EN
- All lint checks pass
This commit is contained in:
Mustafa Cetin committed 2026-07-29 18:45:41 +00:00
1 parent 9680cc9244
commit 04ae7aa80c
23 files changed
+4428 -2672

No files matched your search

+25
View File
@@ -133,6 +133,7 @@ model User {
rewardRedemptions RewardRedemption[]
rewardPoints RewardPoints[]
studentAnswers StudentAnswer[]
student Student?
}
model SchoolYear {
@@ -209,10 +210,12 @@ model Student {
externalId String? // for import matching
avatarUrl String? // student photo/avatar URL
avatarInitials String? // computed from firstName + lastName first chars
userId String? @unique // optional link to User account (for student self-service)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User? @relation(fields: [userId], references: [id])
school School @relation(fields: [schoolId], references: [id])
enrollments Enrollment[]
learningProgressEntries LearningProgressEntry[]
@@ -236,6 +239,9 @@ model Student {
transports StudentTransport[]
healthRecords HealthRecord[]
studentBadges StudentBadge[]
goals StudentGoal[]
@@index([userId])
}
model Enrollment {
@@ -1912,3 +1918,22 @@ model RewardPoints {
@@index([source])
@@map("reward_points")
}
// ─── Student Goals (Student Portal) ─────────────────────────────────
model StudentGoal {
id String @id @default(cuid())
studentId String
title String
description String?
targetDate DateTime?
progress Int @default(0)
status String @default("active") // active, completed, abandoned
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
student Student @relation(fields: [studentId], references: [id])
@@index([studentId])
@@index([status])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

@@ -58,3 +58,38 @@ export async function POST(
return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 });
}
}
// DELETE /api/districts/[id]/schools — remove school from district
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const { searchParams } = new URL(_req.url);
const schoolId = searchParams.get('schoolId');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
// Verify the school belongs to this district
const school = await db.school.findFirst({
where: { id: schoolId, districtId: id },
});
if (!school) {
return NextResponse.json({ error: 'School not found in this district' }, { status: 404 });
}
await db.school.update({
where: { id: schoolId },
data: { districtId: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[districts/[id]/schools] DELETE error:', error);
return NextResponse.json({ error: 'Failed to remove school from district' }, { status: 500 });
}
}
+12 -2
View File
@@ -8,7 +8,15 @@ export async function GET() {
where: { deletedAt: null },
include: {
schools: {
select: { id: true, name: true, schoolType: true, country: true },
select: {
id: true,
name: true,
schoolType: true,
country: true,
_count: {
select: { students: true, classGroups: true, users: true },
},
},
},
},
orderBy: { name: 'asc' },
@@ -18,7 +26,9 @@ export async function GET() {
const result = districts.map((d) => ({
...d,
schoolCount: d.schools.length,
totalStudents: 0, // Would need aggregation query in production
totalStudents: d.schools.reduce((acc, s) => acc + s._count.students, 0),
totalTeachers: d.schools.reduce((acc, s) => acc + s._count.users, 0),
totalClasses: d.schools.reduce((acc, s) => acc + s._count.classGroups, 0),
}));
return NextResponse.json(result);
+175
View File
@@ -0,0 +1,175 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
/**
* Check if a student belongs to the current user (STUDENT role).
* Uses userId (direct link) with name-matching fallback for legacy data.
*/
function isOwnStudent(
student: { userId: string | null; firstName: string; lastName: string; schoolId: string | null },
user: { id: string; firstName: string; lastName: string; schoolId: string | null }
): boolean {
if (student.userId === user.id) return true;
// Fallback: match by name + school for legacy records without userId
if (
student.userId === null &&
student.firstName === user.firstName &&
student.lastName === user.lastName &&
student.schoolId === user.schoolId
) {
return true;
}
return false;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const goal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!goal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(goal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (goal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.json(goal);
} catch (error) {
console.error('StudentGoal GET by ID error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { title, description, targetDate, progress, status } = body;
const existingGoal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(existingGoal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (existingGoal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (targetDate !== undefined) updateData.targetDate = targetDate ? new Date(targetDate) : null;
if (progress !== undefined) updateData.progress = Math.min(100, Math.max(0, progress));
if (status !== undefined) updateData.status = status;
// Auto-complete if progress reaches 100
if (progress !== undefined && progress >= 100) {
updateData.status = 'completed';
updateData.progress = 100;
}
const goal = await db.studentGoal.update({
where: { id },
data: updateData,
});
return NextResponse.json(goal);
} catch (error) {
console.error('StudentGoal PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existingGoal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(existingGoal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (existingGoal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
await db.studentGoal.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('StudentGoal DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
const status = searchParams.get('status');
if (!studentId) {
return NextResponse.json({ error: 'studentId is required' }, { status: 400 });
}
// Verify the student belongs to the same school or is the student themselves
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true },
});
if (!student || student.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { schoolId: true },
});
if (!student || student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const where: Record<string, unknown> = { studentId };
if (status) where.status = status;
const goals = await db.studentGoal.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(goals);
} catch (error) {
console.error('StudentGoal GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const { studentId, title, description, targetDate, progress, status } = body;
if (!studentId || !title) {
return NextResponse.json(
{ error: 'studentId and title are required' },
{ status: 400 }
);
}
// Verify the student belongs to the same school or is the student themselves
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true, firstName: true, lastName: true },
});
if (!student) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check by userId (direct link) or by name matching (fallback for legacy data)
const isOwnStudent = student.userId === session.user.id ||
(student.userId === null &&
student.firstName === session.user.firstName &&
student.lastName === session.user.lastName &&
student.schoolId === session.user.schoolId);
if (!isOwnStudent) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { schoolId: true },
});
if (!student || student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const goal = await db.studentGoal.create({
data: {
studentId,
title,
description: description || null,
targetDate: targetDate ? new Date(targetDate) : null,
progress: progress || 0,
status: status || 'active',
},
});
return NextResponse.json(goal, { status: 201 });
} catch (error) {
console.error('StudentGoal POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+32 -1
View File
@@ -25,11 +25,31 @@ export async function GET(request: Request) {
const schoolId = searchParams.get('schoolId');
const classGroupId = searchParams.get('classGroupId');
const search = searchParams.get('search');
const userId = searchParams.get('userId');
const where: Record<string, unknown> = { deletedAt: null };
if (schoolId) where.schoolId = schoolId;
// Filter by userId (direct link to User account)
let userIdFilter: Record<string, unknown>[] | null = null;
if (userId) {
// For STUDENT role, also match by name + school as fallback for legacy data
if (session.user?.role === 'STUDENT' && session.user.id === userId) {
userIdFilter = [
{ userId },
{
userId: null,
firstName: session.user.firstName,
lastName: session.user.lastName,
schoolId: session.user.schoolId,
},
];
} else {
where.userId = userId;
}
}
if (classGroupId) {
where.enrollments = {
some: { classGroupId, endDate: null },
@@ -37,11 +57,22 @@ export async function GET(request: Request) {
}
if (search) {
where.OR = [
const searchOr = [
{ firstName: { contains: search } },
{ lastName: { contains: search } },
{ externalId: { contains: search } },
];
// If userId filter also uses OR, combine with AND
if (userIdFilter) {
where.AND = [
{ OR: userIdFilter },
{ OR: searchOr },
];
} else {
where.OR = searchOr;
}
} else if (userIdFilter) {
where.OR = userIdFilter;
}
const students = await db.student.findMany({
+7
View File
@@ -59,6 +59,7 @@ import {
Library as LibraryIcon,
Trophy,
FolderOpen,
Building2,
} from 'lucide-react';
import {
Sidebar,
@@ -131,6 +132,7 @@ import { Skeleton } from '@/components/ui/skeleton';
// Static imports for lightweight views
import DashboardView from './dashboard-view';
import StudentPortalView from './student-portal-view';
import ClassesView from './classes-view';
import CompetencyGridView from './competency-grid-view';
import ProgressEntriesView from './progress-entries-view';
@@ -153,6 +155,7 @@ import PortfolioView from './portfolio-view';
import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view';
import CompetitionsView from './competitions-view';
import DistrictManagementView from './district-management-view';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -218,6 +221,7 @@ const navSections: NavSection[] = [
labelKey: 'polish.nav_setup',
items: [
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
],
},
@@ -230,6 +234,7 @@ const studentNavSections: NavSection[] = [
labelKey: 'student.dashboard_title',
items: [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'student-portal', icon: GraduationCap, labelKey: 'student_portal.title' },
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
@@ -361,6 +366,7 @@ function AnnouncementBanner({ schoolId }: { schoolId: string | null }) {
function renderView(view: ViewName) {
switch (view) {
case 'dashboard': return <DashboardView />;
case 'student-portal': return <StudentPortalView />;
case 'classes': return <ClassesView />;
case 'competencies': return <CompetencyGridView />;
case 'progress': return <ProgressEntriesView />;
@@ -387,6 +393,7 @@ function renderView(view: ViewName) {
case 'timetable': return <TimetableView />;
case 'resources': return <ResourceLibraryView />;
case 'competitions': return <CompetitionsView />;
case 'districts': return <DistrictManagementView />;
default: return <DashboardView />;
}
}
+534 -162
View File
@@ -1,8 +1,8 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle } from 'lucide-react';
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -23,6 +23,301 @@ const DEMO_ACCOUNTS = [
{ email: 'demo.parent@competencetrack.org', password: 'Demo2025!', role: 'PARENT', labelKey: 'auth.demo_parent', icon: Heart, colorClass: 'from-violet-400 to-violet-600 hover:from-violet-500 hover:to-violet-700 shadow-violet-300/40 dark:shadow-violet-900/40' },
];
/* ─── Staggered entrance helpers ────────────────────────────── */
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.07, delayChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 14 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: 'easeOut' } },
};
/* ─── Floating particles ────────────────────────────────────── */
function FloatingParticles() {
const particles = useMemo(() => [
{ id: 'p1', x: 8, y: 12, size: 6, opacity: 0.25, color: 'emerald', delay: 0, duration: 14 },
{ id: 'p2', x: 85, y: 18, size: 4, opacity: 0.2, color: 'teal', delay: 2, duration: 18 },
{ id: 'p3', x: 22, y: 65, size: 5, opacity: 0.18, color: 'amber', delay: 4, duration: 16 },
{ id: 'p4', x: 70, y: 75, size: 3, opacity: 0.22, color: 'violet', delay: 1, duration: 20 },
{ id: 'p5', x: 45, y: 88, size: 4, opacity: 0.15, color: 'emerald', delay: 3, duration: 12 },
{ id: 'p6', x: 92, y: 45, size: 5, opacity: 0.2, color: 'teal', delay: 5, duration: 15 },
{ id: 'p7', x: 15, y: 40, size: 3, opacity: 0.18, color: 'amber', delay: 6, duration: 22 },
{ id: 'p8', x: 55, y: 10, size: 4, opacity: 0.15, color: 'violet', delay: 2.5, duration: 17 },
{ id: 'p9', x: 35, y: 50, size: 2, opacity: 0.12, color: 'emerald', delay: 7, duration: 19 },
{ id: 'p10', x: 78, y: 55, size: 3, opacity: 0.16, color: 'teal', delay: 1.5, duration: 13 },
{ id: 'p11', x: 60, y: 30, size: 2, opacity: 0.14, color: 'amber', delay: 4.5, duration: 21 },
{ id: 'p12', x: 5, y: 80, size: 4, opacity: 0.2, color: 'violet', delay: 3.5, duration: 16 },
], []);
const colorMap: Record<string, string> = {
emerald: 'bg-emerald-400 dark:bg-emerald-500',
teal: 'bg-teal-400 dark:bg-teal-500',
amber: 'bg-amber-400 dark:bg-amber-500',
violet: 'bg-violet-400 dark:bg-violet-500',
};
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<motion.div
key={p.id}
className={`absolute rounded-full ${colorMap[p.color]}`}
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: p.size,
height: p.size,
opacity: p.opacity,
}}
animate={{
y: [0, -20, 10, -15, 0],
x: [0, 8, -6, 12, 0],
opacity: [p.opacity, p.opacity * 0.6, p.opacity * 1.2, p.opacity * 0.8, p.opacity],
}}
transition={{
duration: p.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: p.delay,
}}
/>
))}
</div>
);
}
/* ─── Decorative geometric shapes ───────────────────────────── */
function FloatingShapes() {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Large circles */}
<div className="absolute top-[10%] left-[8%] w-40 h-40 rounded-full bg-emerald-200/20 dark:bg-emerald-800/8 animate-float-slow" />
<div className="absolute top-[55%] right-[15%] w-28 h-28 rounded-full bg-teal-200/18 dark:bg-teal-800/8 animate-float" />
<div className="absolute bottom-[15%] left-[20%] w-20 h-20 rounded-full bg-amber-200/15 dark:bg-amber-800/8 animate-float-reverse" />
{/* Rotated squares / diamonds */}
<motion.div
className="absolute top-[25%] right-[35%] w-12 h-12 rounded-xl bg-emerald-300/12 dark:bg-emerald-700/8"
animate={{ rotate: [0, 90, 180, 270, 360] }}
transition={{ duration: 30, repeat: Infinity, ease: 'linear' }}
/>
<motion.div
className="absolute top-[70%] left-[45%] w-8 h-8 rounded-lg bg-violet-300/12 dark:bg-violet-700/8"
animate={{ rotate: [360, 270, 180, 90, 0] }}
transition={{ duration: 25, repeat: Infinity, ease: 'linear' }}
/>
<motion.div
className="absolute top-[15%] left-[50%] w-10 h-10 rounded-lg bg-teal-300/10 dark:bg-teal-700/8"
animate={{ rotate: [0, -90, -180, -270, -360] }}
transition={{ duration: 35, repeat: Infinity, ease: 'linear' }}
/>
{/* Hexagonal shapes (approximated with rounded corners) */}
<motion.div
className="absolute bottom-[30%] right-[25%] w-16 h-16 rounded-2xl bg-amber-200/10 dark:bg-amber-800/8 border border-amber-300/10 dark:border-amber-700/10"
animate={{ rotate: [0, 45, 0], scale: [1, 1.05, 1] }}
transition={{ duration: 12, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute top-[45%] left-[5%] w-14 h-14 rounded-2xl bg-emerald-200/10 dark:bg-emerald-800/8 border border-emerald-300/10 dark:border-emerald-700/10"
animate={{ rotate: [0, -30, 0], scale: [1, 1.08, 1] }}
transition={{ duration: 15, repeat: Infinity, ease: 'easeInOut' }}
/>
{/* Dot grid overlay */}
<div className="absolute inset-0 bg-dots opacity-20 dark:opacity-8" />
</div>
);
}
/* ─── Left-side illustration panel ──────────────────────────── */
function LeftIllustration({ loginRole }: { loginRole: LoginRole }) {
const features = [
{ icon: Sparkles, labelKey: 'auth.feature_competencies', color: 'emerald' },
{ icon: Heart, labelKey: 'auth.feature_student_centered', color: 'teal' },
{ icon: BookOpen, labelKey: 'auth.feature_open_source', color: 'amber' },
{ icon: GraduationCap, labelKey: 'auth.feature_reports', color: 'violet' },
{ icon: Flower2, labelKey: 'auth.feature_flower', color: 'rose' },
];
const colorClasses: Record<string, { bg: string; text: string; ring: string }> = {
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', ring: 'ring-emerald-200/50 dark:ring-emerald-800/30' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', ring: 'ring-teal-200/50 dark:ring-teal-800/30' },
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', ring: 'ring-amber-200/50 dark:ring-amber-800/30' },
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400', ring: 'ring-violet-200/50 dark:ring-violet-800/30' },
rose: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400', ring: 'ring-rose-200/50 dark:ring-rose-800/30' },
};
return (
<motion.div
initial={{ opacity: 0, x: -40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.7, delay: 0.2, ease: 'easeOut' }}
className="hidden lg:flex flex-col items-center justify-center w-1/2 max-w-lg mr-12"
>
<div className="relative">
{/* Decorative orbiting circles */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="absolute -top-8 -left-8 w-20 h-20 rounded-full bg-emerald-200/40 dark:bg-emerald-800/20 ring-4 ring-emerald-100/50 dark:ring-emerald-900/20"
/>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="absolute -bottom-10 -right-10 w-24 h-24 rounded-full bg-teal-200/40 dark:bg-teal-800/20 ring-4 ring-teal-100/50 dark:ring-teal-900/20"
/>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.5 }}
className="absolute top-10 -right-6 w-12 h-12 rounded-full bg-amber-200/40 dark:bg-amber-800/20 ring-2 ring-amber-100/50 dark:ring-amber-900/20"
/>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
className="absolute -bottom-4 -left-6 w-10 h-10 rounded-full bg-violet-200/40 dark:bg-violet-800/20 ring-2 ring-violet-100/50 dark:ring-violet-900/20"
/>
{/* Main icon with role-based animation */}
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex items-center justify-center w-32 h-32 rounded-3xl bg-gradient-to-br from-emerald-400 via-teal-500 to-emerald-600 text-white shadow-2xl shadow-emerald-300/50 dark:shadow-emerald-900/50 ring-1 ring-white/20"
>
<AnimatePresence mode="wait">
{loginRole === 'teacher' && (
<motion.div key="teacher" initial={{ rotate: -90, opacity: 0, scale: 0.5 }} animate={{ rotate: 0, opacity: 1, scale: 1 }} exit={{ rotate: 90, opacity: 0, scale: 0.5 }} transition={{ duration: 0.35, type: 'spring', stiffness: 200 }}>
<GraduationCap className="w-16 h-16" />
</motion.div>
)}
{loginRole === 'student' && (
<motion.div key="student" initial={{ rotate: -90, opacity: 0, scale: 0.5 }} animate={{ rotate: 0, opacity: 1, scale: 1 }} exit={{ rotate: 90, opacity: 0, scale: 0.5 }} transition={{ duration: 0.35, type: 'spring', stiffness: 200 }}>
<UsersIcon className="w-16 h-16" />
</motion.div>
)}
{loginRole === 'parent' && (
<motion.div key="parent" initial={{ rotate: -90, opacity: 0, scale: 0.5 }} animate={{ rotate: 0, opacity: 1, scale: 1 }} exit={{ rotate: 90, opacity: 0, scale: 0.5 }} transition={{ duration: 0.35, type: 'spring', stiffness: 200 }}>
<Heart className="w-16 h-16" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
{/* Feature highlights */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.7 }}
className="mt-12 space-y-4 text-center"
>
<h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 tracking-tight">
{t('auth.mission_title')}
</h3>
<div className="space-y-3">
{features.map((f, i) => {
const c = colorClasses[f.color];
return (
<motion.div
key={f.labelKey}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: 0.8 + i * 0.1 }}
className="flex items-center gap-3"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-xl ${c.bg} ${c.text} ring-1 ${c.ring}`}>
<f.icon className="w-4.5 h-4.5" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 font-medium">{t(f.labelKey)}</p>
</motion.div>
);
})}
</div>
{/* Mockup dashboard preview */}
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.3 }}
className="mt-8 p-5 rounded-2xl bg-white/60 dark:bg-gray-800/40 backdrop-blur-md border border-emerald-200/30 dark:border-emerald-900/20 shadow-xl shadow-emerald-100/20 dark:shadow-emerald-900/10"
>
<div className="flex items-center gap-2 mb-3">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-amber-400" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-400" />
<div className="ml-3 flex-1 h-2.5 rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
</div>
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className={`h-10 rounded-lg ${i % 3 === 0 ? 'bg-gradient-to-br from-emerald-100 to-emerald-200/50 dark:from-emerald-900/30 dark:to-emerald-800/20' : i % 3 === 1 ? 'bg-gradient-to-br from-teal-100 to-teal-200/50 dark:from-teal-900/30 dark:to-teal-800/20' : 'bg-gradient-to-br from-amber-100 to-amber-200/50 dark:from-amber-900/30 dark:to-amber-800/20'}`} />
))}
</div>
<div className="mt-3 space-y-2">
<div className="h-2 w-full rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
<div className="h-2 w-3/4 rounded-full bg-teal-100 dark:bg-teal-900/30" />
<div className="h-2 w-1/2 rounded-full bg-amber-100 dark:bg-amber-900/30" />
</div>
</motion.div>
</motion.div>
</motion.div>
);
}
/* ─── Features section below form ───────────────────────────── */
function FeaturesSection() {
const features = [
{ icon: BarChart3, labelKey: 'auth.feature_competencies', color: 'emerald' },
{ icon: LockKeyhole, labelKey: 'auth.feature_data_security', color: 'teal' },
{ icon: GitBranch, labelKey: 'auth.feature_open_source', color: 'amber' },
{ icon: Flower2, labelKey: 'auth.feature_flower', color: 'violet' },
];
const colorClasses: Record<string, { bg: string; text: string }> = {
emerald: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400' },
teal: { bg: 'bg-teal-50 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400' },
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
violet: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
};
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-6"
>
<div className="grid grid-cols-2 gap-3">
{features.map((f, i) => {
const c = colorClasses[f.color];
return (
<motion.div
key={f.labelKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7 + i * 0.08 }}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl ${c.bg} border border-white/40 dark:border-gray-800/40`}
>
<f.icon className={`w-4 h-4 ${c.text} shrink-0`} />
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 leading-tight">{t(f.labelKey)}</span>
</motion.div>
);
})}
</div>
</motion.div>
);
}
/* ─── Main Auth View ────────────────────────────────────────── */
export default function AuthView() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [loginRole, setLoginRole] = useState<LoginRole>('teacher');
@@ -114,143 +409,156 @@ export default function AuthView() {
}
};
// Role tab config
const roleTabs: Array<{ key: LoginRole; icon: React.ElementType; labelKey: string; color: string; activeColor: string; bgGradient: string }> = [
{ key: 'teacher', icon: GraduationCap, labelKey: 'auth.login', color: 'text-emerald-600 dark:text-emerald-400', activeColor: 'bg-emerald-500 text-white', bgGradient: 'from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10' },
{ key: 'student', icon: UsersIcon, labelKey: 'auth.student_login', color: 'text-amber-600 dark:text-amber-400', activeColor: 'bg-amber-500 text-white', bgGradient: 'from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10' },
{ key: 'parent', icon: Heart, labelKey: 'auth.parent_login', color: 'text-violet-600 dark:text-violet-400', activeColor: 'bg-violet-500 text-white', bgGradient: 'from-violet-50/50 to-purple-50/50 dark:from-violet-950/10 dark:to-purple-950/10' },
// Role tab config with enhanced visual distinction
const roleTabs: Array<{
key: LoginRole;
icon: React.ElementType;
labelKey: string;
color: string;
activeColor: string;
activeBg: string;
bgGradient: string;
hoverBg: string;
indicatorColor: string;
glowColor: string;
}> = [
{
key: 'teacher',
icon: GraduationCap,
labelKey: 'auth.login',
color: 'text-emerald-600 dark:text-emerald-400',
activeColor: 'text-white',
activeBg: 'bg-gradient-to-r from-emerald-500 to-teal-500',
bgGradient: 'from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10',
hoverBg: 'hover:bg-emerald-50/60 dark:hover:bg-emerald-950/20',
indicatorColor: 'bg-emerald-500',
glowColor: 'shadow-emerald-200/50 dark:shadow-emerald-900/30',
},
{
key: 'student',
icon: UsersIcon,
labelKey: 'auth.student_login',
color: 'text-amber-600 dark:text-amber-400',
activeColor: 'text-white',
activeBg: 'bg-gradient-to-r from-amber-500 to-orange-500',
bgGradient: 'from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10',
hoverBg: 'hover:bg-amber-50/60 dark:hover:bg-amber-950/20',
indicatorColor: 'bg-amber-500',
glowColor: 'shadow-amber-200/50 dark:shadow-amber-900/30',
},
{
key: 'parent',
icon: Heart,
labelKey: 'auth.parent_login',
color: 'text-violet-600 dark:text-violet-400',
activeColor: 'text-white',
activeBg: 'bg-gradient-to-r from-violet-500 to-purple-500',
bgGradient: 'from-violet-50/50 to-purple-50/50 dark:from-violet-950/10 dark:to-purple-950/10',
hoverBg: 'hover:bg-violet-50/60 dark:hover:bg-violet-950/20',
indicatorColor: 'bg-violet-500',
glowColor: 'shadow-violet-200/50 dark:shadow-violet-900/30',
},
];
const activeRoleTab = roleTabs.find((r) => r.key === loginRole)!;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 md:p-8 relative overflow-hidden">
{/* Animated floating geometric shapes background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-[15%] left-[10%] w-32 h-32 rounded-full bg-emerald-200/30 dark:bg-emerald-800/10 animate-float-slow" />
<div className="absolute top-[60%] right-[20%] w-20 h-20 rounded-xl bg-teal-200/25 dark:bg-teal-800/10 animate-float" />
<div className="absolute bottom-[25%] left-[25%] w-14 h-14 rounded-full bg-amber-200/20 dark:bg-amber-800/10 animate-float-reverse" />
<div className="absolute top-[40%] right-[40%] w-8 h-8 rounded-full bg-emerald-300/15 dark:bg-emerald-700/10 animate-float" />
<div className="absolute top-[80%] left-[60%] w-16 h-16 rounded-lg bg-violet-200/20 dark:bg-violet-800/10 animate-float-reverse" />
<div className="absolute top-[30%] right-[70%] w-12 h-12 rounded-full bg-teal-200/20 dark:bg-teal-800/10 animate-float-slow" />
<div className="absolute inset-0 bg-dots opacity-30 dark:opacity-10" />
</div>
<div className="min-h-screen flex items-center justify-center animated-gradient-bg p-4 md:p-8 relative overflow-hidden">
{/* Animated gradient background overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50/80 via-white/60 to-teal-50/80 dark:from-gray-950/90 dark:via-gray-900/90 dark:to-emerald-950/90 pointer-events-none" />
{/* Floating geometric shapes */}
<FloatingShapes />
{/* Floating particles */}
<FloatingParticles />
{/* Left illustration side (hidden on small screens) */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="hidden lg:flex flex-col items-center justify-center w-1/2 max-w-md mr-8"
>
<div className="relative">
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.3 }} className="absolute -top-6 -left-6 w-20 h-20 rounded-full bg-emerald-200/50 dark:bg-emerald-800/30" />
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.4 }} className="absolute -bottom-8 -right-8 w-24 h-24 rounded-full bg-teal-200/50 dark:bg-teal-800/30" />
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.5 }} className="absolute top-12 -right-4 w-12 h-12 rounded-full bg-amber-200/50 dark:bg-amber-800/30" />
{/* Main icon changes based on login role */}
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex items-center justify-center w-28 h-28 rounded-3xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-2xl shadow-emerald-300/40 dark:shadow-emerald-900/40"
>
<AnimatePresence mode="wait">
{loginRole === 'teacher' && <motion.div key="teacher" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><GraduationCap className="w-14 h-14" /></motion.div>}
{loginRole === 'student' && <motion.div key="student" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><UsersIcon className="w-14 h-14" /></motion.div>}
{loginRole === 'parent' && <motion.div key="parent" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><Heart className="w-14 h-14" /></motion.div>}
</AnimatePresence>
</motion.div>
</div>
{/* Feature highlights */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.6 }} className="mt-10 space-y-4 text-center">
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-200">{t('auth.mission_title')}</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"><Sparkles className="w-4 h-4" /></div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_competencies')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"><Heart className="w-4 h-4" /></div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_student_centered')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"><BookOpen className="w-4 h-4" /></div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_open_source')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"><GraduationCap className="w-4 h-4" /></div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_reports')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400"><Flower2 className="w-4 h-4" /></div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_flower')}</p>
</div>
</div>
{/* Mockup dashboard preview */}
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.8 }} className="mt-6 p-4 rounded-2xl bg-white/70 dark:bg-gray-800/50 backdrop-blur-sm border border-emerald-200/40 dark:border-emerald-900/30 shadow-lg shadow-emerald-100/30 dark:shadow-emerald-900/10">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-400" />
<div className="w-2 h-2 rounded-full bg-amber-400" />
<div className="w-2 h-2 rounded-full bg-emerald-400" />
<div className="ml-2 flex-1 h-2 rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
</div>
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className={`h-8 rounded-md ${i % 3 === 0 ? 'bg-gradient-to-br from-emerald-100 to-emerald-200/50 dark:from-emerald-900/30 dark:to-emerald-800/20' : i % 3 === 1 ? 'bg-gradient-to-br from-teal-100 to-teal-200/50 dark:from-teal-900/30 dark:to-teal-800/20' : 'bg-gradient-to-br from-amber-100 to-amber-200/50 dark:from-amber-900/30 dark:to-amber-800/20'}`} />
))}
</div>
<div className="mt-3 space-y-1.5">
<div className="h-1.5 w-full rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
<div className="h-1.5 w-3/4 rounded-full bg-teal-100 dark:bg-teal-900/30" />
<div className="h-1.5 w-1/2 rounded-full bg-amber-100 dark:bg-amber-900/30" />
</div>
</motion.div>
</motion.div>
</motion.div>
<LeftIllustration loginRole={loginRole} />
{/* Auth form */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md md:max-w-lg">
{/* Branding */}
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="w-full max-w-md md:max-w-lg relative z-10"
>
{/* Branding (mobile only) */}
<div className="text-center mb-8 lg:hidden">
<motion.div initial={{ scale: 0.8 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white mb-4 shadow-lg shadow-emerald-200 dark:shadow-emerald-900">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1, type: 'spring', stiffness: 180 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white mb-4 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/50 ring-1 ring-white/20"
>
<BookOpen className="w-8 h-8" />
</motion.div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{t('app.name')}</h1>
<p className="mt-2 text-sm text-emerald-600/70 dark:text-emerald-400/50">{t('app.subtitle')}</p>
<motion.h1
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
className="text-3xl font-bold text-gray-900 dark:text-gray-100 tracking-tight"
>
{t('app.name')}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="mt-2 text-sm text-emerald-600/70 dark:text-emerald-400/50"
>
{t('app.subtitle')}
</motion.p>
</div>
{/* Auth Card */}
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-2xl overflow-hidden bg-white dark:bg-gray-950">
{/* Role tabs */}
<div className="flex border-b border-gray-100 dark:border-gray-800">
{roleTabs.map((tab) => (
<button
{/* Auth Card - Glassmorphism */}
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<Card className="shadow-2xl border-0 shadow-emerald-100/60 dark:shadow-emerald-900/30 rounded-2xl overflow-hidden bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl ring-1 ring-white/40 dark:ring-gray-800/40">
{/* Role tabs with enhanced visual distinction */}
<div className="flex border-b border-gray-100/80 dark:border-gray-800/80 bg-gray-50/50 dark:bg-gray-900/30">
{roleTabs.map((tab, i) => (
<motion.button
key={tab.key}
type="button"
onClick={() => { setLoginRole(tab.key); setShowForgotInfo(false); }}
className={`flex-1 min-h-[44px] flex items-center justify-center gap-2 px-3 py-3 text-sm font-semibold transition-all duration-200 ${
className={`relative flex-1 min-h-[48px] flex items-center justify-center gap-2 px-3 py-3.5 text-sm font-semibold transition-all duration-300 ${
loginRole === tab.key
? `${tab.activeColor} shadow-sm`
: `${tab.color} bg-transparent hover:bg-gray-50 dark:hover:bg-gray-900/30`
? `${tab.activeBg} ${tab.activeColor} shadow-sm ${tab.glowColor}`
: `${tab.color} bg-transparent ${tab.hoverBg}`
}`}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 + i * 0.05 }}
whileHover={{ scale: loginRole === tab.key ? 1 : 1.02 }}
whileTap={{ scale: 0.98 }}
>
<tab.icon className="h-4 w-4" />
<tab.icon className="h-4.5 w-4.5" />
<span>{t(tab.labelKey)}</span>
</button>
{/* Active indicator */}
{loginRole === tab.key && (
<motion.div
layoutId="activeTabIndicator"
className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-white/60"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</motion.button>
))}
</div>
<CardHeader className={`text-center pb-2 pt-6 bg-gradient-to-r ${activeRoleTab.bgGradient}`}>
<CardHeader className={`text-center pb-2 pt-6 bg-gradient-to-r ${activeRoleTab.bgGradient} transition-all duration-300`}>
<AnimatePresence mode="wait">
<motion.div key={loginRole} initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={{ duration: 0.2 }}>
<motion.div key={loginRole} initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={{ duration: 0.25 }}>
<CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100">
{loginRole === 'teacher' ? (mode === 'login' ? t('auth.login') : t('auth.register_title'))
: loginRole === 'student' ? t('auth.student_login')
: t('auth.parent_login')}
</CardTitle>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{loginRole === 'teacher' ? (mode === 'login' ? t('auth.login_subtitle') : t('auth.register_subtitle'))
: loginRole === 'student' ? t('auth.student_login_desc')
: t('auth.parent_login_desc')}
@@ -260,42 +568,48 @@ export default function AuthView() {
</CardHeader>
<CardContent className="pt-6 pb-6">
<form onSubmit={handleSubmit} className="space-y-5">
<motion.form
onSubmit={handleSubmit}
className="space-y-5"
variants={containerVariants}
initial="hidden"
animate="visible"
>
<AnimatePresence mode="wait">
{/* Name fields for register (teacher only) */}
{mode === 'register' && loginRole === 'teacher' && (
<motion.div key="name-fields" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<motion.div key="name-fields" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.25 }} className="space-y-4">
<motion.div variants={itemVariants} className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.firstName')} required />
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.firstName')} required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName" className="text-sm font-medium">{t('auth.lastName')}</Label>
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.lastName')} required />
</div>
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.lastName')} required />
</div>
</motion.div>
</motion.div>
)}
{/* Student-specific: School ID field */}
{loginRole === 'student' && (
<motion.div key="school-id" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="space-y-2">
<motion.div key="school-id" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.25 }} className="space-y-2">
<Label htmlFor="schoolId" className="text-sm font-medium">{t('auth.school_id')}</Label>
<div className="relative">
<Info className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-amber-400" />
<Input id="schoolId" value={schoolIdInput} onChange={(e) => setSchoolIdInput(e.target.value)} className="pl-10 h-12 min-h-[44px] border-amber-200/50 dark:border-amber-900/30 focus:border-amber-400 focus:ring-amber-400/20 text-base" placeholder={t('auth.school_id')} />
<Input id="schoolId" value={schoolIdInput} onChange={(e) => setSchoolIdInput(e.target.value)} className="pl-10 h-12 min-h-[44px] border-amber-200/50 dark:border-amber-900/30 focus:border-amber-400 focus:ring-amber-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.school_id')} />
</div>
</motion.div>
)}
{/* Parent-specific: name fields on login */}
{loginRole === 'parent' && mode === 'login' && (
<motion.div key="parent-name" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }}>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20 mb-4">
<motion.div key="parent-name" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.25 }}>
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20 backdrop-blur-sm">
<Heart className="h-4 w-4 text-violet-500" />
<p className="text-xs text-violet-700 dark:text-violet-300 font-medium">{t('auth.parent_login_desc')}</p>
</div>
@@ -304,7 +618,7 @@ export default function AuthView() {
</AnimatePresence>
{/* Email field (always shown) */}
<div className="space-y-2">
<motion.div variants={itemVariants} className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
@@ -313,7 +627,7 @@ export default function AuthView() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base ${
className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20'
: loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20'
: ''
@@ -322,20 +636,22 @@ export default function AuthView() {
required
/>
</div>
</div>
</motion.div>
{/* Password field */}
<div className="space-y-2">
<motion.div variants={itemVariants} className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-medium">{t('auth.password')}</Label>
{mode === 'login' && (
<button
<motion.button
type="button"
onClick={() => setShowForgotInfo(!showForgotInfo)}
className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{t('auth.forgot_password')}
</button>
</motion.button>
)}
</div>
<div className="relative">
@@ -345,27 +661,29 @@ export default function AuthView() {
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-9 pr-10 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
className="pl-9 pr-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200"
placeholder="••••••"
minLength={6}
required
/>
<button
<motion.button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center"
tabIndex={-1}
aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</motion.button>
</div>
{/* Forgot Password info */}
<AnimatePresence>
{showForgotInfo && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="mt-2">
<div className="flex items-start gap-2 px-3 py-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.25 }} className="mt-2">
<div className="flex items-start gap-2 px-3 py-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20 backdrop-blur-sm">
<HelpCircle className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
<p className="text-xs text-emerald-700 dark:text-emerald-300">
{loginRole === 'student'
@@ -379,43 +697,65 @@ export default function AuthView() {
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
{/* Remember me (login mode only) */}
{mode === 'login' && (
<div className="flex items-center gap-2">
<motion.div variants={itemVariants} className="flex items-center gap-2">
<Checkbox id="remember" checked={rememberMe} onCheckedChange={(v) => setRememberMe(v === true)} className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" />
<Label htmlFor="remember" className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer">{t('polish.remember_me')}</Label>
</div>
</motion.div>
)}
{/* Submit button */}
{/* Submit button with glow effect */}
<motion.div variants={itemVariants}>
<Button
type="submit"
className={`w-full h-12 min-h-[44px] text-white shadow-lg rounded-xl text-base ${
loginRole === 'teacher' ? 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 shadow-emerald-300/30 dark:shadow-emerald-900/30'
: loginRole === 'student' ? 'bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 shadow-amber-300/30 dark:shadow-amber-900/30'
: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 shadow-violet-300/30 dark:shadow-violet-900/30'
className={`group relative w-full h-12 min-h-[44px] text-white shadow-lg rounded-xl text-base font-semibold overflow-hidden transition-all duration-300 ${
loginRole === 'teacher' ? 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 shadow-emerald-300/30 dark:shadow-emerald-900/30 hover:shadow-emerald-300/50 dark:hover:shadow-emerald-900/50'
: loginRole === 'student' ? 'bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 shadow-amber-300/30 dark:shadow-amber-900/30 hover:shadow-amber-300/50 dark:hover:shadow-amber-900/50'
: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 shadow-violet-300/30 dark:shadow-violet-900/30 hover:shadow-violet-300/50 dark:hover:shadow-violet-900/50'
}`}
disabled={loading || demoLoading}
>
{/* Glow effect layer */}
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-white/10" />
<motion.div
className="absolute inset-0 rounded-xl"
animate={{
boxShadow: loginRole === 'teacher'
? ['0 0 0px rgba(16,185,129,0)', '0 0 20px rgba(16,185,129,0.15)', '0 0 0px rgba(16,185,129,0)']
: loginRole === 'student'
? ['0 0 0px rgba(245,158,11,0)', '0 0 20px rgba(245,158,11,0.15)', '0 0 0px rgba(245,158,11,0)']
: ['0 0 0px rgba(139,92,246,0)', '0 0 20px rgba(139,92,246,0.15)', '0 0 0px rgba(139,92,246,0)'],
}}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
/>
<span className="relative z-10 flex items-center justify-center gap-2">
{loading || demoLoading ? (
<span className="flex items-center gap-2">
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{demoLoading ? t('auth.demo_logging_in') : t('empty.loading')}
</span>
</>
) : (
<span className="flex items-center gap-2">
<>
{mode === 'login' ? t('auth.login') : t('auth.register')}
<ArrowRight className="h-4 w-4" />
</span>
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
</>
)}
</span>
</Button>
</form>
</motion.div>
</motion.form>
{/* Role-specific demo login buttons */}
{mode === 'login' && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="mt-5">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-6"
>
<div className="text-center mb-3">
<div className="flex items-center justify-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-amber-500" />
@@ -425,18 +765,24 @@ export default function AuthView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p>
</div>
<div className="grid grid-cols-2 gap-3">
{DEMO_ACCOUNTS.map((demo) => (
<Button
{DEMO_ACCOUNTS.map((demo, i) => (
<motion.div
key={demo.email}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.35 + i * 0.06, duration: 0.3 }}
>
<Button
type="button"
variant="outline"
onClick={() => handleDemoLogin(demo.email, demo.password)}
disabled={loading || demoLoading}
className={`h-auto min-h-[44px] py-3 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`}
className={`group h-auto min-h-[44px] py-3 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl hover:scale-[1.03] active:scale-[0.97] transition-all duration-200`}
>
<demo.icon className="h-5 w-5" />
<demo.icon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
<span>{t(demo.labelKey)}</span>
</Button>
</motion.div>
))}
</div>
</motion.div>
@@ -444,55 +790,81 @@ export default function AuthView() {
{/* Toggle register/login (only for teacher role) */}
{loginRole === 'teacher' && (
<div className="mt-6 text-center">
<button
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="mt-6 text-center"
>
<motion.button
type="button"
onClick={toggleMode}
className="text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{mode === 'login' ? t('auth.no_account') : t('auth.has_account')}
</button>
</div>
</motion.button>
</motion.div>
)}
{/* Student info: accounts created by teachers */}
{loginRole === 'student' && mode === 'login' && (
<div className="mt-5 text-center">
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-5 text-center space-y-2"
>
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20 backdrop-blur-sm">
<Info className="h-4 w-4 text-amber-500" />
<p className="text-xs text-amber-700 dark:text-amber-300 font-medium">
{t('auth.create_student_account')} — {t('auth.student_login_desc')}
</p>
</div>
{/* Jugendschutz notice for students */}
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20 mt-2">
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20 backdrop-blur-sm">
<Shield className="h-4 w-4 text-emerald-500" />
<p className="text-xs text-emerald-700 dark:text-emerald-300 font-medium">
{t('dsgvo.jugendschutz_login_notice')}
</p>
</div>
</div>
</motion.div>
)}
{/* Parent info */}
{loginRole === 'parent' && mode === 'login' && (
<div className="mt-5 text-center">
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-200/30 dark:border-violet-900/20">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-5 text-center"
>
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-violet-50/50 dark:bg-violet-900/10 border border-violet-200/30 dark:border-violet-900/20 backdrop-blur-sm">
<Heart className="h-4 w-4 text-violet-500" />
<p className="text-xs text-violet-700 dark:text-violet-300 font-medium">
{t('parent.my_children')} — {t('auth.parent_login_desc')}
</p>
</div>
</div>
</motion.div>
)}
</CardContent>
</Card>
</motion.div>
{/* Features section below form */}
<FeaturesSection />
{/* Footer */}
<div className="mt-8 text-center flex items-center justify-center gap-1.5">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.5 }}
className="mt-8 text-center flex items-center justify-center gap-1.5"
>
<Leaf className="h-3.5 w-3.5 text-emerald-500/40 dark:text-emerald-400/30" />
<p className="text-xs text-emerald-600/40 dark:text-emerald-400/30">{t('app.tagline')}</p>
</div>
</motion.div>
</motion.div>
</div>
);
+288 -45
View File
@@ -234,6 +234,30 @@ const motivationalTips = [
{ text: 'Detailed records make parent conversations productive.', icon: MessageSquare },
];
// ─── Time-based Greeting Helper ────────────────────────────────────
function getTimeGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return t('dashboard.greeting_morning');
if (hour < 18) return t('dashboard.greeting_afternoon');
return t('dashboard.greeting_evening');
}
// ─── Gradient Border Card Wrapper ──────────────────────────────────
function GradientBorderCard({ children, className = '', gradientFrom = 'from-emerald-400', gradientTo = 'to-teal-500' }: {
children: React.ReactNode;
className?: string;
gradientFrom?: string;
gradientTo?: string;
}) {
return (
<div className={`relative rounded-xl p-[1.5px] bg-gradient-to-br ${gradientFrom} ${gradientTo} ${className}`}>
<div className="rounded-[10px] bg-white dark:bg-gray-950 h-full">
{children}
</div>
</div>
);
}
// ─── Parent Dashboard Component ────────────────────────────────────────
function ParentDashboard({ currentUser, setCurrentView, locale }: {
currentUser: CurrentUser;
@@ -848,7 +872,7 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/>
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} />
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} isAnimationActive animationDuration={1000} animationEasing="ease-out" />
</BarChart>
</ResponsiveContainer>
</div>
@@ -866,6 +890,9 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
outerRadius={80}
paddingAngle={3}
dataKey="value"
isAnimationActive
animationDuration={1200}
animationEasing="ease-out"
>
{gradeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
@@ -1579,7 +1606,7 @@ export default function DashboardView() {
<XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" />
<YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} />
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} isAnimationActive animationDuration={1000} animationEasing="ease-out" />
</BarChart>
</ResponsiveContainer>
</CardContent>
@@ -1658,12 +1685,17 @@ export default function DashboardView() {
{/* Top row: welcome + date */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 150, damping: 12 }}
className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
>
<Sparkles className="w-6 h-6" />
</div>
</motion.div>
<div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent">
{t('dashboard.welcome')}, {currentUser?.firstName}!
{getTimeGreeting()}, {currentUser?.firstName}!
</h2>
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('dashboard.overview')}</p>
</div>
@@ -1698,20 +1730,27 @@ export default function DashboardView() {
</div>
</div>
{/* Tip of the day */}
{/* Tip of the day — enhanced with gradient border */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20"
className="relative"
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-amber-400/20 via-emerald-400/20 to-teal-400/20 dark:from-amber-400/10 dark:via-emerald-400/10 dark:to-teal-400/10 blur-sm" />
<div className="relative flex items-center gap-3 px-4 py-3 rounded-xl bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20">
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 5 }}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0">
<dailyTip.icon className="h-4 w-4" />
</div>
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-snug">
<span className="font-semibold text-emerald-700 dark:text-emerald-300">{locale === 'de' ? 'Tipp des Tages' : 'Tip of the day'}:</span>{' '}
<span className="font-semibold text-emerald-700 dark:text-emerald-300">{t('dashboard.daily_tip')}:</span>{' '}
{dailyTip.text}
</p>
</div>
</motion.div>
</div>
</motion.div>
@@ -1815,23 +1854,36 @@ export default function DashboardView() {
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-2 hover:ring-emerald-200/60 dark:hover:ring-emerald-700/40`}>
{/* Gradient top border */}
<div className={`h-1 bg-gradient-to-r ${colors.gradient}`} />
<CardContent className={`p-5 ${colors.bg}`}>
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
{stat.label}
</p>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${colors.iconBg} ${colors.iconText} shadow-md shadow-emerald-200/30 shrink-0`}>
<motion.div
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ duration: 0.15 }}
className={`flex items-center justify-center w-9 h-9 rounded-lg ${colors.iconBg} ${colors.iconText} shadow-md shadow-emerald-200/30 shrink-0`}
>
<stat.icon className="h-4 w-4" />
</div>
</motion.div>
</div>
<div className="mt-2 flex items-baseline gap-1.5">
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none"><CountUp target={stat.value} /></p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none">
<CountUp target={stat.value} />
</p>
{stat.value > 0 && stat.trend === 'up' && (
<div className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-emerald-100/80 dark:bg-emerald-900/30">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, type: 'spring', stiffness: 200 }}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-emerald-100/80 dark:bg-emerald-900/30"
>
<ArrowUpRight className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
<span className="text-[9px] font-bold text-emerald-600 dark:text-emerald-400">{trendPct > 0 ? `+${trendPct}%` : ''}</span>
</div>
</motion.div>
)}
{stat.value === 0 && (
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide">{t('dashboard.start_here')}</span>
@@ -1868,7 +1920,7 @@ export default function DashboardView() {
<CardFooter className="px-5 pb-3 pt-0">
<div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500">
<RefreshCw className="h-2.5 w-2.5" />
<span>{locale === 'de' ? 'Aktualisiert' : 'Updated'} {currentTime}</span>
<span>{t('dashboard.updated_at')} {currentTime}</span>
</div>
</CardFooter>
</Card>
@@ -2040,8 +2092,8 @@ export default function DashboardView() {
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
<Flower2 className="h-4 w-4" />
</div>
{t('teacher.competence_progress')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span>
{t('dashboard.competency_progress')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('dashboard.competency_progress_desc')}</span>
</CardTitle>
</CardHeader>
<CardContent>
@@ -2057,12 +2109,41 @@ export default function DashboardView() {
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
<PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} />
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} />
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" />
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} isAnimationActive animationDuration={1200} animationEasing="ease-out" />
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" isAnimationActive animationDuration={1500} animationEasing="ease-out" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px' }} />
</RadarChart>
</ResponsiveContainer>
{/* Animated progress bars for each subject */}
<div className="mt-4 space-y-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('dashboard.competency_progress')}</h4>
{[
{ subject: t('student.notebook_math'), current: 3.5, target: 4, color: 'from-amber-400 to-orange-500' },
{ subject: t('student.notebook_german'), current: 3.8, target: 4, color: 'from-emerald-400 to-teal-500' },
{ subject: t('student.notebook_english'), current: 2.9, target: 4, color: 'from-rose-400 to-pink-500' },
{ subject: t('student.notebook_science'), current: 3.2, target: 4, color: 'from-violet-400 to-purple-500' },
].map((item) => {
const pct = Math.round((item.current / item.target) * 100);
return (
<div key={item.subject} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.subject}</span>
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400">{item.current}/{item.target}</span>
</div>
<div className="h-2.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 1, ease: 'easeOut', delay: 0.2 }}
className={`h-full rounded-full bg-gradient-to-r ${item.color}`}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
@@ -2128,7 +2209,7 @@ export default function DashboardView() {
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-emerald-500 text-white shadow-sm">
<CalendarDays className="h-4 w-4" />
</div>
{locale === 'de' ? 'Heutige Aufgaben' : 'Today\'s Schedule'}
{t('dashboard.class_overview_grid')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</span>
@@ -2137,8 +2218,8 @@ export default function DashboardView() {
<CardContent>
<div className="space-y-2">
{[
{ icon: PenLine, label: locale === 'de' ? 'Fortschritte erfassen' : 'Log progress entries', desc: locale === 'de' ? 'Neue Beobachtungen eintragen' : 'Record new observations', view: 'progress', color: 'from-emerald-400 to-teal-500' },
{ icon: ClipboardCheck, label: locale === 'de' ? 'Uberpruefung erstellen' : 'Create assessment', desc: locale === 'de' ? 'Leistungsueberpruefung planen' : 'Plan performance assessment', view: 'assessments', color: 'from-amber-400 to-rose-500' },
{ icon: PenLine, label: t('action.log_entry'), desc: t('dashboard.quick_action_assessment_desc'), view: 'progress', color: 'from-emerald-400 to-teal-500' },
{ icon: ClipboardCheck, label: t('action.create_assessment'), desc: t('dashboard.quick_action_assessment_desc'), view: 'assessments', color: 'from-amber-400 to-rose-500' },
{ icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' },
{ icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' },
].map((item, i) => (
@@ -2329,6 +2410,99 @@ export default function DashboardView() {
<div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" />
</motion.div>
{/* ===== CLASS OVERVIEW MINI CARD GRID ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<School className="h-4 w-4" />
</div>
{t('dashboard.class_overview_grid')}
{data.classesOverview.length > 0 && (
<Badge variant="secondary" className="ml-1 text-[10px] bg-emerald-100/80 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">
{data.classesOverview.length}
</Badge>
)}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('classes')}
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 min-h-[44px] px-2"
>
{t('dashboard.view_all')}
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{data.classesOverview.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/20 mx-auto mb-4">
<Target className="h-8 w-8 text-emerald-400 dark:text-emerald-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('dashboard.no_classes')}</p>
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{data.classesOverview.map((cls, idx) => {
const gradientIdx = idx % avatarGradients.length;
const totalStudents = cls.studentCount;
const maxStudents = Math.max(...data.classesOverview.map(c => c.studentCount), 1);
const fillPct = Math.round((totalStudents / maxStudents) * 100);
return (
<motion.div
key={cls.id}
whileHover={{ scale: 1.03, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
className="cursor-pointer"
onClick={() => {
useAppStore.getState().setCurrentClass(cls.id);
setCurrentView('classes');
}}
>
<div className="relative rounded-xl overflow-hidden border border-gray-200/40 dark:border-gray-700/40 bg-white dark:bg-gray-900/50 hover:shadow-md transition-shadow duration-200">
{/* Gradient top strip */}
<div className={`h-1.5 bg-gradient-to-r ${avatarGradients[gradientIdx]}`} />
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br ${avatarGradients[gradientIdx]} text-white text-[10px] font-bold shadow-sm shrink-0`}>
{cls.name.substring(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100 truncate">{cls.name}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('label.grade')} {cls.gradeLevel}</p>
</div>
</div>
{/* Mini student count bar */}
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400">{t('dashboard.total_students')}</span>
<span className="text-[10px] font-semibold text-gray-700 dark:text-gray-300">{cls.studentCount}</span>
</div>
<div className="h-1.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${fillPct}%` }}
transition={{ duration: 0.8, ease: 'easeOut', delay: idx * 0.05 }}
className={`h-full rounded-full bg-gradient-to-r ${avatarGradients[gradientIdx]}`}
/>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* ===== MAIN CONTENT GRID ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Classes overview */}
@@ -2481,16 +2655,19 @@ export default function DashboardView() {
<div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" />
</motion.div>
{/* ===== RECENT ACTIVITY ===== */}
{/* ===== RECENT ACTIVITY TIMELINE ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden transition-shadow duration-200 hover:shadow-md ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<TrendingUp className="h-4 w-4" />
</div>
{t('dashboard.recent_activity')}
{t('dashboard.activity_timeline')}
</CardTitle>
<span className="text-xs text-gray-500 dark:text-gray-400">{t('dashboard.activity_timeline_desc')}</span>
</div>
</CardHeader>
<CardContent>
{data.recentEntries.length === 0 ? (
@@ -2502,18 +2679,27 @@ export default function DashboardView() {
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div>
) : (
<div className="activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => {
<div className="relative activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
{/* Timeline connector line */}
<div className="absolute left-[22px] top-4 bottom-4 w-px bg-gradient-to-b from-teal-200 via-emerald-200 to-transparent dark:from-teal-800 dark:via-emerald-800" />
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry, idx) => {
const catColor = entry.competency.category.color ?? '#10b981';
const studentName = `${entry.student.firstName} ${entry.student.lastName}`;
const gradient = getAvatarGradient(studentName);
return (
<motion.div
key={entry.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden mb-2 ml-6"
>
{/* Timeline dot */}
<div className="absolute -left-6 top-1/2 -translate-y-1/2 flex items-center justify-center">
<div className="w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 shadow-sm" style={{ backgroundColor: catColor }} />
</div>
{/* Left border color indicator */}
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} />
@@ -2670,9 +2856,11 @@ export default function DashboardView() {
{/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Paper Saved Counter */}
{/* Paper Saved Counter — enhanced with CountUp */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
{/* Gradient top border */}
<div className="h-1.5 bg-gradient-to-r from-emerald-400 via-teal-400 to-emerald-500" />
<CardContent className="p-6">
<div className="flex items-center gap-4">
<motion.div
@@ -2685,30 +2873,38 @@ export default function DashboardView() {
</motion.div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{locale === 'de' ? 'Papier gespart' : 'Paper Saved'}
{t('dashboard.paper_saved')}
</h3>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">
{data.stats.totalProgressEntries * 50}
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100">
<CountUp target={data.stats.totalProgressEntries * 50} />
</span>
<span className="text-sm text-emerald-600/70 dark:text-emerald-400/60 font-medium">
{locale === 'de' ? 'Seiten' : 'pages'}
{t('dashboard.paper_saved_pages')}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-2">
{locale === 'de'
? 'Jeder digitale Eintrag spart ca. 50 Seiten Papier. Danke fuer Ihren Beitrag zum Umweltschutz!'
: 'Each digital entry saves ~50 pages of paper. Thank you for contributing to environmental protection!'}
{t('dashboard.env_tip_text')}
</p>
<div className="flex items-center gap-2 mt-3">
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.3 }}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
>
<TreePine className="h-3 w-3" />
{locale === 'de' ? '~0.5 Baume' : '~0.5 trees'}
</div>
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-teal-100/60 dark:bg-teal-900/30 text-[11px] font-semibold text-teal-600 dark:text-teal-400">
~{(data.stats.totalProgressEntries * 50 / 8000).toFixed(1)} {t('dashboard.paper_saved_trees')}
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.7, duration: 0.3 }}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-teal-100/60 dark:bg-teal-900/30 text-[11px] font-semibold text-teal-600 dark:text-teal-400"
>
<Sprout className="h-3 w-3" />
{locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'}
</div>
{(data.stats.totalProgressEntries * 50 * 0.005).toFixed(1)} {t('dashboard.paper_saved_co2')}
</motion.div>
</div>
</div>
</div>
@@ -2751,6 +2947,53 @@ export default function DashboardView() {
</motion.div>
</div>
{/* ===== SCHOOL BRANDING SECTION ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
{/* Gradient top border */}
<div className="h-1.5 bg-gradient-to-r from-emerald-400 via-teal-400 to-violet-400" />
<CardContent className="p-6">
<div className="flex items-center gap-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3, type: 'spring', stiffness: 120 }}
className="flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 shrink-0"
>
<School className="h-7 w-7" />
</motion.div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{t('dashboard.school_branding')}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
{t('dashboard.school_branding_desc')}
</p>
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded-full bg-emerald-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-teal-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-violet-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-amber-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-rose-500 shadow-sm" />
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('app.name')}</span>
</div>
</div>
<div className="hidden sm:flex flex-col items-end gap-1 shrink-0">
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs">
<GraduationCap className="h-3 w-3 mr-1" />
{currentUser?.schoolId ? t('nav.classes') : 'CompetenceTrack'}
</Badge>
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{t('dashboard.total_students')}: {data.stats.totalStudents} · {t('dashboard.total_classes')}: {data.stats.totalClasses}
</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+230
View File
@@ -493,6 +493,22 @@ const de: TranslationDict = {
// New styling keys
'dashboard.start_here': 'Jetzt beginnen!',
'dashboard.greeting_morning': 'Guten Morgen',
'dashboard.greeting_afternoon': 'Guten Tag',
'dashboard.greeting_evening': 'Guten Abend',
'dashboard.school_branding': 'Schulprofil',
'dashboard.school_branding_desc': 'Ihre Schule im Überblick',
'dashboard.competency_progress': 'Kompetenzfortschritt',
'dashboard.competency_progress_desc': 'Aktuelle Entwicklung der Kompetenzniveaus',
'dashboard.paper_saved_pages': 'Seiten',
'dashboard.paper_saved_trees': 'Baeume gerettet',
'dashboard.paper_saved_co2': 'kg CO2 eingespart',
'dashboard.daily_tip': 'Tipp des Tages',
'dashboard.class_overview_grid': 'Klassenuebersicht',
'dashboard.class_overview_grid_desc': 'Schnellzugriff auf alle Klassen',
'dashboard.activity_timeline': 'Aktivitaetenverlauf',
'dashboard.activity_timeline_desc': 'Neueste Eintraege im Zeitverlauf',
'dashboard.updated_at': 'Aktualisiert',
'date.today': 'Heute',
'date.yesterday': 'Gestern',
'date.days_ago': '{count} Tage zuvor',
@@ -511,6 +527,7 @@ const de: TranslationDict = {
'header.notifications': 'Aktivitäten',
'auth.feature_reports': 'Berichte & Zeugnisse',
'auth.feature_flower': 'Kompetenzblume',
'auth.feature_data_security': 'Datenschutz & Sicherheit',
'flower.hide_average': 'Vergleich aus',
'flower.show_average': 'Vergleich an',
'flower.download_png': 'Als PNG',
@@ -2665,6 +2682,41 @@ const de: TranslationDict = {
'district.total_students': 'Gesamtschüler',
'district.total_classes': 'Gesamtklassen',
'district.total_teachers': 'Gesamtlehrer',
'district.management': 'Bezirksverwaltung',
'district.overview': 'Übersicht',
'district.teacher_count': 'Anzahl Lehrer',
'district.add_district': 'Bezirk hinzufügen',
'district.edit_district': 'Bezirk bearbeiten',
'district.delete_district': 'Bezirk löschen',
'district.no_schools': 'Keine Schulen vorhanden',
'district.remove_school': 'Schule entfernen',
'district.performance_comparison': 'Leistungsvergleich',
'district.recent_activity': 'Letzte Aktivität',
'district.active': 'Aktiv',
'district.inactive': 'Inaktiv',
'district.details': 'Details',
'district.save_success': 'Bezirk erfolgreich gespeichert',
'district.delete_success': 'Bezirk erfolgreich gelöscht',
'district.school_assigned': 'Schule erfolgreich zugeordnet',
'district.school_removed': 'Schule erfolgreich entfernt',
'district.unassigned_schools': 'Nicht zugeordnete Schulen',
'district.no_unassigned_schools': 'Alle Schulen sind zugeordnet',
'district.all_schools': 'Alle Schulen',
'district.search_districts': 'Bezirke durchsuchen',
'district.overview_stats': 'Bezirksstatistik',
'district.avg_performance': 'Durchschnittliche Leistung',
'district.school_type': 'Schultyp',
'district.select_district': 'Bezirk auswählen',
'district.district_info': 'Bezirksinformationen',
'district.activity_created': 'Bezirk erstellt',
'district.activity_updated': 'Bezirk aktualisiert',
'district.activity_school_added': 'Schule hinzugefügt',
'district.activity_school_removed': 'Schule entfernt',
'district.performance_score': 'Leistungswert',
'district.attendance_rate': 'Anwesenheitsrate',
'district.student_teacher_ratio': 'Schüler-Lehrer-Verhältnis',
'nav.districts': 'Bezirksverwaltung',
'nav.student-portal': 'Mein Portal',
// PWA / Offline
'pwa.offline': 'Sie sind offline',
@@ -3831,6 +3883,69 @@ const de: TranslationDict = {
'admin.health_good': 'Gut',
'admin.health_warning': 'Warnung',
'admin.health_critical': 'Kritisch',
// Student Portal
'student_portal.title': 'Mein Portal',
'student_portal.my_competencies': 'Meine Kompetenzen',
'student_portal.my_goals': 'Meine Ziele',
'student_portal.my_achievements': 'Meine Erfolge',
'student_portal.my_schedule': 'Mein Stundenplan',
'student_portal.my_homework': 'Meine Hausaufgaben',
'student_portal.my_feedback': 'Mein Feedback',
'student_portal.goal_set': 'Ziel setzen',
'student_portal.goal_progress': 'Zielfortschritt',
'student_portal.goal_complete': 'Ziel abschliessen',
'student_portal.no_competencies': 'Noch keine Kompetenzdaten vorhanden',
'student_portal.no_goals': 'Noch keine Ziele gesetzt',
'student_portal.no_achievements': 'Noch keine Erfolge freigeschaltet',
'student_portal.points_earned': 'Punkte gesammelt',
'student_portal.badges_earned': 'Abzeichen verdient',
'student_portal.rewards_available': 'Verfuegbare Belohnungen',
'student_portal.today_schedule': 'Heutiger Stundenplan',
'student_portal.next_period': 'Naechste Stunde',
'student_portal.current_period': 'Aktuelle Stunde',
'student_portal.overdue': 'Ueberfaellig',
'student_portal.due_today': 'Heute faellig',
'student_portal.upcoming': 'Anstehend',
'student_portal.mastery_level': 'Kompetenzniveau',
'student_portal.level_1': 'Anfaenger',
'student_portal.level_2': 'Fortgeschritten',
'student_portal.level_3': 'Kompetent',
'student_portal.level_4': 'Experte',
'student_portal.add_goal': 'Neues Ziel',
'student_portal.goal_title': 'Zieltitel',
'student_portal.goal_description': 'Zielbeschreibung',
'student_portal.target_date': 'Zieldatum',
'student_portal.active_goals': 'Aktive Ziele',
'student_portal.completed_goals': 'Abgeschlossene Ziele',
'student_portal.abandon_goal': 'Ziel aufgeben',
'student_portal.update_progress': 'Fortschritt aktualisieren',
'student_portal.badge_name': 'Abzeichen',
'student_portal.earned_on': 'Verdient am',
'student_portal.no_schedule': 'Kein Stundenplan fuer heute',
'student_portal.period': 'Stunde',
'student_portal.break': 'Pause',
'student_portal.no_homework_due': 'Keine Hausaufgaben faellig',
'student_portal.no_feedback': 'Noch kein Feedback vorhanden',
'student_portal.recent_feedback': 'Letztes Feedback',
'student_portal.from_teacher': 'Von',
'student_portal.subject': 'Fach',
'student_portal.competency_card': 'Kompetenzkarte',
'student_portal.progress_bar': 'Fortschrittsbalken',
'student_portal.total_points': 'Gesamtpunkte',
'student_portal.level': 'Stufe',
'student_portal.no_class': 'Keine Klasse',
'student_portal.schedule_for': 'Stundenplan fuer',
'student_portal.good_morning': 'Guten Morgen',
'student_portal.good_afternoon': 'Guten Tag',
'student_portal.good_evening': 'Guten Abend',
'student_portal.hero_message': 'Weiter so! Jeder Schritt bringt dich naeher an dein Ziel.',
'student_portal.streak_days': 'Tage Strich',
'student_portal.days': 'Tage',
'student_portal.learning_streak': 'Lernstrich',
'student_portal.streak_message': 'Tolle Serie! Halte durch, um deine Tagesreihe zu verlaengern.',
'student_portal.level_up_progress': 'Aufstiegsfortschritt',
'student_portal.level_up_detail': 'Aktuelle Stufe {level} von {max}',
};
const en: TranslationDict = {
@@ -4323,6 +4438,22 @@ const en: TranslationDict = {
// New styling keys
'dashboard.start_here': 'Start here!',
'dashboard.greeting_morning': 'Good morning',
'dashboard.greeting_afternoon': 'Good afternoon',
'dashboard.greeting_evening': 'Good evening',
'dashboard.school_branding': 'School Profile',
'dashboard.school_branding_desc': 'Your school at a glance',
'dashboard.competency_progress': 'Competency Progress',
'dashboard.competency_progress_desc': 'Current development of competency levels',
'dashboard.paper_saved_pages': 'pages',
'dashboard.paper_saved_trees': 'trees saved',
'dashboard.paper_saved_co2': 'kg CO2 saved',
'dashboard.daily_tip': 'Tip of the day',
'dashboard.class_overview_grid': 'Class Overview',
'dashboard.class_overview_grid_desc': 'Quick access to all classes',
'dashboard.activity_timeline': 'Activity Timeline',
'dashboard.activity_timeline_desc': 'Latest entries over time',
'dashboard.updated_at': 'Updated',
'date.today': 'Today',
'date.yesterday': 'Yesterday',
'date.days_ago': '{count} days ago',
@@ -4341,6 +4472,7 @@ const en: TranslationDict = {
'header.notifications': 'Activity',
'auth.feature_reports': 'Reports & Transcripts',
'auth.feature_flower': 'Competence Flower',
'auth.feature_data_security': 'Data Security & Privacy',
'flower.hide_average': 'Hide Average',
'flower.show_average': 'Show Average',
'flower.download_png': 'As PNG',
@@ -6494,6 +6626,41 @@ const en: TranslationDict = {
'district.total_students': 'Total Students',
'district.total_classes': 'Total Classes',
'district.total_teachers': 'Total Teachers',
'district.management': 'District Management',
'district.overview': 'Overview',
'district.teacher_count': 'Teacher Count',
'district.add_district': 'Add District',
'district.edit_district': 'Edit District',
'district.delete_district': 'Delete District',
'district.no_schools': 'No schools found',
'district.remove_school': 'Remove School',
'district.performance_comparison': 'Performance Comparison',
'district.recent_activity': 'Recent Activity',
'district.active': 'Active',
'district.inactive': 'Inactive',
'district.details': 'Details',
'district.save_success': 'District saved successfully',
'district.delete_success': 'District deleted successfully',
'district.school_assigned': 'School assigned successfully',
'district.school_removed': 'School removed successfully',
'district.unassigned_schools': 'Unassigned Schools',
'district.no_unassigned_schools': 'All schools are assigned',
'district.all_schools': 'All Schools',
'district.search_districts': 'Search districts',
'district.overview_stats': 'District Statistics',
'district.avg_performance': 'Average Performance',
'district.school_type': 'School Type',
'district.select_district': 'Select District',
'district.district_info': 'District Information',
'district.activity_created': 'District created',
'district.activity_updated': 'District updated',
'district.activity_school_added': 'School added',
'district.activity_school_removed': 'School removed',
'district.performance_score': 'Performance Score',
'district.attendance_rate': 'Attendance Rate',
'district.student_teacher_ratio': 'Student-Teacher Ratio',
'nav.districts': 'District Management',
'nav.student-portal': 'My Portal',
// PWA / Offline
'pwa.offline': 'You are offline',
@@ -7660,6 +7827,69 @@ const en: TranslationDict = {
'admin.health_good': 'Good',
'admin.health_warning': 'Warning',
'admin.health_critical': 'Critical',
// Student Portal
'student_portal.title': 'My Portal',
'student_portal.my_competencies': 'My Competencies',
'student_portal.my_goals': 'My Goals',
'student_portal.my_achievements': 'My Achievements',
'student_portal.my_schedule': 'My Schedule',
'student_portal.my_homework': 'My Homework',
'student_portal.my_feedback': 'My Feedback',
'student_portal.goal_set': 'Set Goal',
'student_portal.goal_progress': 'Goal Progress',
'student_portal.goal_complete': 'Complete Goal',
'student_portal.no_competencies': 'No competency data yet',
'student_portal.no_goals': 'No goals set yet',
'student_portal.no_achievements': 'No achievements unlocked yet',
'student_portal.points_earned': 'Points Earned',
'student_portal.badges_earned': 'Badges Earned',
'student_portal.rewards_available': 'Rewards Available',
'student_portal.today_schedule': 'Today\'s Schedule',
'student_portal.next_period': 'Next Period',
'student_portal.current_period': 'Current Period',
'student_portal.overdue': 'Overdue',
'student_portal.due_today': 'Due Today',
'student_portal.upcoming': 'Upcoming',
'student_portal.mastery_level': 'Mastery Level',
'student_portal.level_1': 'Beginner',
'student_portal.level_2': 'Developing',
'student_portal.level_3': 'Competent',
'student_portal.level_4': 'Expert',
'student_portal.add_goal': 'New Goal',
'student_portal.goal_title': 'Goal Title',
'student_portal.goal_description': 'Goal Description',
'student_portal.target_date': 'Target Date',
'student_portal.active_goals': 'Active Goals',
'student_portal.completed_goals': 'Completed Goals',
'student_portal.abandon_goal': 'Abandon Goal',
'student_portal.update_progress': 'Update Progress',
'student_portal.badge_name': 'Badge',
'student_portal.earned_on': 'Earned on',
'student_portal.no_schedule': 'No schedule for today',
'student_portal.period': 'Period',
'student_portal.break': 'Break',
'student_portal.no_homework_due': 'No homework due',
'student_portal.no_feedback': 'No feedback yet',
'student_portal.recent_feedback': 'Recent Feedback',
'student_portal.from_teacher': 'From',
'student_portal.subject': 'Subject',
'student_portal.competency_card': 'Competency Card',
'student_portal.progress_bar': 'Progress Bar',
'student_portal.total_points': 'Total Points',
'student_portal.level': 'Level',
'student_portal.no_class': 'No Class',
'student_portal.schedule_for': 'Schedule for',
'student_portal.good_morning': 'Good Morning',
'student_portal.good_afternoon': 'Good Afternoon',
'student_portal.good_evening': 'Good Evening',
'student_portal.hero_message': 'Keep going! Every step brings you closer to your goal.',
'student_portal.streak_days': 'Day Streak',
'student_portal.days': 'days',
'student_portal.learning_streak': 'Learning Streak',
'student_portal.streak_message': 'Great streak! Keep going to extend your daily streak.',
'student_portal.level_up_progress': 'Level Up Progress',
'student_portal.level_up_detail': 'Current level {level} of {max}',
};
const translations: Record<string, TranslationDict> = { de, en };
+3 -1
View File
@@ -29,7 +29,9 @@ export type ViewName =
| 'portfolio'
| 'timetable'
| 'resources'
| 'competitions';
| 'competitions'
| 'districts'
| 'student-portal';
export interface CurrentUser {
id: string;
+174 -2461
View File
File diff suppressed because it is too large. Load diff