Endprüfung P6 (C5, W1): Beim Blättern hängt die Seitenzeile beim Laden aus, und „Alle anzeigen“ wird durch den Raumfilter ersetzt; der Fokus fiel jeweils auf <body>. - Blättern: gemerkt wird der gedrückte Knopf; nach dem Laden bekommt er den Fokus zurück, ist er gesperrt (erste oder letzte Seite), der andere. Raum oder Suche bedient der Nutzer inzwischen, geschieht nichts; scheitert die Anfrage, gibt es keine Seitenzeile und nichts geschieht. - „Alle anzeigen“: der Raumfilter bekommt den Fokus, sobald die Seite bot aus der Adresse genommen hat. Nicht beim Einhängen (am Handy öffnete das die Auswahl) und nicht bei einem anderen Weg zur Liste (Zurück-Taste). - aria-current nur mit bot (single) und passender Kennung; Zeilen ohne Kennung teilen sich keinen key mehr. - Tests: botleadList.test.mjs (Fokus-Attrappe über __fakeRef, Mutationsprobe für die Reset-Zeilen), botleadsView.test.mjs (Quelltext-Probe); der Prüfstand prüft zusätzlich den Fokus nach „Alle anzeigen“. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
182 lines
9.4 KiB
React
182 lines
9.4 KiB
React
import { useEffect, useRef, useState } from 'react'
|
||
import EmptyState from '../ui/EmptyState'
|
||
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
|
||
|
||
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
|
||
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
|
||
const UNKNOWN_ERROR = 'Unbekannter Fehler'
|
||
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
|
||
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
|
||
|
||
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
|
||
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
|
||
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen.
|
||
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
|
||
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
|
||
export default function BotLeadList({ api, bot, onClearBot }) {
|
||
const botId = typeof bot === 'string' ? bot : ''
|
||
const single = botId !== ''
|
||
const [view, setView] = useState(START_VIEW)
|
||
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
|
||
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
|
||
const timerRef = useRef(null)
|
||
const seqRef = useRef(0)
|
||
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
|
||
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
|
||
const selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“
|
||
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus
|
||
const prevRef = useRef(null)
|
||
const nextRef = useRef(null)
|
||
const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück
|
||
|
||
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
|
||
|
||
useEffect(() => {
|
||
seqRef.current += 1
|
||
const mine = seqRef.current
|
||
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
|
||
setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
|
||
const load = async () => {
|
||
try {
|
||
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
|
||
if (mine !== seqRef.current) return
|
||
const items = Array.isArray(data?.items) ? data.items : []
|
||
const total = Number.isFinite(data?.total) ? data.total : items.length
|
||
// Die Liste ist inzwischen kürzer (Kürzen über Nacht): statt einer leeren Seite auf die letzte Seite springen
|
||
const { pages } = pageInfo({ total, page: view.page, pageSize: data?.pageSize })
|
||
if (!single && items.length === 0 && total > 0 && view.page > pages) {
|
||
setView((v) => ({ ...v, page: pages }))
|
||
return
|
||
}
|
||
setResult({ key, items, total, pageSize: data?.pageSize, error: null })
|
||
} catch (err) {
|
||
if (mine !== seqRef.current) return
|
||
setResult({ key, items: [], total: 0, pageSize: undefined, error: err?.message || UNKNOWN_ERROR })
|
||
}
|
||
}
|
||
load()
|
||
return () => { seqRef.current += 1 } // eine noch offene Anfrage zählt nicht mehr
|
||
}, [api, key])
|
||
|
||
useEffect(() => () => window.clearTimeout(timerRef.current), [])
|
||
|
||
const onClear = () => {
|
||
clearedRef.current = true
|
||
onClearBot()
|
||
}
|
||
const onPage = (dir) => {
|
||
pagedRef.current = dir
|
||
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
|
||
}
|
||
const onRoom = (e) => {
|
||
const room = e.target.value
|
||
pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück
|
||
setView((v) => ({ ...v, room, page: 1 }))
|
||
}
|
||
const onSearch = (e) => {
|
||
const value = e.target.value
|
||
pagedRef.current = ''
|
||
setDraft(value)
|
||
window.clearTimeout(timerRef.current)
|
||
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
|
||
}
|
||
|
||
const loading = result.key !== key
|
||
|
||
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter ersetzt den gedrückten Knopf und bekommt den Fokus.
|
||
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
|
||
useEffect(() => {
|
||
const wanted = clearedRef.current
|
||
clearedRef.current = false
|
||
if (wanted && !single) selectRef.current?.focus()
|
||
}, [single])
|
||
|
||
// Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen.
|
||
// Scheiterte die Anfrage, gibt es keine Seitenzeile (die Refs sind null) und nichts geschieht.
|
||
useEffect(() => {
|
||
if (loading) return
|
||
const dir = pagedRef.current
|
||
pagedRef.current = ''
|
||
if (dir === '') return
|
||
const [first, second] = dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current]
|
||
const target = first && !first.disabled ? first : second
|
||
target?.focus()
|
||
}, [loading])
|
||
|
||
const rows = result.items.map((item) => botleadRow(item))
|
||
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
|
||
const filtered = view.room !== '' || view.q !== ''
|
||
|
||
let body
|
||
if (loading) {
|
||
body = <div className="loading" role="status">Wird geladen …</div>
|
||
} else if (result.error) {
|
||
body = <div className="msg err" role="alert">{result.error}</div>
|
||
} else if (rows.length === 0) {
|
||
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
|
||
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
|
||
else body = <EmptyState title="Noch keine Bot-Leads." hint="Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund." />
|
||
} else {
|
||
body = (
|
||
<div className="wk-bl-scroll" role="region" aria-label="Bot-Leads" tabIndex={0}>
|
||
<table className="table wk-bl-table">
|
||
<thead>
|
||
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => (
|
||
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && row.id === botId ? 'true' : undefined}>
|
||
<td>
|
||
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
|
||
{row.place && <div className="cell-sub">{row.place}</div>}
|
||
</td>
|
||
<td>{row.roomLabel}</td>
|
||
<td className="wk-bl-nowrap">{row.scoreLabel}</td>
|
||
<td>{row.reason}</td>
|
||
<td>{row.phone}</td>
|
||
<td className="wk-bl-nowrap">
|
||
{row.sourceHref
|
||
? <a href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${row.title}`}>Quelle öffnen</a>
|
||
: '–'}
|
||
</td>
|
||
<td className="wk-bl-nowrap">{row.found}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="wk-bl">
|
||
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p>
|
||
{single ? (
|
||
<div className="toolbar wk-bl-single">
|
||
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
|
||
<button type="button" className="btn" onClick={onClear}>Alle anzeigen</button>
|
||
</div>
|
||
) : (
|
||
<div className="toolbar">
|
||
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
|
||
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||
</select>
|
||
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
||
</div>
|
||
)}
|
||
{body}
|
||
{!single && !loading && !result.error && result.total > 0 && (
|
||
<div className="table-foot">
|
||
<span>{info.label}</span>
|
||
{info.pages > 1 && (
|
||
<span className="wk-bl-pager">
|
||
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
|
||
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|