fix: clear every open Sonar security issue, and half the reliability ones (#2037)

* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
This commit is contained in:
Maurice
2026-08-25 15:22:38 +02:00
committed by GitHub
parent 7da0080557
commit 2c2d008dc2
239 changed files with 1303 additions and 1097 deletions
@@ -236,7 +236,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const isSelected = selected.has(f.id)
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}`
return (
<button
<button type="button"
key={f.id}
onClick={() => !already && toggle(f.id)}
disabled={already}
@@ -279,7 +279,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
return (
<div key={chainKey(chain)} style={{ border: '1px solid var(--border-primary)', borderRadius: 12, padding: '8px 8px 0', marginBottom: 8 }}>
{chain.map(renderFlight)}
<button
<button type="button"
onClick={() => toggleJoin(chain)}
className="bg-transparent"
style={{
@@ -327,7 +327,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<div style={{ flex: 1, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.airtrail.title')}
</div>
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<button type="button" onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<X size={16} />
</button>
</div>
@@ -371,13 +371,13 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
<button
<button type="button"
onClick={handleClose}
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={handleImport}
disabled={selectableCount === 0 || importing}
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
@@ -130,7 +130,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
<div style={{ flex: 1, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.import.title')}
</div>
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<button type="button" onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<X size={16} />
</button>
</div>
@@ -184,13 +184,13 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
{/* Footer */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
<button
<button type="button"
onClick={handleClose}
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
@@ -174,7 +174,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
</span>
{canEditDays && onUpdateDayTitle && (
<button onClick={startRename} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<button type="button" onClick={startRename} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={12} strokeWidth={1.8} />
</button>
)}
@@ -182,14 +182,14 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)}
{!collapsed && formattedDate && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 1 }}>{formattedDate}</div>}
</div>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
<button type="button" onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
className="bg-surface-secondary"
style={{ border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
{collapsed ? <ChevronsUp size={14} className="text-content-muted" /> : <ChevronsDown size={14} className="text-content-muted" />}
</button>
<button onClick={(e) => { e.stopPropagation(); onClose() }} className="bg-surface-secondary" style={{ border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}
<button type="button" onClick={(e) => { e.stopPropagation(); onClose() }} className="bg-surface-secondary" style={{ border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
<X size={14} className="text-content-muted" />
@@ -409,11 +409,11 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
</div>
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_in_end: acc.check_in_end || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
{canEditDays && <button type="button" onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_in_end: acc.check_in_end || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>}
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
{canEditDays && <button type="button" onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<X size={12} style={{ color: 'var(--text-faint)' }} />
</button>}
</div>
@@ -468,7 +468,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
)
})}
{/* Add another hotel */}
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
{canEditDays && <button type="button" onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit',
@@ -477,7 +477,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
</button>}
</div>
) : (
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
canEditDays ? <button type="button" onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit',
@@ -546,7 +546,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
<div style={{ padding: '16px 18px 12px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', gap: 10 }}>
<Hotel size={16} style={{ color: 'var(--text-primary)' }} />
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', flex: 1 }}>{showHotelPicker === 'edit' ? t('day.editAccommodation') : t('day.addAccommodation')}</span>
<button onClick={() => setShowHotelPicker(false)} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 8, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<button type="button" onClick={() => setShowHotelPicker(false)} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 8, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<X size={12} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
@@ -584,7 +584,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
size="sm"
/>
</div>
<button onClick={() => setHotelDayRange({ start: days[0]?.id, end: days[days.length - 1]?.id })} style={{
<button type="button" onClick={() => setHotelDayRange({ start: days[0]?.id, end: days[days.length - 1]?.id })} style={{
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', flexShrink: 0,
background: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--text-primary)' : 'var(--bg-card)',
color: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--bg-card)' : 'var(--text-muted)',
@@ -618,14 +618,14 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
{/* Category Filter */}
{categories.length > 0 && (
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button onClick={() => setHotelCategoryFilter('')} style={{
<button type="button" onClick={() => setHotelCategoryFilter('')} style={{
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer',
background: !hotelCategoryFilter ? 'var(--text-primary)' : 'var(--bg-secondary)',
color: !hotelCategoryFilter ? 'var(--bg-card)' : 'var(--text-muted)',
}}>{t('day.allDays')}</button>
{categories.map(c => (
<button key={c.id} onClick={() => setHotelCategoryFilter(c.id)} style={{
<button type="button" key={c.id} onClick={() => setHotelCategoryFilter(c.id)} style={{
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer',
background: hotelCategoryFilter === c.id ? c.color || 'var(--text-primary)' : 'var(--bg-secondary)',
color: hotelCategoryFilter === c.id ? '#fff' : 'var(--text-muted)',
@@ -641,7 +641,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
return filtered.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
) : filtered.map(p => (
<button key={p.id} onClick={() => handleSelectPlace(p.id)} style={{
<button type="button" key={p.id} onClick={() => handleSelectPlace(p.id)} style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
border: 'none', borderBottom: '1px solid var(--border-faint)',
background: hotelForm.place_id === p.id ? 'var(--bg-hover)' : 'none',
@@ -671,10 +671,10 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
{/* Save / Cancel */}
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
<button type="button" onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={() => { void saveHotelPicker() }} disabled={!hotelForm.place_id} style={{
<button type="button" onClick={() => { void saveHotelPicker() }} disabled={!hotelForm.place_id} style={{
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
@@ -306,7 +306,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
assignmentId: dragDataRef.current.assignmentId || '',
noteId: dragDataRef.current.noteId || '',
reservationId: dragDataRef.current.reservationId || '',
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
fromDayId: Number.parseInt(dragDataRef.current.fromDayId) || 0,
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
}
}
@@ -990,7 +990,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
}
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
onAssignToDay?.(Number.parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
const capturedFromDayId = fromDayId
@@ -1739,7 +1739,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()
) : (
<button onClick={e => toggleDay(day.id, e)} className="text-content-faint" style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<button type="button" onClick={e => toggleDay(day.id, e)} className="text-content-faint" style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
</button>
)}
@@ -1765,7 +1765,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
onAssignToDay?.(Number.parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
@@ -1791,7 +1791,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
onAssignToDay?.(Number.parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (assignmentId && fromDayId !== day.id) {
@@ -1909,7 +1909,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
onAssignToDay?.(Number.parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
@@ -2154,15 +2154,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
</div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={idx === 0} className={idx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<button type="button" onClick={moveUp} disabled={idx === 0} className={idx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={idx === merged.length - 1} className={idx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<button type="button" onClick={moveDown} disabled={idx === merged.length - 1} className={idx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button
<button type="button"
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
@@ -2289,7 +2289,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const insertAfter = e.clientY > rect.top + rect.height / 2
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
onAssignToDay?.(Number.parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r2 = reservations.find(x => x.id === Number(fromReservationId))
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
@@ -2464,7 +2464,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
onAssignToDay?.(parseInt(placeId), day.id, pos)
onAssignToDay?.(Number.parseInt(placeId), day.id, pos)
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
@@ -2549,12 +2549,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
every note a shorter line for the whole time you are
not hovering. */}
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
</div>
</React.Fragment>
@@ -2580,7 +2580,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
// Neuer Ort von der Orte-Liste
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
onAssignToDay?.(Number.parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (fromReservationId && fromDayId !== day.id) {
@@ -2618,7 +2618,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
<button type="button"
onClick={() => {
if (showRouteToolsWhenExpanded) {
// Mobile: toggle this day's inline leg distances in place.
@@ -2647,7 +2647,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
@@ -2666,7 +2666,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
<button
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
@@ -2688,7 +2688,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
sat against both edges as soon as the row filled up, and
the row gained a button when CoMaps arrived. The label
stays as the accessible name and the tooltip. */}
<button
<button type="button"
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
title={t('dayplan.optimize')}
@@ -2706,7 +2706,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
const active = (day.default_transport_mode ?? routeProfile) === p.key
return (
<button
<button type="button"
key={p.key}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
@@ -24,7 +24,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA
return (
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
{!open ? (
<button
<button type="button"
onClick={e => { e.stopPropagation(); setOpen(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
@@ -47,7 +47,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA
placeholder={t('dayplan.mobile.searchPlaces')}
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', color: 'var(--text-primary)' }}
/>
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<button type="button" onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
@@ -58,7 +58,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA
</div>
)}
{filtered.slice(0, 20).map(p => (
<button
<button type="button"
key={p.id}
onClick={() => {
onAssign?.(p.id, dayId)
@@ -77,7 +77,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA
))}
</div>
{onAddNew && (
<button
<button type="button"
onClick={() => { onAddNew(); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
@@ -47,11 +47,11 @@ export function DayPlanSidebarTimeConfirmModal({ timeConfirm, setTimeConfirm, co
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={() => setTimeConfirm(null)} className="text-content-muted" style={{
<button type="button" onClick={() => setTimeConfirm(null)} className="text-content-muted" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>{t('common.cancel')}</button>
<button onClick={confirmTimeRemoval} className="bg-[#ef4444] text-white" style={{
<button type="button" onClick={confirmTimeRemoval} className="bg-[#ef4444] text-white" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>{t('common.confirm')}</button>
@@ -94,7 +94,7 @@ export function DayPlanSidebarToolbar({
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
return (
<Tooltip label={label} placement="bottom">
<button
<button type="button"
onClick={() => {
const next = allExpanded ? new Set<number>() : new Set(days.map(d => d.id))
setExpandedDays(next)
@@ -138,7 +138,7 @@ export function DayPlanSidebarToolbar({
})()}
{onUndo && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
<button type="button"
onClick={onUndo}
disabled={!canUndo}
aria-label={t('undo.button')}
@@ -172,7 +172,7 @@ export function DayPlanSidebarToolbar({
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
<button
<button type="button"
onClick={() => setReorderOpen(v => !v)}
aria-label={t('dayplan.reorderDays')}
aria-pressed={reorderOpen}
@@ -241,7 +241,7 @@ export function DayPlanSidebarTransportDetailModal({
{/* Aktionen */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{onEdit && (
<button onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
<button type="button" onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
@@ -249,7 +249,7 @@ export function DayPlanSidebarTransportDetailModal({
<Pencil size={12} /> {t('common.edit')}
</button>
)}
<button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
<button type="button" onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>
@@ -56,7 +56,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
size="md"
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button
<button type="button"
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
@@ -66,7 +66,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
>
{t('common.close')}
</button>
<button
<button type="button"
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
@@ -360,7 +360,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
<button type="button"
onClick={handleClose}
style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
@@ -370,7 +370,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={handleImport}
disabled={!canImport}
className={canImport ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
@@ -89,7 +89,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
</div>
<button
<button type="button"
onClick={onClose}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
@@ -110,7 +110,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
</div>
) : !feedUrl ? (
<>
<button
<button type="button"
onClick={() => mutate('POST')}
disabled={busy}
style={{
@@ -133,7 +133,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
<button
<button type="button"
onClick={() => mutate('PUT')}
disabled={busy}
style={{
@@ -148,7 +148,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate
</button>
<button
<button type="button"
onClick={() => mutate('DELETE')}
disabled={busy}
style={{
@@ -66,8 +66,8 @@ function findDuplicatePlace(
): { name?: string | null } | null {
const name = (form.name || '').trim().toLowerCase()
const gid = (form.google_place_id || '').trim()
const lat = form.lat ? parseFloat(form.lat) : null
const lng = form.lng ? parseFloat(form.lng) : null
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
for (const p of places || []) {
if (gid && p.google_place_id && p.google_place_id === gid) return p
if (name && p.name && p.name.trim().toLowerCase() === name) return p
@@ -473,8 +473,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
try {
const saved = await onSave({
...form,
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
@@ -580,7 +580,7 @@ export function ActionButton({ onClick, variant, icon, label, ref }: ActionButto
}
const s = base[variant] || base.ghost
return (
<button
<button type="button"
ref={ref}
onClick={onClick}
style={{
@@ -676,7 +676,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
{/* Add button */}
{availableToAdd.length > 0 && (
<div style={{ position: 'relative' }}>
<button onClick={() => setShowAdd(!showAdd)} className="text-content-faint" style={{
<button type="button" onClick={() => setShowAdd(!showAdd)} className="text-content-faint" style={{
width: 22, height: 22, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', transition: 'all 0.12s',
@@ -692,7 +692,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 140,
}}>
{availableToAdd.map(member => (
<button key={member.id} onClick={() => handleAdd(member.id)} className="text-content" style={{
<button type="button" key={member.id} onClick={() => handleAdd(member.id)} className="text-content" style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textAlign: 'left',
@@ -807,7 +807,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
</div>
)}
</div>
<button
<button type="button"
onClick={onClose}
className="bg-surface-hover"
style={{ width: 28, height: 28, borderRadius: '50%', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
@@ -940,7 +940,7 @@ function TrackColorRow({ place, trackColor, onUpdatePlace, t }: any) {
const [open, setOpen] = useState(false)
return (
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
<button type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
style={{
@@ -980,7 +980,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<button
<button type="button"
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
@@ -1111,7 +1111,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
{(placeFiles.length > 0 || onFileUpload) && (
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
<button type="button"
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
@@ -1135,7 +1135,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
<button type="button" key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
@@ -37,7 +37,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('places.changeCategory')}</span>
<button onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
<button type="button" onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
<X size={15} strokeWidth={2} />
</button>
</div>
@@ -47,7 +47,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
{categories.map(c => {
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<button type="button" key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<CatIcon size={14} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
<span style={{ flex: 1 }}>{c.name}</span>
</button>
@@ -30,7 +30,7 @@ export function PlacesHeader(S: SidebarState) {
} = S
return (
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
{canEditPlaces && <button
{canEditPlaces && <button type="button"
onClick={onAddPlace}
className="bg-accent text-accent-text"
style={{
@@ -44,7 +44,7 @@ export function PlacesHeader(S: SidebarState) {
</button>}
{canEditPlaces && <>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
<button type="button"
onClick={() => setFileImportOpen(true)}
className="border border-dashed border-edge text-content-faint"
style={{
@@ -56,7 +56,7 @@ export function PlacesHeader(S: SidebarState) {
>
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
</button>
<button
<button type="button"
onClick={() => setListImportOpen(true)}
className="border border-dashed border-edge text-content-faint"
style={{
@@ -101,7 +101,7 @@ export function PlacesHeader(S: SidebarState) {
{tabs.map(f => {
const active = filter === f.id
return (
<button
<button type="button"
key={f.id}
onClick={() => { setFilter(f.id); setSelectedIds(new Set()) }}
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
@@ -145,7 +145,7 @@ export function PlacesHeader(S: SidebarState) {
}}
/>
{search && (
<button onClick={() => setSearch('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex' }}>
<button type="button" onClick={() => setSearch('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex' }}>
<X size={12} strokeWidth={2} color="var(--text-faint)" />
</button>
)}
@@ -162,7 +162,7 @@ export function PlacesHeader(S: SidebarState) {
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
{categories.length > 0 && <button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
{categories.length > 0 && <button type="button" onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
@@ -173,7 +173,7 @@ export function PlacesHeader(S: SidebarState) {
</button>}
{/* Minimum-stars filter (#1435), same floors as the collections bar. */}
<Tooltip label={t('places.filterByRating')} placement="bottom">
<button
<button type="button"
onClick={() => { setStarDropOpen(v => !v); setCatDropOpen(false) }}
aria-label={t('places.filterByRating')}
aria-expanded={starDropOpen}
@@ -203,7 +203,7 @@ export function PlacesHeader(S: SidebarState) {
{(['all', 5, 4, 3, 2, 1] as const).map(opt => {
const active = ratingFilter === opt
return (
<button
<button type="button"
key={String(opt)}
onClick={() => { setRatingFilter(opt as number | 'all'); setStarDropOpen(false) }}
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
@@ -230,7 +230,7 @@ export function PlacesHeader(S: SidebarState) {
)}
{canEditPlaces && (
<Tooltip label={t('common.select')} placement="bottom">
<button
<button type="button"
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
aria-label={t('common.select')}
aria-pressed={selectMode}
@@ -273,7 +273,7 @@ export function PlacesHeader(S: SidebarState) {
const active = categoryFilters.has(String(c.id))
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`} style={{
<button type="button" key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
@@ -295,7 +295,7 @@ export function PlacesHeader(S: SidebarState) {
{places.some(p => p.category_id == null) && (() => {
const active = categoryFilters.has('uncategorized')
return (
<button onClick={() => toggleCategoryFilter('uncategorized')} className={`text-content-muted ${active ? 'bg-surface-hover' : 'bg-transparent'}`} style={{
<button type="button" onClick={() => toggleCategoryFilter('uncategorized')} className={`text-content-muted ${active ? 'bg-surface-hover' : 'bg-transparent'}`} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
@@ -314,7 +314,7 @@ export function PlacesHeader(S: SidebarState) {
)
})()}
{categoryFilters.size > 0 && (
<button onClick={() => setCategoryFilters(new Set())} className="bg-transparent text-content-faint" style={{
<button type="button" onClick={() => setCategoryFilters(new Set())} className="bg-transparent text-content-faint" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
@@ -18,7 +18,7 @@ export function PlacesList(S: SidebarState) {
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
{canEditPlaces && <button onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{canEditPlaces && <button type="button" onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>}
</div>
@@ -25,7 +25,7 @@ export function ListImportModal(S: SidebarState) {
{hasMultipleListImportProviders && (
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{availableListImportProviders.map(provider => (
<button
<button type="button"
key={provider}
onClick={() => setListImportProvider(provider)}
className={listImportProvider === provider ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}
@@ -67,7 +67,7 @@ export function ListImportModal(S: SidebarState) {
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
<button type="button"
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
className="text-content"
style={{
@@ -78,7 +78,7 @@ export function ListImportModal(S: SidebarState) {
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={handleListImport}
disabled={!listImportUrl.trim() || listImportLoading}
className={!listImportUrl.trim() || listImportLoading ? 'bg-surface-tertiary text-content-faint' : 'bg-accent text-accent-text'}
@@ -23,7 +23,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
</div>
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
{/* View details */}
<button
<button type="button"
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
className="bg-transparent text-content"
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}
@@ -32,7 +32,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
</button>
{/* Edit */}
{canEditPlaces && (
<button
<button type="button"
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
className="bg-transparent text-content"
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}
@@ -43,7 +43,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
{/* Assign to day */}
{days?.length > 0 && (
<>
<button
<button type="button"
onClick={() => setMobileShowDays(v => !v)}
className="bg-transparent text-content"
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}
@@ -54,7 +54,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
{mobileShowDays && (
<div style={{ paddingLeft: 20 }}>
{days.map((day, i) => (
<button
<button type="button"
key={day.id}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
@@ -73,7 +73,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
)}
{/* Delete */}
{canEditPlaces && (
<button
<button type="button"
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: '#ef4444' }}
>
@@ -121,7 +121,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!selectMode && !inDay && selectedDayId && (
<button
<button type="button"
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="bg-surface-hover text-content-faint"
style={{
@@ -14,7 +14,7 @@ export function PlacesSelectionBar(S: SidebarState) {
{t('places.selectionCount', { count: selectedIds.size })}
</span>
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
<button
<button type="button"
onClick={() => {
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filtered.map(p => p.id)))
@@ -33,7 +33,7 @@ export function PlacesSelectionBar(S: SidebarState) {
</button>
</Tooltip>
<Tooltip label={t('places.changeCategory')} placement="bottom">
<button
<button type="button"
onClick={() => { if (selectedIds.size === 0) return; setCategoryPickerOpen(true) }}
disabled={selectedIds.size === 0}
aria-label={t('places.changeCategory')}
@@ -51,7 +51,7 @@ export function PlacesSelectionBar(S: SidebarState) {
</Tooltip>
{collectionsEnabled && (
<Tooltip label={t('inspector.saveToCollection')} placement="bottom">
<button
<button type="button"
onClick={() => { if (selectedIds.size === 0) return; setSaveToListOpen(true) }}
disabled={selectedIds.size === 0}
aria-label={t('inspector.saveToCollection')}
@@ -70,7 +70,7 @@ export function PlacesSelectionBar(S: SidebarState) {
)}
{collectionsEnabled && (
<Tooltip label={t('collections.markVisitedSelection')} placement="bottom">
<button
<button type="button"
onClick={() => { if (selectedIds.size === 0) return; void markSelectionVisited() }}
disabled={selectedIds.size === 0 || markVisitedBusy}
aria-label={t('collections.markVisitedSelection')}
@@ -88,7 +88,7 @@ export function PlacesSelectionBar(S: SidebarState) {
</Tooltip>
)}
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
<button type="button"
onClick={() => {
if (selectedIds.size === 0) return
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
@@ -229,7 +229,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
maxWidth: 140, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{r.title}</span>
{canEdit && (
<button onClick={() => onEdit(r)} title={t('common.edit')} className="bg-transparent text-content-faint" style={{
<button type="button" onClick={() => onEdit(r)} title={t('common.edit')} className="bg-transparent text-content-faint" style={{
appearance: 'none', border: 'none',
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
cursor: 'pointer', flexShrink: 0,
@@ -240,7 +240,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</button>
)}
{canEdit && (
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} className="bg-transparent text-content-faint" style={{
<button type="button" onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} className="bg-transparent text-content-faint" style={{
appearance: 'none', border: 'none',
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
cursor: 'pointer', flexShrink: 0,
@@ -522,11 +522,11 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.confirm.deleteBody', { name: r.title })}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={() => setShowDeleteConfirm(false)} className="text-content-muted" style={{
<button type="button" onClick={() => setShowDeleteConfirm(false)} className="text-content-muted" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>{t('common.cancel')}</button>
<button onClick={handleDelete} className="bg-[#ef4444] text-white" style={{
<button type="button" onClick={handleDelete} className="bg-[#ef4444] text-white" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>{t('common.confirm')}</button>
@@ -561,7 +561,7 @@ function Section({ title, count, children, defaultOpen = true, accent, storageKe
}, [open, storageKey])
return (
<div style={{ marginBottom: 28 }}>
<button onClick={() => setOpen(o => !o)} style={{
<button type="button" onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 12, fontFamily: 'inherit',
userSelect: 'none',
@@ -632,7 +632,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
</div>
</div>
{canEdit && (
<button
<button type="button"
onClick={e => { e.stopPropagation(); setConfirmOpen(true) }}
title={t('common.delete')}
className="bg-transparent text-content-faint"
@@ -680,8 +680,8 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}</button>
<button type="button" onClick={e => { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={e => { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}</button>
</div>
</div>
</div>,
@@ -837,7 +837,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<button
<button type="button"
onClick={() => { setTypeFilters(new Set()); sessionStorage.removeItem(storageKey) }}
className={typeFilters.size === 0 ? 'bg-surface-card text-content' : 'bg-transparent text-content-muted'}
style={{
@@ -859,7 +859,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const active = typeFilters.has(opt.value)
const Icon = opt.Icon
return (
<button
<button type="button"
key={opt.value}
onClick={() => toggleTypeFilter(opt.value)}
className={active ? 'bg-surface-card text-content' : 'bg-transparent text-content-muted'}
@@ -896,7 +896,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
{onImport && bookingImportAvailable && (
<button onClick={onImport} className="bg-surface-card text-content" style={{
<button type="button" onClick={onImport} className="bg-surface-card text-content" style={{
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 13px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
@@ -911,7 +911,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
<button type="button" onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, boxSizing: 'border-box',
@@ -925,7 +925,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
</button>
)}
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
<button type="button" onClick={onAdd} className="bg-accent text-accent-text" style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
@@ -952,7 +952,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
// page, so the import in particular went unnoticed (#2007).
action={canEdit ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<button onClick={onAdd} className="bg-accent text-accent-text" style={CTA_STYLE}>
<button type="button" onClick={onAdd} className="bg-accent text-accent-text" style={CTA_STYLE}>
<Plus size={14} strokeWidth={2} />
{t(addManualKey)}
</button>
@@ -114,7 +114,7 @@ function UrlRow({ label, hint, url, copied, onCopy }: {
}}>
{url}
</div>
<button
<button type="button"
onClick={onCopy}
title="Copy"
style={{
@@ -142,7 +142,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{canEdit && (
<button onClick={() => setConfirmDelete(true)} aria-label={t('common.delete')} title={t('common.delete')} style={{
<button type="button" onClick={() => setConfirmDelete(true)} aria-label={t('common.delete')} title={t('common.delete')} style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: isMobile ? '9px 11px' : '8px 14px', borderRadius: 10,
border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.06)', color: '#ef4444',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
@@ -152,7 +152,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
)}
<div style={{ flex: 1 }} />
{canEdit && (
<button onClick={onChangeRoute} className="text-content-muted" style={{
<button type="button" onClick={onChangeRoute} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
@@ -161,7 +161,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
</button>
)}
{canEdit ? (
<button onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
<button type="button" onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
padding: '8px 20px', borderRadius: 10, border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
opacity: saving || !title.trim() || !dirty ? 0.5 : 1,
@@ -169,7 +169,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
{saving ? t('common.saving') : t('common.save')}
</button>
) : (
<button onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<button type="button" onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.close')}
</button>
)}
@@ -198,7 +198,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', minWidth: 0 }}>
<span style={{ minWidth: 0, overflow: 'hidden' }}><TransitTitle title={title} iconSize={15} /></span>
{canEdit && (
<button onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<button type="button" onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={13} strokeWidth={1.8} />
</button>
)}
@@ -149,7 +149,7 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.14)', zIndex: 30, overflow: 'hidden', maxHeight: 240, overflowY: 'auto' }}>
{results.length > 0
? results.map((r, i) => (
<button key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
<button type="button" key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -162,7 +162,7 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
</button>
))
: quickPicks.map((p, i) => (
<button key={i} onClick={() => { onPick(p); setText(''); setOpen(false) }}
<button type="button" key={i} onClick={() => { onPick(p); setText(''); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -206,7 +206,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
const walkMins = Math.round(it.walkSeconds / 60)
return (
<div className="bg-surface-card border border-edge" style={{ borderRadius: 14, overflow: 'hidden' }}>
<button onClick={onToggle} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '12px 14px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
<button type="button" onClick={onToggle} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '12px 14px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>
{fmtTimeInTz(it.startTime, tzFrom, is12h)} – {fmtTimeInTz(it.endTime, tzTo, is12h)}
@@ -289,7 +289,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
{[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')}
</div>
)}
<button
<button type="button"
onClick={onAdd}
disabled={adding}
className="bg-accent text-accent-text"
@@ -507,7 +507,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
{!isMobile && (
<button
<button type="button"
onClick={() => { const f = from; setFrom(to); setTo(f) }}
aria-label={t('transit.swap')}
title={t('transit.swap')}
@@ -525,8 +525,8 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
<button type="button" onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button type="button" onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
</div>
<div style={{ width: 110 }}>
<CustomTimePicker value={time} onChange={setTime} />
@@ -552,7 +552,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
{MODE_GROUPS.map(m => {
const active = activeModes.has(m.key)
return (
<button key={m.key} onClick={() => toggleMode(m.key)}
<button type="button" key={m.key} onClick={() => toggleMode(m.key)}
className={active ? 'bg-surface-card text-content' : 'text-content-faint'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -569,7 +569,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
)
})}
</div>
<button
<button type="button"
onClick={search}
disabled={!from || !to || loading}
className="bg-accent text-accent-text"
@@ -17,7 +17,7 @@ export function convertHoursLine(line: string, timeFormat: string): string {
if (timeFormat === '12h' && !hasAmPm) {
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
const hour = parseInt(h)
const hour = Number.parseInt(h)
if (isNaN(hour)) return match
const period = hour >= 12 ? 'PM' : 'AM'
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
@@ -27,7 +27,7 @@ export function convertHoursLine(line: string, timeFormat: string): string {
if (timeFormat !== '12h' && hasAmPm) {
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
let hour = parseInt(h)
let hour = Number.parseInt(h)
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
return `${String(hour).padStart(2, '0')}:${m}`
@@ -67,8 +67,8 @@ function localParts(now: Date, timeZone: string | null): { day: number; minutes:
}).formatToParts(now)
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(p => p.type === type)?.value ?? ''
const day = WEEKDAY_NUMBER[value('weekday')]
const hour = parseInt(value('hour'), 10)
const minute = parseInt(value('minute'), 10)
const hour = Number.parseInt(value('hour'), 10)
const minute = Number.parseInt(value('minute'), 10)
if (day === undefined || Number.isNaN(hour) || Number.isNaN(minute)) return null
return {
day,