Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -0,0 +1,601 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import type { MyCourse, StudentDashboard, StudentParticipation } from '../api/types'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Spinner,
} from '../components/ui'
import { Icon } from '../components/Icon'
import { BrandMark } from '../components/BrandMark'
import { formatWithZone } from '../lib/dates'
import { CourseCover, ProgressBar } from './AcademyBits'
import { useStudentAuth } from './StudentAuthContext'
import { STUDENT_ROUTES } from '../lib/routes'
type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane'
/**
* Pulpit kursanta.
*
* Zakladki przelaczaja widocznosc, nie trasy: dane pobierane sa raz, jednym
* zapytaniem, bo i tak pokazujemy je wszystkie naraz.
*/
export function StudentDashboardPage() {
const { student, logout } = useStudentAuth()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('kursy')
const dashboard = useQuery({
queryKey: ['academy', 'dashboard'],
queryFn: async () => (await academyClient.get<StudentDashboard>('/academy/dashboard')).data,
})
const handleLogout = () => {
logout()
navigate(STUDENT_ROUTES.login, { replace: true })
}
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<header className="border-b border-slate-100 bg-white">
<div className="mx-auto flex w-full max-w-container flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<BrandMark />
<div className="flex items-center gap-3">
<span className="hidden text-sm text-slate-600 sm:inline">
{student?.firstName} {student?.lastName}
</span>
<Button variant="ghost" icon="log-out" onClick={handleLogout}>
Wyloguj
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-container flex-1 px-4 py-6 sm:px-6">
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Moje konto</h1>
<p className="text-sm text-slate-500">
Twoje kursy, webinary, certyfikaty i dane w jednym miejscu.
</p>
</div>
{student && !student.emailVerified && <VerifyNotice />}
<nav
role="tablist"
aria-label="Sekcje konta"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{(
[
['kursy', 'Moje kursy', 'graduation-cap'],
['webinary', 'Moje webinary', 'presentation'],
['certyfikaty', 'Certyfikaty', 'file-text'],
['dane', 'Moje dane i zgody', 'users'],
] as const
).map(([value, label, icon]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
</button>
))}
</nav>
{dashboard.isPending && <Spinner />}
{dashboard.isError && (
<ErrorState message={academyErrorMessage(dashboard.error, 'Nie udało się wczytać danych')} />
)}
{dashboard.data && (
<>
{tab === 'kursy' && <CoursesTab items={dashboard.data.courses} />}
{tab === 'webinary' && <WebinarsTab items={dashboard.data.webinars} />}
{tab === 'certyfikaty' && <CertificatesTab items={dashboard.data.certificates} />}
{tab === 'dane' && <ProfileTab data={dashboard.data} />}
</>
)}
</div>
</main>
</div>
)
}
/**
* Kursy kursanta: w trakcie i ukonczone.
*
* Przycisk prowadzi do lekcji, na ktorej kursant skonczyl, a nie na poczatek
* kursu: wracajac po tygodniu nie musi szukac, gdzie przerwal.
*/
function CoursesTab({ items }: { items: MyCourse[] }) {
if (items.length === 0) {
return (
<EmptyState
icon="graduation-cap"
title="Nie masz jeszcze żadnych kursów"
description="Wybierz kurs z oferty i zacznij naukę."
/>
)
}
const inProgress = items.filter((c) => c.status !== 'COMPLETED')
const completed = items.filter((c) => c.status === 'COMPLETED')
return (
<div className="space-y-8">
{inProgress.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-900">W trakcie</h2>
<div className="mt-3 space-y-3">
{inProgress.map((c) => (
<MyCourseRow key={c.courseId} course={c} />
))}
</div>
</section>
)}
{completed.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-900">Ukończone</h2>
<div className="mt-3 space-y-3">
{completed.map((c) => (
<MyCourseRow key={c.courseId} course={c} />
))}
</div>
</section>
)}
</div>
)
}
function MyCourseRow({ course }: { course: MyCourse }) {
const done = course.status === 'COMPLETED'
return (
<article className="flex flex-col gap-4 rounded-lg border border-slate-100 bg-white p-4 shadow-sm sm:flex-row sm:items-center">
<CourseCover
mediaId={course.coverMediaId}
title={course.title}
className="h-24 w-full shrink-0 rounded-md sm:h-16 sm:w-24"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900">{course.title}</h3>
{done && <Badge tone="ok">Ukończony</Badge>}
{/* Dostep wygasly pokazujemy wprost: inaczej kursant klika przycisk
i dostaje blad, nie wiedzac dlaczego. */}
{!course.accessActive && <Badge tone="warn">Dostęp wygasł</Badge>}
</div>
<div className="mt-2.5 max-w-md">
<ProgressBar
percent={course.progressPercent}
label={`Ukończono ${course.completedRequired} z ${course.totalRequired}`}
/>
</div>
{!done && course.resumeLessonTitle && (
<p className="mt-2 truncate text-xs text-slate-500">
Następna lekcja: {course.resumeLessonTitle}
</p>
)}
</div>
<div className="shrink-0">
{course.accessActive ? (
<Link
to={
course.resumeLessonId
? `/kurs/${course.slug}/nauka?lekcja=${course.resumeLessonId}`
: `/kurs/${course.slug}/nauka`
}
>
<Button size="sm" icon={done ? 'book-open' : 'play'}>
{done ? 'Otwórz kurs' : 'Kontynuuj'}
</Button>
</Link>
) : (
<Link to={`/kurs/${course.slug}`}>
<Button size="sm" variant="secondary">
Szczegóły
</Button>
</Link>
)}
</div>
</article>
)
}
/**
* Przypomnienie o niepotwierdzonym adresie.
*
* Logowanie dziala bez potwierdzenia, ale dostep do kursow nie, wiec kursant
* musi wiedziec, czego mu brakuje i jak to naprawic.
*/
function VerifyNotice() {
const [sent, setSent] = useState(false)
const resend = useMutation({
mutationFn: () => academyClient.post('/academy/resend-verification'),
onSuccess: () => setSent(true),
})
return (
<Alert tone="warn">
<p className="font-medium">Adres e-mail nie został jeszcze potwierdzony.</p>
<p className="mt-1">
Bez potwierdzenia nie masz dostępu do materiałów kursów.{' '}
{sent ? (
<strong>Wysłaliśmy nową wiadomość, sprawdź skrzynkę.</strong>
) : (
<button
type="button"
onClick={() => resend.mutate()}
disabled={resend.isPending}
className="font-medium underline underline-offset-2"
>
{resend.isPending ? 'Wysyłanie...' : 'Wyślij link ponownie'}
</button>
)}
</p>
</Alert>
)
}
function WebinarsTab({ items }: { items: StudentParticipation[] }) {
if (items.length === 0) {
return (
<EmptyState
icon="presentation"
title="Nie masz jeszcze żadnych webinarów"
description="Gdy zapiszesz się na webinar tym samym adresem e-mail, pojawi się tutaj."
/>
)
}
const upcoming = items.filter((i) => i.upcoming)
const past = items.filter((i) => !i.upcoming)
return (
<div className="space-y-4">
{upcoming.length > 0 && (
<Card title="Nadchodzące">
<ul className="space-y-3">
{upcoming.map((item) => (
<WebinarRow key={item.webinarId} item={item} />
))}
</ul>
</Card>
)}
{past.length > 0 && (
<Card title="Zakończone">
<ul className="space-y-3">
{past.map((item) => (
<WebinarRow key={item.webinarId} item={item} />
))}
</ul>
</Card>
)}
</div>
)
}
function WebinarRow({ item }: { item: StudentParticipation }) {
const cancelled = item.status === 'CANCELLED'
return (
<li className="rounded-md border border-slate-100 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{item.title}</p>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{formatWithZone(item.startsAt, item.timezone)}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{cancelled && <Badge tone="neutral">rezygnacja</Badge>}
{!cancelled && item.attended && <Badge tone="ok">obecność</Badge>}
{!cancelled && item.upcoming && <Badge tone="info">zapisany</Badge>}
</div>
</div>
{/*
Link wejscia jest ten sam, ktory kursant dostal w mailu. Konto to
wygoda, a nie nowy warunek dostepu: wejscie tokenem dziala dalej
takze dla osob bez konta.
*/}
{item.accessToken && (
<div className="mt-2 flex flex-wrap gap-3 text-sm">
{item.upcoming && (
<a
href={`/spotkanie/${item.accessToken}`}
className="text-blue-600 hover:text-blue-700"
>
Wejdź na webinar
</a>
)}
<Link to={`/zapis/${item.accessToken}`} className="text-blue-600 hover:text-blue-700">
Szczegóły zapisu
</Link>
{item.hasRecording && (
<span className="inline-flex items-center gap-1 text-slate-500">
<Icon name="video" size={14} /> nagranie dostępne
</span>
)}
{item.materialCount > 0 && (
<span className="inline-flex items-center gap-1 text-slate-500">
<Icon name="file-text" size={14} /> materiały: {item.materialCount}
</span>
)}
</div>
)}
</li>
)
}
function CertificatesTab({ items }: { items: StudentDashboard['certificates'] }) {
if (items.length === 0) {
return (
<EmptyState
icon="award"
title="Nie masz jeszcze certyfikatów"
description="Certyfikat pojawi się tutaj po ukończeniu kursu albo po webinarze, na którym odnotowano Twoją obecność."
/>
)
}
return (
<Card title="Moje certyfikaty">
{/*
Jedna lista dla kursow i webinarow. Kursant nie mysli kategoriami
"to bylo wydarzenie, a to kurs": ma jedno miejsce ze wszystkimi swoimi
dokumentami. Zrodlo rozroznia odznaka.
*/}
<ul className="space-y-3">
{items.map((item) => (
<li
key={item.id}
className="flex flex-wrap items-start justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-slate-900">{item.subjectTitle}</p>
<Badge tone={item.forCourse ? 'brand' : 'info'}>
{item.forCourse ? 'kurs' : 'webinar'}
</Badge>
</div>
<p className="hx-data mt-0.5 text-xs text-slate-500">
nr {item.number} · {formatWithZone(item.issuedAt, 'Europe/Warsaw')}
</p>
</div>
<div className="flex items-center gap-3">
{item.revoked ? (
<Badge tone="alarm">unieważniony</Badge>
) : (
<>
{/*
Adres pobrania sklada backend, bo tylko on zna token zapisu.
Wczesniej link byl budowany tutaj, bez tokenu, i pobranie
konczylo sie 404 mimo zalogowania.
*/}
{item.downloadUrl && (
<a
href={item.downloadUrl}
className="text-sm text-blue-600 hover:text-blue-700"
>
Pobierz PDF
</a>
)}
<a
href={`/certyfikat/${item.number}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500 hover:text-slate-700"
>
Weryfikacja
</a>
</>
)}
</div>
</li>
))}
</ul>
</Card>
)
}
function ProfileTab({ data }: { data: StudentDashboard }) {
const queryClient = useQueryClient()
const { refresh } = useStudentAuth()
const [form, setForm] = useState({
firstName: data.student.firstName,
lastName: data.student.lastName,
phone: data.student.phone ?? '',
company: data.student.company ?? '',
})
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const marketingEmail = data.consents.find((c) => c.type === 'MARKETING_EMAIL')?.granted ?? false
const marketingSms = data.consents.find((c) => c.type === 'MARKETING_SMS')?.granted ?? false
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['academy', 'dashboard'] })
const saveProfile = useMutation({
mutationFn: () => academyClient.put('/academy/me', form),
onSuccess: async () => {
setSaved(true)
setError(null)
await refresh()
invalidate()
},
onError: (err) => setError(academyErrorMessage(err, 'Nie udało się zapisać zmian')),
})
const saveConsents = useMutation({
mutationFn: (next: { marketingEmail: boolean; marketingSms: boolean }) =>
academyClient.put('/academy/consents', next),
onSuccess: invalidate,
})
const unsubscribe = useMutation({
mutationFn: () => academyClient.post('/academy/unsubscribe'),
onSuccess: invalidate,
})
return (
<div className="space-y-4">
<Card title="Moje dane">
<form
onSubmit={(e) => {
e.preventDefault()
setSaved(false)
saveProfile.mutate()
}}
noValidate
className="space-y-4"
>
{error && <Alert tone="alarm">{error}</Alert>}
{saved && <Alert tone="ok">Zmiany zostały zapisane.</Alert>}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Imię">
<Input
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
required
/>
</Field>
<Field label="Nazwisko">
<Input
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
required
/>
</Field>
</div>
<Field label="Adres e-mail" hint="Adresu nie da się zmienić samodzielnie: jest tożsamością konta.">
<Input value={data.student.email} disabled />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Telefon">
<Input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</Field>
<Field label="Firma">
<Input
value={form.company}
onChange={(e) => setForm({ ...form, company: e.target.value })}
/>
</Field>
</div>
<Button type="submit" disabled={saveProfile.isPending} icon="save">
{saveProfile.isPending ? 'Zapisywanie...' : 'Zapisz zmiany'}
</Button>
</form>
</Card>
<Card
title="Zgody"
description="Zmiana zapisuje się od razu. Historia zgód jest zachowywana."
>
<div className="space-y-2">
<ConsentToggle
label="Informacje handlowe drogą elektroniczną (e-mail)"
checked={marketingEmail}
disabled={saveConsents.isPending}
onChange={(v) => saveConsents.mutate({ marketingEmail: v, marketingSms })}
/>
<ConsentToggle
label="Informacje handlowe w formie wiadomości SMS"
checked={marketingSms}
disabled={saveConsents.isPending}
onChange={(v) => saveConsents.mutate({ marketingEmail, marketingSms: v })}
/>
</div>
<div className="mt-4 border-t border-slate-100 pt-4">
{data.student.unsubscribed ? (
<Alert tone="info">
Jesteś wypisany z komunikacji marketingowej. Nadal otrzymujesz wiadomości dotyczące
webinarów, na które się zapisałeś: potwierdzenia, przypomnienia i podsumowania.
</Alert>
) : (
<>
<p className="mb-2 text-sm text-slate-600">
Możesz wypisać się ze wszystkich wiadomości marketingowych. Powiadomienia
o webinarach, na które się zapisałeś, będą wysyłane dalej.
</p>
<Button
variant="secondary"
icon="user-off"
disabled={unsubscribe.isPending}
onClick={() => {
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) {
unsubscribe.mutate()
}
}}
>
{unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'}
</Button>
</>
)}
</div>
</Card>
</div>
)
}
function ConsentToggle({
label,
checked,
onChange,
disabled,
}: {
label: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">{label}</span>
</label>
)
}