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:
@@ -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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user