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