Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user