1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.
CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.
HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.
TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".
NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.
Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
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 { useConfirm } from '../components/Confirm'
|
|
import { Icon } from '../components/Icon'
|
|
import { BrandMark } from '../components/BrandMark'
|
|
import { formatWithZone } from '../lib/dates'
|
|
import { CourseCover, ProgressBar } from './AcademyBits'
|
|
import { useStudentAuth } from './StudentAuthContext'
|
|
|
|
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()
|
|
// po wylogowaniu na strone glowna, nie na formularz logowania
|
|
navigate('/', { 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 confirm = useConfirm()
|
|
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={async () => {
|
|
const ok = await confirm({
|
|
title: 'Wypisać się z komunikacji marketingowej?',
|
|
message:
|
|
'Przestaniesz dostawać zaproszenia na nowe szkolenia. Powiadomienia o kursach i webinarach, na które jesteś już zapisany, przychodzą dalej.',
|
|
confirmLabel: 'Wypisz się',
|
|
})
|
|
if (ok) 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>
|
|
)
|
|
}
|