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({