Licznik odliczajacy na stronie zapisu
Deploy PROD / deploy (push) Successful in 25s

Sama data w naglowku mowi KIEDY, ale nie mowi ZA ILE: "14 wrzesnia, godz.
10:00" wymaga od odwiedzajacego przeliczenia w glowie. Licznik odpowiada na
pytanie, ktore naprawde sobie zadaje: czy to juz, czy jeszcze daleko. Przy
wydarzeniu za dwie godziny jest przy okazji sygnalem, ze warto zapisac sie
teraz, a nie wrocic pozniej.

Miejsce: pod trescia strony, nad formularzem zapisu.

ZNIKA PO TERMINIE
Odliczanie do czegos, co juz sie zaczelo, wprowadza w blad. Po `startsAt`
komponent nie renderuje sie wcale, wiec przy webinarze zakonczonym nie pokazuje
zer obok komunikatu o zamknieciu zapisow.

CZAS Z ZEGARA, NIE Z ODEJMOWANIA
Wartosc bierze sie z roznicy dat przy kazdym tyknieciu. Karta uspiona przez
przegladarke wstrzymuje `setInterval`, wiec licznik odliczajacy "o jeden"
rozjechalby sie z zegarem tym bardziej, im dluzej strona byla w tle.

STREFA CZASOWA
Backend zwraca `OffsetDateTime`, czyli moment z przesunieciem, wiec odliczanie
jest poprawne takze dla odwiedzajacego z innej strefy niz webinar. Zgodnie
z regula, ze strefa zostaje jawna tam, gdzie ma znaczenie.

`tabular-nums` (klasa `hx-data`) nie jest tu ozdoba: bez niego cyfry maja rozne
szerokosci i licznik drga przy kazdej zmianie sekundy.

Czytnik ekranu dostaje jedno zdanie z `aria-live`, a nie cztery liczby odczytane
po kolei: same cyfry z podpisami sa dla oka, a odczytane brzmialyby jak zbior
przypadkowych wartosci.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 07:40:28 +02:00
parent ecece16352
commit 06097be829
2 changed files with 116 additions and 0 deletions
@@ -8,6 +8,7 @@ import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
import { useDocumentMeta } from '../../lib/useDocumentMeta' import { useDocumentMeta } from '../../lib/useDocumentMeta'
import { PublicLayout, PublicNotice } from './PublicLayout' import { PublicLayout, PublicNotice } from './PublicLayout'
import { RegistrationForm } from './RegistrationForm' import { RegistrationForm } from './RegistrationForm'
import { WebinarCountdown } from './WebinarCountdown'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
export function PublicWebinarPage() { export function PublicWebinarPage() {
@@ -169,6 +170,14 @@ export function PublicWebinarPage() {
</section> </section>
)} )}
{/*
Licznik nad formularzem, pod opisem: odpowiada na pytanie "za ile",
ktore sama data w naglowku zostawia bez odpowiedzi. Sam znika po
terminie rozpoczecia, wiec przy webinarze zakonczonym nie pokazuje
zer obok komunikatu o zamknieciu.
*/}
<WebinarCountdown startsAt={webinar.startsAt} />
{/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */} {/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
{closed && notice ? ( {closed && notice ? (
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} /> <PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
/**
* Odliczanie do rozpoczecia webinaru.
*
* DLACZEGO W OGOLE
* ------------------------------------------------------------------------
* Sama data w naglowku mowi KIEDY, ale nie mowi ZA ILE. "14 wrzesnia, godz.
* 10:00" wymaga od odwiedzajacego przeliczenia w glowie, a licznik odpowiada
* na pytanie, ktore naprawde sobie zadaje: czy to juz, czy jeszcze daleko.
* Przy wydarzeniu za dwie godziny jest to takze delikatny sygnal, ze warto
* zapisac sie teraz, a nie wrocic pozniej.
*
* ZNIKA, GDY PRZESTAJE MIEC SENS
* ------------------------------------------------------------------------
* Odliczanie do czegos, co juz sie zaczelo albo skonczylo, wprowadza w blad.
* Po terminie rozpoczecia komponent nie renderuje sie wcale: strona zamknietego
* webinaru ma wtedy wlasny komunikat, a licznik pokazujacy zera obok niego
* wygladalby na zepsuty.
*
* ODSWIEZANIE CO SEKUNDE, LICZONE OD ZEGARA
* ------------------------------------------------------------------------
* Wartosc bierze sie z roznicy dat przy kazdym tyknieciu, a nie z odejmowania
* jedynki od poprzedniego stanu. Karta uspiona przez przegladarke wstrzymuje
* `setInterval`, wiec licznik odliczajacy "o jeden" rozjechalby sie z zegarem
* tym bardziej, im dluzej strona byla w tle.
*/
type Remaining = { days: number; hours: number; minutes: number; seconds: number }
function remaining(target: Date): Remaining | null {
const diff = target.getTime() - Date.now()
if (diff <= 0) return null
const total = Math.floor(diff / 1000)
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
}
}
export function WebinarCountdown({ startsAt }: { startsAt: string }) {
const target = new Date(startsAt)
const [left, setLeft] = useState(() => remaining(target))
useEffect(() => {
const timer = window.setInterval(() => setLeft(remaining(new Date(startsAt))), 1000)
return () => window.clearInterval(timer)
}, [startsAt])
// webinar juz sie zaczal albo termin jest nieczytelny: nie ma czego odliczac
if (!left || Number.isNaN(target.getTime())) return null
return (
<section
className="rounded-lg border border-slate-200 bg-white p-5 text-center shadow-sm sm:p-6"
aria-label="Czas do rozpoczęcia webinaru"
>
<p className="text-xs font-semibold uppercase tracking-wide text-blue-600">
Webinar rozpocznie się za:
</p>
{/*
Wartosc czytana przez czytnik ekranu jako jedno zdanie: cztery liczby
z podpisami pod spodem sa dla oka, ale odczytane po kolei brzmialyby
jak zbior przypadkowych cyfr.
*/}
<p className="sr-only" aria-live="polite">
Pozostało {left.days} dni, {left.hours} godzin, {left.minutes} minut i {left.seconds} sekund.
</p>
<div className="mt-3 flex items-start justify-center gap-2 sm:gap-4" aria-hidden="true">
<Unit value={left.days} label="Dni" />
<Separator />
<Unit value={left.hours} label="Godzin" />
<Separator />
<Unit value={left.minutes} label="Minut" />
<Separator />
<Unit value={left.seconds} label="Sekund" />
</div>
</section>
)
}
/**
* Jedna wartosc z podpisem.
*
* `tabular-nums` (klasa `hx-data`) jest tu konieczne, a nie ozdobne: bez niego
* cyfry maja rozne szerokosci i licznik drga przy kazdej zmianie sekundy.
*/
function Unit({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[3.5rem] sm:min-w-[4.5rem]">
<p className="hx-data text-3xl font-semibold text-navy-900 sm:text-4xl">
{String(value).padStart(2, '0')}
</p>
<p className="mt-0.5 text-xs text-slate-500">{label}</p>
</div>
)
}
/** Dwukropek na wysokosci cyfr, nie podpisow. */
function Separator() {
return <p className="hx-data text-3xl font-semibold text-slate-300 sm:text-4xl">:</p>
}