diff --git a/frontend/src/academy/StudentDashboardPage.tsx b/frontend/src/academy/StudentDashboardPage.tsx
index dec73a0..307021f 100644
--- a/frontend/src/academy/StudentDashboardPage.tsx
+++ b/frontend/src/academy/StudentDashboardPage.tsx
@@ -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'}
diff --git a/frontend/src/components/Confirm.tsx b/frontend/src/components/Confirm.tsx
new file mode 100644
index 0000000..f732e46
--- /dev/null
+++ b/frontend/src/components/Confirm.tsx
@@ -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
+
+const ConfirmContext = createContext(null)
+
+export function ConfirmProvider({ children }: { children: ReactNode }) {
+ const [pending, setPending] = useState(null)
+ const resolveRef = useRef<((value: boolean) => void) | null>(null)
+
+ const confirm = useCallback((options) => {
+ setPending(options)
+ return new Promise((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 (
+
+ {children}
+
+ {pending && (
+ settle(false)}
+ footer={
+
+
+
+
+ }
+ >
+
+ {pending.destructive && (
+
+
+
+ Tej operacji nie da się cofnąć.
+
+
+ )}
+ {pending.message && (
+
{pending.message}
+ )}
+
+
+ )}
+
+ )
+}
+
+export function useConfirm(): ConfirmApi {
+ const api = useContext(ConfirmContext)
+ if (!api) {
+ throw new Error('useConfirm musi byc uzyte wewnatrz ConfirmProvider')
+ }
+ return api
+}
diff --git a/frontend/src/components/MediaPicker.tsx b/frontend/src/components/MediaPicker.tsx
index 1151314..1198d06 100644
--- a/frontend/src/components/MediaPicker.tsx
+++ b/frontend/src/components/MediaPicker.tsx
@@ -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(null)
const [uploading, setUploading] = useState(false)
@@ -137,10 +139,19 @@ export function MediaPicker({
Token JWT jest wyłączony, więc każdy, kto zna adres pokoju, wejdzie do
- środka — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
+ środka - 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ść.
diff --git a/frontend/src/pages/webinars/ReminderSection.tsx b/frontend/src/pages/webinars/ReminderSection.tsx
index 87a5328..c1a1a24 100644
--- a/frontend/src/pages/webinars/ReminderSection.tsx
+++ b/frontend/src/pages/webinars/ReminderSection.tsx
@@ -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('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 {describeOffset(rule.offsetMinutes)}{' '}
+ 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łę"
diff --git a/frontend/src/pages/webinars/SpeakQueue.tsx b/frontend/src/pages/webinars/SpeakQueue.tsx
index 7fce8c0..15f52e7 100644
--- a/frontend/src/pages/webinars/SpeakQueue.tsx
+++ b/frontend/src/pages/webinars/SpeakQueue.tsx
@@ -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(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.{' '}
+ Tego fragmentu nie nagrasz ponownie: ponowne włączenie zacznie nowe
+ nagranie od bieżącej chwili.
+ >
+ ),
+ confirmLabel: 'Zatrzymaj nagrywanie',
+ destructive: true,
+ })
+ if (!ok) return
}
setRecordingBusy(true)
diff --git a/frontend/src/pages/webinars/VideoSection.tsx b/frontend/src/pages/webinars/VideoSection.tsx
index 91a4799..e9c99ba 100644
--- a/frontend/src/pages/webinars/VideoSection.tsx
+++ b/frontend/src/pages/webinars/VideoSection.tsx
@@ -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: (
+ <>
+ {speaker.fullName} 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"
/>
diff --git a/frontend/src/pages/webinars/WebinarFormPage.tsx b/frontend/src/pages/webinars/WebinarFormPage.tsx
index 5408f72..c222824 100644
--- a/frontend/src/pages/webinars/WebinarFormPage.tsx
+++ b/frontend/src/pages/webinars/WebinarFormPage.tsx
@@ -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{' '}
+ wszystkich uczestników z potwierdzonym zapisem. 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'}
diff --git a/frontend/src/pages/webinars/WebinarListPage.tsx b/frontend/src/pages/webinars/WebinarListPage.tsx
index b8d5866..60b4cea 100644
--- a/frontend/src/pages/webinars/WebinarListPage.tsx
+++ b/frontend/src/pages/webinars/WebinarListPage.tsx
@@ -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 {webinar.title} 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 {webinar.title} 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)
}