1599c7b0c1
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>
209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
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'
|
|
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')
|
|
|
|
const rules = useQuery({
|
|
queryKey: ['reminders', webinarId],
|
|
queryFn: async () => {
|
|
const response = await client.get<ReminderRule[]>(`/admin/webinars/${webinarId}/reminders`)
|
|
return response.data
|
|
},
|
|
})
|
|
|
|
function invalidate() {
|
|
queryClient.invalidateQueries({ queryKey: ['reminders', webinarId] })
|
|
}
|
|
|
|
const add = useMutation({
|
|
mutationFn: () =>
|
|
client.post(`/admin/webinars/${webinarId}/reminders`, {
|
|
offsetMinutes: Number(offset),
|
|
channel,
|
|
template: 'reminder',
|
|
}),
|
|
onSuccess: () => {
|
|
toasts.success('Reguła została dodana')
|
|
invalidate()
|
|
},
|
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać reguły')),
|
|
})
|
|
|
|
const toggle = useMutation({
|
|
mutationFn: ({ id, value }: { id: number; value: boolean }) =>
|
|
client.post(`/admin/webinars/${webinarId}/reminders/${id}/enabled?value=${value}`),
|
|
onSuccess: () => invalidate(),
|
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić reguły')),
|
|
})
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/reminders/${id}`),
|
|
onSuccess: () => {
|
|
toasts.success('Reguła została usunięta')
|
|
invalidate()
|
|
},
|
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć reguły')),
|
|
})
|
|
|
|
return (
|
|
<Card
|
|
title="Przypomnienia"
|
|
description="Wiadomości wysyłane automatycznie przed startem webinaru"
|
|
>
|
|
{rules.isPending && <Spinner />}
|
|
{rules.isError && (
|
|
<ErrorState message={errorMessage(rules.error, 'Nie udało się wczytać reguł')} />
|
|
)}
|
|
|
|
{rules.data && (
|
|
<div className="space-y-4">
|
|
{rules.data.length === 0 ? (
|
|
<p className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-500">
|
|
Brak reguł. Ten webinar nie wyśle żadnych przypomnień.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100 rounded-md border border-slate-200">
|
|
{rules.data.map((rule) => (
|
|
<li key={rule.id} className="flex flex-wrap items-center gap-3 p-3">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={rule.enabled}
|
|
onChange={(e) => toggle.mutate({ id: rule.id, value: e.target.checked })}
|
|
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
|
|
aria-label={rule.enabled ? 'Wyłącz regułę' : 'Włącz regułę'}
|
|
/>
|
|
</label>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-400'}`}>
|
|
{describeOffset(rule.offsetMinutes)}
|
|
<span className="ml-2 text-xs font-normal text-slate-500">
|
|
{rule.channel === 'SMS' ? 'SMS' : 'e-mail'}
|
|
</span>
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
{formatWithZone(rule.scheduledAt, timezone)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* liczniki pokazuja, co juz poszlo z tej reguly */}
|
|
<div className="flex shrink-0 gap-1.5">
|
|
{rule.sentCount > 0 && (
|
|
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">
|
|
wysłano {rule.sentCount}
|
|
</Badge>
|
|
)}
|
|
{rule.queuedCount > 0 && (
|
|
<Badge className="bg-warn-50 text-warn-600 ring-warn-500/25">
|
|
w kolejce {rule.queuedCount}
|
|
</Badge>
|
|
)}
|
|
{rule.failedCount > 0 && (
|
|
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">
|
|
błędy {rule.failedCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
icon="trash"
|
|
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łę"
|
|
>
|
|
<span className="sr-only">Usuń</span>
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-end gap-2 border-t border-slate-100 pt-4">
|
|
<div className="w-44">
|
|
<label className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</label>
|
|
<Select value={offset} onChange={(e) => setOffset(e.target.value)}>
|
|
{PRESETS.map((preset) => (
|
|
<option key={preset.minutes} value={preset.minutes}>
|
|
{preset.label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div className="w-28">
|
|
<label className="mb-1 block text-xs font-semibold text-slate-700">Kanał</label>
|
|
<Select
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value as NotificationChannel)}
|
|
>
|
|
<option value="EMAIL">E-mail</option>
|
|
<option value="SMS">SMS</option>
|
|
</Select>
|
|
</div>
|
|
<div className="w-32">
|
|
<label className="mb-1 block text-xs font-semibold text-slate-700">Własny odstęp</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={offset}
|
|
onChange={(e) => setOffset(e.target.value)}
|
|
placeholder="minuty"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
icon="plus"
|
|
variant="secondary"
|
|
onClick={() => add.mutate()}
|
|
disabled={add.isPending || !offset || Number(offset) < 1}
|
|
>
|
|
Dodaj
|
|
</Button>
|
|
</div>
|
|
|
|
{rules.data.some((rule) => rule.channel === 'SMS') && (
|
|
<p className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
|
|
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu
|
|
i wyrazili zgodę marketingową na SMS.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|