From 1599c7b0c14ae5ba60104423872d15c1f31958ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?=
Date: Fri, 4 Sep 2026 14:13:16 +0200
Subject: [PATCH] Potwierdzenia operacji jako okno panelu, nie okno
przegladarki
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
frontend/src/academy/StudentDashboardPage.tsx | 14 +-
frontend/src/components/Confirm.tsx | 123 ++++++++++++++++++
frontend/src/components/MediaPicker.tsx | 19 ++-
frontend/src/main.tsx | 8 ++
frontend/src/pages/apikeys/ApiKeyListPage.tsx | 80 ++++++++----
.../src/pages/banners/BannerFormDialog.tsx | 25 ++--
.../src/pages/contacts/ContactFormDialog.tsx | 24 ++--
frontend/src/pages/contacts/GroupsDialog.tsx | 19 ++-
.../pages/courses/CertificateOverviewPage.tsx | 24 ++--
.../courses/CourseCertificateSection.tsx | 48 ++++---
frontend/src/pages/courses/CourseListPage.tsx | 61 +++++----
.../src/pages/courses/CurriculumSection.tsx | 40 ++++--
frontend/src/pages/courses/QuizEditor.tsx | 23 +++-
.../src/pages/courses/StudentDetailPage.tsx | 48 ++++---
frontend/src/pages/orders/OrderListPage.tsx | 13 +-
.../src/pages/settings/BrandingSection.tsx | 12 +-
.../pages/settings/HomePageSettingsPage.tsx | 18 ++-
frontend/src/pages/users/UserListPage.tsx | 65 +++++++--
.../src/pages/webinars/CertificateSection.tsx | 93 ++++++++-----
frontend/src/pages/webinars/FilesSection.tsx | 41 ++++--
.../src/pages/webinars/InvitationSection.tsx | 19 ++-
.../src/pages/webinars/ProviderPicker.tsx | 30 +++--
.../src/pages/webinars/ReminderSection.tsx | 29 ++++-
frontend/src/pages/webinars/SpeakQueue.tsx | 18 ++-
frontend/src/pages/webinars/VideoSection.tsx | 19 ++-
.../src/pages/webinars/WebinarFormPage.tsx | 19 ++-
.../src/pages/webinars/WebinarListPage.tsx | 36 +++--
27 files changed, 723 insertions(+), 245 deletions(-)
create mode 100644 frontend/src/components/Confirm.tsx
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)
}