Potwierdzenia operacji jako okno panelu, nie okno przegladarki
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>
This commit is contained in:
2026-09-04 14:13:16 +02:00
parent b96cf8d07d
commit 1599c7b0c1
27 changed files with 723 additions and 245 deletions
+10 -4
View File
@@ -14,6 +14,7 @@ import {
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'
@@ -428,6 +429,7 @@ function CertificatesTab({ items }: { items: StudentDashboard['certificates'] })
function ProfileTab({ data }: { data: StudentDashboard }) {
const confirm = useConfirm()
const queryClient = useQueryClient()
const { refresh } = useStudentAuth()
const [form, setForm] = useState({
@@ -559,10 +561,14 @@ function ProfileTab({ data }: { data: StudentDashboard }) {
variant="secondary"
icon="user-off"
disabled={unsubscribe.isPending}
onClick={() => {
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) {
unsubscribe.mutate()
}
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'}
+123
View File
@@ -0,0 +1,123 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { Button } from './ui'
/**
* Potwierdzenie operacji, jako okno panelu.
*
* DLACZEGO NIE `window.confirm`
* ------------------------------------------------------------------------
* Natywne okno przegladarki 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, wyglada to jak blad strony, a nie jak pytanie
* aplikacji. Nie da sie tam takze wyroznic operacji nieodwracalnej ani
* pokazac, co konkretnie przepadnie.
*
* DLACZEGO HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
* ------------------------------------------------------------------------
* Potwierdzen jest w panelu ponad czterdziesci. Budowanie do kazdego wlasnego
* stanu i wlasnego `Modal` oznaczaloby kilkanascie linii na jedno pytanie
* i te sama pewnosc, ze predzej czy pozniej ktores bedzie wygladalo inaczej.
* Hook oddaje funkcje o tym samym ksztalcie co `confirm`, wiec zamiana jest
* jednolinijkowa:
*
* if (await confirm({ title: 'Usunac plik?' })) remove.mutate(id)
*
* OBIETNICA ZAMIAST WARTOSCI
* ------------------------------------------------------------------------
* `window.confirm` zatrzymuje watek i oddaje odpowiedz od razu; tutaj okno
* musi sie wyrenderowac, wiec wynik przychodzi obietnica. Referencja na
* `resolve` trzymana jest poza stanem Reacta: to nie jest dana do wyswietlenia,
* a jej zmiana nie ma przerysowywac okna.
*/
type ConfirmOptions = {
title: string
/** Zdanie pod tytulem. Warto powiedziec, co dokladnie przepadnie. */
message?: ReactNode
/** Etykieta przycisku potwierdzenia. Nazwij czynnosc, nie mow "OK". */
confirmLabel?: string
cancelLabel?: string
/**
* Operacja nieodwracalna: przycisk jest czerwony, a nad trescia pojawia sie
* ostrzezenie. Uzywac tam, gdzie po kliknieciu nie da sie nic odzyskac.
*/
destructive?: boolean
}
type ConfirmApi = (options: ConfirmOptions) => Promise<boolean>
const ConfirmContext = createContext<ConfirmApi | null>(null)
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ConfirmOptions | null>(null)
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback<ConfirmApi>((options) => {
setPending(options)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}, [])
const settle = useCallback((value: boolean) => {
// najpierw odpowiedz, potem zamkniecie: kolejnosc odwrotna zostawialaby
// obietnice nierozstrzygnieta, gdyby renderowanie sie przerwalo
resolveRef.current?.(value)
resolveRef.current = null
setPending(null)
}, [])
const api = useMemo(() => confirm, [confirm])
return (
<ConfirmContext.Provider value={api}>
{children}
{pending && (
<Modal
title={pending.title}
onClose={() => settle(false)}
footer={
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => settle(false)}>
{pending.cancelLabel ?? 'Anuluj'}
</Button>
<Button
type="button"
variant={pending.destructive ? 'danger' : 'primary'}
onClick={() => settle(true)}
>
{pending.confirmLabel ?? 'Potwierdź'}
</Button>
</div>
}
>
<div className="space-y-3">
{pending.destructive && (
<div className="flex items-start gap-3 rounded-md border border-alarm-500/30 bg-alarm-50 p-3">
<Icon name="alert-triangle" size={20} className="mt-0.5 shrink-0 text-alarm-600" />
<p className="text-sm leading-relaxed text-alarm-600">
Tej operacji nie da się cofnąć.
</p>
</div>
)}
{pending.message && (
<div className="text-sm leading-relaxed text-slate-600">{pending.message}</div>
)}
</div>
</Modal>
)}
</ConfirmContext.Provider>
)
}
export function useConfirm(): ConfirmApi {
const api = useContext(ConfirmContext)
if (!api) {
throw new Error('useConfirm musi byc uzyte wewnatrz ConfirmProvider')
}
return api
}
+15 -4
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../api/client'
import type { MediaFile, Page } from '../api/types'
import { useConfirm } from './Confirm'
import { Icon } from './Icon'
import { useToasts } from './Toasts'
import { Button, ErrorState, Spinner } from './ui'
@@ -25,6 +26,7 @@ export function MediaPicker({
onClose: () => void
}) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
@@ -137,10 +139,19 @@ export function MediaPicker({
<button
type="button"
aria-label={`Usuń plik ${file.originalName}`}
onClick={() => {
if (window.confirm(`Usunąć plik "${file.originalName}"?`)) {
remove.mutate(file.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć plik?',
message: (
<>
Plik <strong>{file.originalName}</strong> zniknie z biblioteki
mediów. Miejsca, w których jest już osadzony, przestaną go pokazywać.
</>
),
confirmLabel: 'Usuń plik',
destructive: true,
})
if (ok) remove.mutate(file.id)
}}
className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100"
>
+8
View File
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
import { App } from './App'
import { AuthProvider } from './auth/AuthContext'
import { StudentAuthProvider } from './academy/StudentAuthContext'
import { ConfirmProvider } from './components/Confirm'
import { ToastProvider } from './components/Toasts'
import './index.css'
@@ -23,6 +24,12 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
{/*
Potwierdzenia operacji jako okno panelu, nie `window.confirm`:
natywne okno wyglada inaczej w kazdej przegladarce, wyskakuje przy
gornej krawedzi i pokazuje nad trescia adres domeny.
*/}
<ConfirmProvider>
{/*
Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem
i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej
@@ -33,6 +40,7 @@ createRoot(document.getElementById('root')!).render(
<App />
</StudentAuthProvider>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
+55 -25
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import {
Alert,
@@ -42,6 +43,7 @@ type ApiKeyCreated = {
*/
export function ApiKeyListPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [creating, setCreating] = useState(false)
@@ -159,7 +161,7 @@ export function ApiKeyListPage() {
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
odczytać. Gdy zostanie zgubiony, użyj{' '}
<span className="whitespace-nowrap">„wygeneruj ponownie”</span>
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać.
{' '}(ikona odświeżania) - powstaje nowy klucz, a stary przestaje działać.
</Alert>
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
@@ -261,14 +263,19 @@ export function ApiKeyListPage() {
<IconButton
icon="refresh"
label="Wygeneruj ponownie"
onClick={() => {
if (
window.confirm(
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
)
) {
regenerate.mutate(key.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Wygenerować nowy klucz?',
message: (
<>
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
</>
),
confirmLabel: 'Wygeneruj nowy',
destructive: true,
})
if (ok) regenerate.mutate(key.id)
}}
disabled={regenerate.isPending}
/>
@@ -281,10 +288,19 @@ export function ApiKeyListPage() {
<IconButton
icon="trash"
label="Usuń"
onClick={() => {
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
remove.mutate(key.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć klucz?',
message: (
<>
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
straci dostęp do publicznego API.
</>
),
confirmLabel: 'Usuń klucz',
destructive: true,
})
if (ok) remove.mutate(key.id)
}}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
@@ -329,14 +345,19 @@ export function ApiKeyListPage() {
<Button
variant="ghost"
icon="refresh"
onClick={() => {
if (
window.confirm(
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
)
) {
regenerate.mutate(key.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Wygenerować nowy klucz?',
message: (
<>
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
</>
),
confirmLabel: 'Wygeneruj nowy',
destructive: true,
})
if (ok) regenerate.mutate(key.id)
}}
disabled={regenerate.isPending}
title="Wygeneruj ponownie"
@@ -355,10 +376,19 @@ export function ApiKeyListPage() {
<Button
variant="ghost"
icon="trash"
onClick={() => {
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
remove.mutate(key.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć klucz?',
message: (
<>
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
straci dostęp do publicznego API.
</>
),
confirmLabel: 'Usuń klucz',
destructive: true,
})
if (ok) remove.mutate(key.id)
}}
disabled={remove.isPending}
title="Usuń"
+15 -10
View File
@@ -7,6 +7,7 @@ import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
import { Icon } from '../../components/Icon'
import { MediaField } from '../../components/MediaField'
import { Modal } from '../../components/Modal'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Select } from '../../components/ui'
@@ -50,6 +51,7 @@ export function BannerFormDialog({
onClose: () => void
}) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const isEdit = Boolean(banner)
@@ -308,16 +310,19 @@ export function BannerFormDialog({
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (
window.confirm(
`Usunąć baner "${banner!.title}"?\n\n` +
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` +
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`,
)
) {
remove.mutate()
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć baner?',
message: (
<>
Baner <strong>{banner!.title}</strong> zniknie z sali, a razem z nim jego
statystyka: {banner!.impressions} wyświetleń i {banner!.clicks} kliknięć.
</>
),
confirmLabel: 'Usuń baner',
destructive: true,
})
if (ok) remove.mutate()
}}
>
Usuń
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Textarea } from '../../components/ui'
import { Modal } from '../../components/Modal'
@@ -17,6 +18,7 @@ export function ContactFormDialog({
onClose: () => void
}) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const isEdit = contact !== null
@@ -206,15 +208,19 @@ export function ContactFormDialog({
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (
confirm(
`Usunąć kontakt ${contact!.email} wraz z historią zgód?
` +
'Tej operacji nie da się cofnąć.',
)
) {
onClick={async () => {
const ok = await confirm({
title: 'Usunąć kontakt?',
message: (
<>
Kontakt <strong>{contact!.email}</strong> zniknie z bazy razem z całą
historią zgód, która dokumentuje podstawę wysyłki.
</>
),
confirmLabel: 'Usuń kontakt',
destructive: true,
})
if (ok) {
remove.mutate()
}
}}
+15 -4
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { ContactGroup } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Button, Input } from '../../components/ui'
import { Modal } from '../../components/Modal'
@@ -15,6 +16,7 @@ export function GroupsDialog({
onClose: () => void
}) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [name, setName] = useState('')
@@ -82,11 +84,20 @@ export function GroupsDialog({
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
onClick={async () => {
// grupa znika, kontakty zostaja: to tylko etykieta
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) {
remove.mutate(group.id)
}
const ok = await confirm({
title: 'Usunąć grupę?',
message: (
<>
Grupa <strong>{group.name}</strong> zniknie z listy, ale należące do
niej kontakty zostaną w bazie.
</>
),
confirmLabel: 'Usuń grupę',
destructive: true,
})
if (ok) remove.mutate(group.id)
}}
>
Usuń
@@ -25,6 +25,7 @@ import {
Spinner,
Textarea,
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -458,6 +459,8 @@ function RowActions({
onRevoke: () => void
busy: boolean
}) {
const confirm = useConfirm()
return (
<>
<a
@@ -473,15 +476,18 @@ function RowActions({
<>
<button
type="button"
onClick={() => {
if (
window.confirm(
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` +
'Wyślemy dokładnie ten plik, który został wydany.',
)
) {
onResend()
}
onClick={async () => {
const ok = await confirm({
title: 'Wysłać certyfikat ponownie?',
message: (
<>
Certyfikat <strong>{row.number}</strong> trafi na adres{' '}
<strong>{row.email}</strong>. Wyślemy dokładnie ten plik, który został wydany.
</>
),
confirmLabel: 'Wyślij ponownie',
})
if (ok) onResend()
}}
disabled={busy}
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
@@ -15,6 +15,7 @@ import {
Spinner,
Textarea,
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -34,6 +35,7 @@ import { usePublicUrl } from '../../lib/usePublicConfig'
export function CourseCertificateSection({ course }: { course: Course }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(course.certificateEnabled)
@@ -187,15 +189,18 @@ export function CourseCertificateSection({ course }: { course: Course }) {
<div className="mb-4 flex flex-wrap gap-2">
<Button
icon="award"
onClick={() => {
if (
window.confirm(
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` +
`Dotyczy ${completedPending.length} osób.`,
)
) {
issueAll.mutate({ force: false })
}
onClick={async () => {
const ok = await confirm({
title: 'Wydać certyfikaty tym, którzy ukończyli kurs?',
message: (
<>
Dokument dostaną osoby, które ukończyły kurs{' '}
<strong>{course.title}</strong>. Dotyczy {completedPending.length} osób.
</>
),
confirmLabel: 'Wydaj certyfikaty',
})
if (ok) issueAll.mutate({ force: false })
}}
disabled={issueAll.isPending || completedPending.length === 0}
>
@@ -313,17 +318,20 @@ export function CourseCertificateSection({ course }: { course: Course }) {
<Button
size="sm"
variant="danger"
onClick={() => {
if (
window.confirm(
`Unieważnić certyfikat ${certificate.number} ` +
`wydany dla ${certificate.participantName}? ` +
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' +
'ale nie zniknie.',
)
) {
revoke.mutate(certificate.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Unieważnić certyfikat?',
message: (
<>
Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
<strong>{certificate.participantName}</strong> zostanie oznaczony jako
nieważny przy weryfikacji publicznej, ale nie zniknie.
</>
),
confirmLabel: 'Unieważnij certyfikat',
destructive: true,
})
if (ok) revoke.mutate(certificate.id)
}}
disabled={revoke.isPending}
>
+38 -23
View File
@@ -18,6 +18,7 @@ import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader } from '../../components/SortableHeader'
import type { SortState } from '../../components/SortableHeader'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
@@ -315,6 +316,8 @@ function RowActions({
onArchive: () => void
onCopy: () => void
}) {
const confirm = useConfirm()
return (
<>
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
@@ -323,10 +326,17 @@ function RowActions({
<ActionButton
icon="upload"
label="Publikuj kurs"
onClick={() => {
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
onPublish()
}
onClick={async () => {
const ok = await confirm({
title: 'Opublikować kurs?',
message: (
<>
Kurs <strong>{course.title}</strong> pojawi się w ofercie Akademii.
</>
),
confirmLabel: 'Opublikuj kurs',
})
if (ok) onPublish()
}}
/>
)}
@@ -335,15 +345,18 @@ function RowActions({
<ActionButton
icon="history"
label="Archiwizuj kurs"
onClick={() => {
if (
window.confirm(
`Zarchiwizować kurs „${course.title}”?\n\n` +
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
)
) {
onArchive()
}
onClick={async () => {
const ok = await confirm({
title: 'Zarchiwizować kurs?',
message: (
<>
Kurs <strong>{course.title}</strong> zniknie z oferty, ale osoby już zapisane
zachowają dostęp.
</>
),
confirmLabel: 'Zarchiwizuj kurs',
})
if (ok) onArchive()
}}
/>
)}
@@ -351,16 +364,18 @@ function RowActions({
<ActionButton
icon="copy"
label="Kopiuj kurs"
onClick={() => {
if (
window.confirm(
`Skopiować kurs „${course.title}”?\n\n` +
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
'Nie przejdą zapisy kursantów ani ich postęp.',
)
) {
onCopy()
}
onClick={async () => {
const ok = await confirm({
title: 'Skopiować kurs?',
message: (
<>
Kopia kursu <strong>{course.title}</strong> przejmie program, opis i ustawienia
certyfikatu. Nie przejdą zapisy kursantów ani ich postęp.
</>
),
confirmLabel: 'Skopiuj kurs',
})
if (ok) onCopy()
}}
/>
</>
@@ -12,6 +12,7 @@ import {
Spinner,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
import { LessonEditor } from './LessonEditor'
@@ -36,6 +37,7 @@ import { LessonEditor } from './LessonEditor'
export function CurriculumSection({ courseId }: { courseId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const [newModuleTitle, setNewModuleTitle] = useState('')
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
@@ -210,15 +212,19 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
<IconAction
icon="trash"
label={`Usuń moduł ${module.title}`}
onClick={() => {
if (
window.confirm(
`Usunąć moduł „${module.title}”?\n\n` +
`Przepadnie razem z lekcjami: ${module.lessons.length}.`,
)
) {
deleteModule.mutate(module.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć moduł?',
message: (
<>
Moduł <strong>{module.title}</strong> przepadnie razem z lekcjami:{' '}
{module.lessons.length}.
</>
),
confirmLabel: 'Usuń moduł',
destructive: true,
})
if (ok) deleteModule.mutate(module.id)
}}
/>
</div>
@@ -272,10 +278,18 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
<IconAction
icon="trash"
label={`Usuń lekcję ${lesson.title}`}
onClick={() => {
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) {
deleteLesson.mutate(lesson.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć lekcję?',
message: (
<>
Lekcja <strong>{lesson.title}</strong> zniknie z programu kursu.
</>
),
confirmLabel: 'Usuń lekcję',
destructive: true,
})
if (ok) deleteLesson.mutate(lesson.id)
}}
/>
</div>
+21 -2
View File
@@ -16,6 +16,7 @@ import {
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
@@ -132,6 +133,7 @@ function QuestionsTab({
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const fileRef = useRef<HTMLInputElement>(null)
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
@@ -335,8 +337,25 @@ function QuestionsTab({
<IconAction
icon="trash"
label={`Usuń pytanie ${index + 1}`}
onClick={() => {
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
onClick={async () => {
const ok = await confirm({
title: 'Usunąć pytanie?',
message: (
<>
{/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */}
Pytanie {index + 1}{' '}
<strong>
{question.content.length > 120
? `${question.content.slice(0, 120).trimEnd()}...`
: question.content}
</strong>{' '}
zniknie z quizu razem z odpowiedziami.
</>
),
confirmLabel: 'Usuń pytanie',
destructive: true,
})
if (ok) remove.mutate(question.id)
}}
/>
</div>
@@ -17,6 +17,7 @@ import {
Spinner,
Textarea,
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
@@ -51,6 +52,7 @@ export function StudentDetailPage() {
const contactId = Number(id)
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const [tab, setTab] = useState<Tab>('kursy')
const [enrolling, setEnrolling] = useState(false)
@@ -169,15 +171,18 @@ export function StudentDetailPage() {
<Button
variant="secondary"
icon="key"
onClick={() => {
if (
window.confirm(
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
'Nie poznasz jego hasła: kursant ustawi je sam.',
)
) {
resetPassword.mutate()
}
onClick={async () => {
const ok = await confirm({
title: 'Wysłać link do ustawienia nowego hasła?',
message: (
<>
Link trafi na adres <strong>{student.email}</strong>. Nie poznasz jego hasła:
kursant ustawi je sam.
</>
),
confirmLabel: 'Wyślij link',
})
if (ok) resetPassword.mutate()
}}
disabled={resetPassword.isPending}
>
@@ -187,15 +192,24 @@ export function StudentDetailPage() {
<Button
variant={student.accountEnabled ? 'danger' : 'primary'}
icon={student.accountEnabled ? 'user-off' : 'check'}
onClick={() => {
onClick={async () => {
const next = !student.accountEnabled
const question = next
? `Włączyć konto ${student.email}?`
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
if (window.confirm(question)) {
setEnabled.mutate(next)
}
const question = next ? (
<>
Konto <strong>{student.email}</strong> odzyska możliwość zalogowania się.
</>
) : (
<>
Konto <strong>{student.email}</strong> straci możliwość zalogowania się, ale jego
zapisy, postęp i certyfikaty zostaną zachowane.
</>
)
const ok = await confirm({
title: next ? 'Włączyć konto kursanta?' : 'Wyłączyć konto kursanta?',
message: question,
confirmLabel: next ? 'Włącz konto' : 'Wyłącz konto',
})
if (ok) setEnabled.mutate(next)
}}
disabled={setEnabled.isPending}
>
+11 -2
View File
@@ -17,6 +17,7 @@ import {
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
@@ -207,6 +208,7 @@ export function OrderListPage() {
*/
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [note, setNote] = useState('')
const [granting, setGranting] = useState(false)
@@ -312,8 +314,15 @@ function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () =
<Button
variant="ghost"
icon="x"
onClick={() => {
if (window.confirm('Anulować to zamówienie?')) cancel.mutate()
onClick={async () => {
const ok = await confirm({
title: 'Anulować zamówienie?',
message:
'Kursant nie dostanie dostępu do kursu. Zamówienie zostaje w dzienniku jako anulowane.',
confirmLabel: 'Anuluj zamówienie',
destructive: true,
})
if (ok) cancel.mutate()
}}
>
Anuluj zamówienie
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon'
import { ImageEditor } from '../../components/ImageEditor'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Button, Card } from '../../components/ui'
import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig'
@@ -40,6 +41,7 @@ const KINDS: Record<
export function BrandingSection() {
const config = usePublicConfig()
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null)
@@ -145,8 +147,14 @@ export function BrandingSection() {
variant="ghost"
icon="trash"
size="sm"
onClick={() => {
if (window.confirm('Przywrócić grafikę domyślną?')) remove.mutate(kind)
onClick={async () => {
const ok = await confirm({
title: 'Przywrócić grafikę domyślną?',
message: 'Wgrany plik zostanie usunięty, a instancja wróci do wyglądu sprzed jego dodania.',
confirmLabel: 'Przywróć domyślną',
destructive: true,
})
if (ok) remove.mutate(kind)
}}
className="text-alarm-600 hover:bg-alarm-50"
>
@@ -17,6 +17,7 @@ import {
Spinner,
Textarea,
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
/**
@@ -106,6 +107,7 @@ export function HomePageSettingsPage() {
function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [editing, setEditing] = useState<PageSection | 'new' | null>(null)
@@ -215,11 +217,19 @@ function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
icon="trash"
label={`Usuń "${item.title}"`}
variant="danger"
onClick={() => {
onClick={async () => {
// pytanie nazywa konkretny obiekt, a nie "czy na pewno?"
if (window.confirm(`Usunąć pozycję "${item.title}"? Tej operacji nie można cofnąć.`)) {
remove.mutate(item.id)
}
const ok = await confirm({
title: 'Usunąć pozycję?',
message: (
<>
Pozycja <strong>{item.title}</strong> zniknie ze strony głównej.
</>
),
confirmLabel: 'Usuń pozycję',
destructive: true,
})
if (ok) remove.mutate(item.id)
}}
/>
</div>
+53 -12
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Page, User, UserRole } from '../../api/types'
import { useAuth } from '../../auth/AuthContext'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
@@ -22,6 +23,7 @@ const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: t
export function UserListPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const { user: currentUser } = useAuth()
@@ -273,8 +275,18 @@ export function UserListPage() {
<IconButton
icon="key"
label="Resetuj hasło"
onClick={() => {
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
onClick={async () => {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło jednorazowe
i będzie musiało je zmienić przy najbliższym logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}}
disabled={reset.isPending}
/>
@@ -282,8 +294,19 @@ export function UserListPage() {
<IconButton
icon="user-off"
label="Dezaktywuj"
onClick={() => {
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
onClick={async () => {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu
natychmiast, także w otwartych już sesjach. Można je później
włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
})
if (ok) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50"
@@ -351,10 +374,19 @@ export function UserListPage() {
<Button
variant="ghost"
icon="key"
onClick={() => {
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
reset.mutate(user)
}
onClick={async () => {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło
jednorazowe i będzie musiało je zmienić przy najbliższym
logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}}
disabled={reset.isPending}
title="Resetuj hasło"
@@ -365,10 +397,19 @@ export function UserListPage() {
<Button
variant="ghost"
icon="user-off"
onClick={() => {
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
deactivate.mutate(user.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu
natychmiast, także w otwartych już sesjach. Można je później
włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
})
if (ok) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending}
title="Dezaktywuj"
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui'
@@ -18,6 +19,7 @@ import { formatLocal } from '../../lib/dates'
export function CertificateSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(webinar.certificatesEnabled)
@@ -183,10 +185,20 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
<Button
icon="check-circle"
disabled={issueAll.isPending}
onClick={() => {
if (confirm('Wydać certyfikaty wszystkim uczestnikom z odnotowaną obecnością?')) {
issueAll.mutate(false)
}
onClick={async () => {
const attending = pending.filter((r) => r.attended).length
const ok = await confirm({
title: 'Wydać certyfikaty obecnym uczestnikom?',
message: (
<>
Certyfikat dostanie <strong>{attending}</strong>{' '}
{attending === 1 ? 'uczestnik' : 'uczestników'} z odnotowaną obecnością.
Każdy dokument dostanie własny numer i trafi do składu instancji.
</>
),
confirmLabel: 'Wydaj certyfikaty',
})
if (ok) issueAll.mutate(false)
}}
>
Wydaj obecnym
@@ -195,16 +207,22 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="secondary"
icon="alert-triangle"
disabled={issueAll.isPending}
onClick={() => {
if (
confirm(
'Wydać certyfikaty WSZYSTKIM potwierdzonym uczestnikom, także tym bez ' +
'odnotowanej obecności? Tej operacji nie da się cofnąć, można jedynie ' +
'unieważnić pojedyncze certyfikaty.',
)
) {
issueAll.mutate(true)
}
onClick={async () => {
const ok = await confirm({
title: 'Wydać certyfikaty także nieobecnym?',
message: (
<>
Certyfikat dostanie <strong>{pending.length}</strong>{' '}
{pending.length === 1 ? 'uczestnik' : 'uczestników'}, w tym{' '}
<strong>{pending.filter((r) => !r.attended).length}</strong> bez
odnotowanej obecności. Wydanego dokumentu nie da się wycofać, można
jedynie unieważnić pojedyncze certyfikaty.
</>
),
confirmLabel: 'Wydaj wszystkim',
destructive: true,
})
if (ok) issueAll.mutate(true)
}}
>
Wydaj wszystkim, także nieobecnym
@@ -234,14 +252,23 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="secondary"
icon="file-text"
disabled={issueOne.isPending}
onClick={() => {
if (
onClick={async () => {
const ok =
registration.attended ||
confirm(
`${registration.firstName} ${registration.lastName} nie ma odnotowanej ` +
'obecności. Wydać certyfikat mimo to?',
)
) {
(await confirm({
title: 'Wydać certyfikat bez odnotowanej obecności?',
message: (
<>
<strong>
{registration.firstName} {registration.lastName}
</strong>{' '}
nie ma odnotowanej obecności na tym webinarze. Certyfikat
potwierdzi udział, którego system nie zarejestrował.
</>
),
confirmLabel: 'Wydaj mimo to',
}))
if (ok) {
issueOne.mutate({
registrationId: registration.id,
force: !registration.attended,
@@ -313,15 +340,21 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="ghost"
icon="x-circle"
disabled={revoke.isPending}
onClick={() => {
if (
confirm(
`Unieważnić certyfikat ${certificate.number}? Plik zostanie, ale ` +
'weryfikacja publiczna pokaże go jako nieważny.',
)
) {
revoke.mutate(certificate.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Unieważnić certyfikat?',
message: (
<>
Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
<strong>{certificate.participantName}</strong> przestanie być ważny.
Plik zostanie w składzie, ale weryfikacja publiczna pokaże go jako
nieważny, a uczestnik straci do niego odnośnik.
</>
),
confirmLabel: 'Unieważnij certyfikat',
destructive: true,
})
if (ok) revoke.mutate(certificate.id)
}}
>
Unieważnij
+29 -12
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Material, Recording, Visibility } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui'
import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -154,6 +155,7 @@ function RecordingsCard({ webinarId }: { webinarId: number }) {
function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const publicUrl = usePublicUrl()
@@ -236,13 +238,20 @@ function RecordingRow({ recording, webinarId }: { recording: Recording; webinarI
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
onClick={async () => {
const name = recording.title ?? 'Nagranie webinaru'
if (confirm(`Usunąć nagranie "${name}" wraz z plikiem?
Tej operacji nie da się cofnąć.`)) {
remove.mutate()
}
const ok = await confirm({
title: 'Usunąć nagranie?',
message: (
<>
Nagranie <strong>{name}</strong> zniknie z panelu, a plik zostanie usunięty
ze składu instancji. Uczestnicy stracą do niego dostęp.
</>
),
confirmLabel: 'Usuń nagranie',
destructive: true,
})
if (ok) remove.mutate()
}}
>
Usuń
@@ -351,6 +360,7 @@ function MaterialsCard({ webinarId }: { webinarId: number }) {
function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const publicUrl = usePublicUrl()
@@ -420,12 +430,19 @@ function MaterialRow({ material, webinarId }: { material: Material; webinarId: n
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (confirm(`Usunąć materiał "${material.title}" wraz z plikiem?
Tej operacji nie da się cofnąć.`)) {
remove.mutate()
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć materiał?',
message: (
<>
Materiał <strong>{material.title}</strong> zniknie z panelu, a plik zostanie
usunięty ze składu instancji. Uczestnicy stracą do niego dostęp.
</>
),
confirmLabel: 'Usuń materiał',
destructive: true,
})
if (ok) remove.mutate()
}}
>
Usuń
@@ -8,6 +8,7 @@ import type {
RecipientPreview,
Webinar,
} from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { ContentEditor } from '../../components/ContentEditor'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
@@ -32,6 +33,7 @@ const STATUS_LABEL: Record<string, string> = {
export function InvitationSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const [groupIds, setGroupIds] = useState<number[]>([])
const [content, setContent] = useState(webinar.invitationContentHtml ?? '')
@@ -186,11 +188,20 @@ export function InvitationSection({ webinar }: { webinar: Webinar }) {
disabled={
send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0
}
onClick={() => {
onClick={async () => {
const count = recipients?.toSend ?? 0
if (confirm(`Wysłać zaproszenie do ${count} odbiorców?`)) {
send.mutate()
}
const ok = await confirm({
title: 'Wysłać zaproszenia?',
message: (
<>
Zaproszenie na webinar <strong>{webinar.title}</strong> trafi do{' '}
<strong>{count}</strong> {count === 1 ? 'odbiorcy' : 'odbiorców'}. Wysłanej
wiadomości nie da się cofnąć.
</>
),
confirmLabel: 'Wyślij zaproszenia',
})
if (ok) send.mutate()
}}
>
{send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'}
+20 -10
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Select, Spinner } from '../../components/ui'
@@ -82,6 +83,7 @@ export function ProviderPicker({
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const state = useQuery({
queryKey: ['video-provider', webinarId],
@@ -173,15 +175,23 @@ export function ProviderPicker({
variant="secondary"
className="mt-2"
disabled={migrate.isPending}
onClick={() => {
if (
window.confirm(
`Zamknąć pokój u dostawcy ${data.roomProvider}?\n\n` +
'Tej operacji nie da się cofnąć. Nowy pokój założysz przyciskiem poniżej.',
)
) {
migrate.mutate()
}
onClick={async () => {
const ok = await confirm({
title: 'Zamknąć stary pokój?',
message: (
<>
Pokój u dostawcy{' '}
<strong>
{PROVIDER_LABEL[data.roomProvider ?? ''] ?? data.roomProvider}
</strong>{' '}
zostanie zamknięty. Uczestnicy z linkiem sprzed zmiany trafią w puste
miejsce. Nowy pokój założysz przyciskiem poniżej.
</>
),
confirmLabel: 'Zamknij stary pokój',
destructive: true,
})
if (ok) migrate.mutate()
}}
>
Zamknij stary pokój
@@ -232,7 +242,7 @@ function JitsiWarning() {
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-700">
Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do
środka</strong> — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
środka</strong> - także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze
Jitsi, żeby je obejść.
</p>
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { NotificationChannel, ReminderRule } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui'
import { formatWithZone } from '../../lib/dates'
@@ -10,6 +11,7 @@ import { describeOffset, REMINDER_PRESETS as PRESETS } from '../../lib/reminders
export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const [offset, setOffset] = useState('1440')
const [channel, setChannel] = useState<NotificationChannel>('EMAIL')
@@ -120,13 +122,26 @@ export function ReminderSection({ webinarId, timezone }: { webinarId: number; ti
type="button"
variant="ghost"
icon="trash"
onClick={() => {
const extra = rule.alreadySent
? '\n\nZ tej reguły wysłano już wiadomości. Historia wysyłek zostanie usunięta.'
: ''
if (window.confirm(`Usunąć regułę "${describeOffset(rule.offsetMinutes)}"?${extra}`)) {
remove.mutate(rule.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć regułę przypomnienia?',
message: (
<>
Przypomnienie <strong>{describeOffset(rule.offsetMinutes)}</strong>{' '}
przestanie być wysyłane uczestnikom tego webinaru.
{rule.alreadySent && (
<>
{' '}
Z tej reguły wysłano już wiadomości, więc historia wysyłek
zostanie usunięta razem z nią.
</>
)}
</>
),
confirmLabel: 'Usuń regułę',
destructive: true,
})
if (ok) remove.mutate(rule.id)
}}
className="shrink-0 text-alarm-600 hover:bg-alarm-50"
title="Usuń regułę"
+16 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
@@ -51,6 +52,7 @@ export function SpeakQueue({
onChanged: () => void
}) {
const toasts = useToasts()
const confirm = useConfirm()
const [busy, setBusy] = useState<number | null>(null)
const [switching, setSwitching] = useState(false)
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
@@ -99,8 +101,20 @@ export function SpeakQueue({
async function toggleRecording() {
if (recordingBusy) return
if (egressId && !confirm('Zatrzymać nagrywanie? Tego fragmentu nie nagrasz ponownie.')) {
return
if (egressId) {
const ok = await confirm({
title: 'Zatrzymać nagrywanie?',
message: (
<>
Nagrywanie tego spotkania zostanie zakończone, a plik trafi do zakładki Pliki.{' '}
<strong>Tego fragmentu nie nagrasz ponownie</strong>: ponowne włączenie zacznie nowe
nagranie od bieżącej chwili.
</>
),
confirmLabel: 'Zatrzymaj nagrywanie',
destructive: true,
})
if (!ok) return
}
setRecordingBusy(true)
+15 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, IconButton, Input, Spinner } from '../../components/ui'
@@ -38,6 +39,7 @@ export function VideoSection({
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const publicUrl = usePublicUrl()
const [email, setEmail] = useState('')
@@ -220,10 +222,19 @@ export function VideoSection({
label={`Usuń prelegenta ${speaker.fullName}`}
variant="ghost"
type="button"
onClick={() => {
if (window.confirm(`Usunąć prelegenta ${speaker.fullName}?`)) {
removeSpeaker.mutate(speaker.id)
}
onClick={async () => {
const ok = await confirm({
title: 'Usunąć prelegenta?',
message: (
<>
<strong>{speaker.fullName}</strong> straci dostęp do sali, a jego
dotychczasowy link do spotkania przestanie działać.
</>
),
confirmLabel: 'Usuń prelegenta',
destructive: true,
})
if (ok) removeSpeaker.mutate(speaker.id)
}}
className="text-alarm-600 hover:bg-alarm-50"
/>
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Webinar, WebinarRequest } from '../../api/types'
import { useAuth } from '../../auth/AuthContext'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Button, Card, ErrorState, Field, Input, Select, Spinner, Textarea } from '../../components/ui'
import { COMMON_TIMEZONES, isoToZonedInput, zonedInputToIso } from '../../lib/dates'
@@ -579,6 +580,7 @@ export function WebinarFormPage() {
*/
function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl: string | null }) {
const toasts = useToasts()
const confirm = useConfirm()
const notify = useMutation({
mutationFn: async () => {
@@ -623,10 +625,19 @@ function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl:
variant="secondary"
icon="mail"
disabled={!hasSavedUrl || notify.isPending}
onClick={() => {
if (confirm('Wysłać powiadomienie o nagraniu do wszystkich potwierdzonych uczestników?')) {
notify.mutate()
}
onClick={async () => {
const ok = await confirm({
title: 'Wysłać powiadomienie o nagraniu?',
message: (
<>
Wiadomość z odnośnikiem do nagrania trafi do{' '}
<strong>wszystkich uczestników z potwierdzonym zapisem</strong>. Każdy dostanie
ją tylko raz, nawet po kilku kliknięciach.
</>
),
confirmLabel: 'Wyślij powiadomienie',
})
if (ok) notify.mutate()
}}
>
{notify.isPending ? 'Wysyłanie...' : 'Wyślij powiadomienie o nagraniu'}
+24 -12
View File
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { CopyResult, DeletionImpact, Page, Webinar, WebinarStatus } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader, type SortState } from '../../components/SortableHeader'
@@ -35,6 +36,7 @@ export function WebinarListPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const query = useQuery({
queryKey: ['webinars', scope, status, search, page, sort.field, sort.direction],
@@ -151,21 +153,31 @@ export function WebinarListPage() {
? `Bezpowrotnie przepadnie: ${losses.join(', ')}, a także obecność, wiadomości z czatu, pytania, nagrania i materiały.`
: 'Ten webinar nie ma jeszcze zapisów ani certyfikatów.'
const confirmed = window.confirm(
`Usunąć webinar "${webinar.title}"?
${detail}
` +
'Tej operacji nie da się cofnąć. Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj "Odwołaj".',
)
const confirmed = await confirm({
title: 'Usunąć webinar?',
message: (
<>
Webinar <strong>{webinar.title}</strong> zniknie z panelu i ze strony publicznej.{' '}
{detail} Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj przycisku „Odwołaj”.
</>
),
confirmLabel: 'Usuń webinar',
destructive: true,
})
if (confirmed) remove.mutate(webinar.id)
}
function handleCancel(webinar: Webinar) {
const confirmed = window.confirm(
`Odwołać webinar "${webinar.title}"?\n\nZapisy zostaną zamknięte, a webinar zniknie ze strony publicznej.`,
)
async function handleCancel(webinar: Webinar) {
const confirmed = await confirm({
title: 'Odwołać webinar?',
message: (
<>
Zapisy na webinar <strong>{webinar.title}</strong> zostaną zamknięte, a wydarzenie
zniknie ze strony publicznej. Zapisani uczestnicy i ich dane zostają w panelu.
</>
),
confirmLabel: 'Odwołaj webinar',
})
if (confirmed) cancel.mutate(webinar.id)
}