update osm
This commit is contained in:
+158
-7
@@ -37,6 +37,8 @@ const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
|
||||
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
||||
const analyticsConsentVersion = 3
|
||||
|
||||
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||
|
||||
const defaultAnalyticsPreferences = {
|
||||
chosen: false,
|
||||
analytics: false,
|
||||
@@ -349,6 +351,66 @@ function Stat({ label, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
const callbacksRef = useRef({ onVerify, onExpire, onError })
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onVerify, onExpire, onError }
|
||||
}, [onVerify, onExpire, onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) return undefined
|
||||
const container = containerRef.current
|
||||
if (!container) return undefined
|
||||
|
||||
let cancelled = false
|
||||
let pollTimer
|
||||
|
||||
const renderWidget = () => {
|
||||
if (cancelled) return
|
||||
if (!window.turnstile || typeof window.turnstile.render !== 'function') {
|
||||
pollTimer = window.setTimeout(renderWidget, 150)
|
||||
return
|
||||
}
|
||||
const options = {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
size,
|
||||
callback: (token) => callbacksRef.current.onVerify?.(token),
|
||||
'expired-callback': () => callbacksRef.current.onExpire?.(),
|
||||
'error-callback': () => callbacksRef.current.onError?.(),
|
||||
}
|
||||
if (action) options.action = action
|
||||
widgetIdRef.current = window.turnstile.render(container, options)
|
||||
}
|
||||
renderWidget()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(pollTimer)
|
||||
const widgetId = widgetIdRef.current
|
||||
widgetIdRef.current = null
|
||||
if (widgetId != null && window.turnstile?.remove) {
|
||||
try { window.turnstile.remove(widgetId) } catch { /* widget already gone */ }
|
||||
}
|
||||
}
|
||||
}, [siteKey, theme, size, action])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resetSignal) return
|
||||
const widgetId = widgetIdRef.current
|
||||
if (widgetId != null && window.turnstile?.reset) {
|
||||
try { window.turnstile.reset(widgetId) } catch { /* widget gone or already reset */ }
|
||||
}
|
||||
}, [resetSignal])
|
||||
|
||||
if (!siteKey) return null
|
||||
|
||||
return <div ref={containerRef} />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [route, setRoute] = useState(() => parseRoute())
|
||||
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||
@@ -847,7 +909,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAnalyticsConsent(preferences) {
|
||||
function chooseAnalyticsConsent(preferences, turnstileToken = '') {
|
||||
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
||||
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
||||
|
||||
@@ -860,6 +922,7 @@ function App() {
|
||||
body: JSON.stringify({
|
||||
visitor_id: previousVisitorId,
|
||||
session_id: analyticsSessionId,
|
||||
turnstile_token: turnstileToken,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
@@ -985,7 +1048,11 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
||||
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
||||
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
const [turnstileReset, setTurnstileReset] = useState(0)
|
||||
const privacySignalEnabled = browserPrivacySignalEnabled()
|
||||
const visitorId = storedVisitorId(analyticsVisitorKey)
|
||||
const deleteVerificationRequired = Boolean(visitorId) && Boolean(turnstileSiteKey)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(normalizeAnalyticsPreferences(preferences))
|
||||
@@ -1011,10 +1078,19 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
}
|
||||
|
||||
function savePreferences(nextPreferences) {
|
||||
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
||||
const normalized = normalizeAnalyticsPreferences(nextPreferences)
|
||||
const willDelete = Boolean(visitorId) && !normalized.analytics
|
||||
if (willDelete && turnstileSiteKey && !turnstileToken) return
|
||||
onChoose(normalized, willDelete ? turnstileToken : '')
|
||||
setTurnstileToken('')
|
||||
setTurnstileReset((value) => value + 1)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const willDeleteFromDraft = Boolean(visitorId) && !draft.analytics
|
||||
const blockSaveDraft = willDeleteFromDraft && deleteVerificationRequired && !turnstileToken
|
||||
const blockDeclineAll = Boolean(visitorId) && deleteVerificationRequired && !turnstileToken
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
@@ -1106,16 +1182,34 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deleteVerificationRequired ? (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-xs font-semibold text-text-soft">
|
||||
Confirm you are not a bot to delete your existing analytics data:
|
||||
</p>
|
||||
<TurnstileWidget
|
||||
siteKey={turnstileSiteKey}
|
||||
action="analytics-delete"
|
||||
onVerify={(token) => setTurnstileToken(token)}
|
||||
onExpire={() => setTurnstileToken('')}
|
||||
onError={() => setTurnstileToken('')}
|
||||
resetSignal={turnstileReset}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={blockDeclineAll}
|
||||
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||
type="button"
|
||||
>
|
||||
Decline all
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={blockSaveDraft}
|
||||
onClick={() => savePreferences(draft)}
|
||||
type="button"
|
||||
>
|
||||
@@ -1281,6 +1375,43 @@ function Landing({
|
||||
teamQuery,
|
||||
}) {
|
||||
const treeRef = useRef(null)
|
||||
const [searchToken, setSearchToken] = useState('')
|
||||
const [searchTurnstileReset, setSearchTurnstileReset] = useState(0)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [searchSubmitting, setSearchSubmitting] = useState(false)
|
||||
|
||||
async function handleSearchSubmit(event) {
|
||||
event.preventDefault()
|
||||
if (searchSubmitting) return
|
||||
if (!teamQuery.trim()) return
|
||||
|
||||
setSearchError('')
|
||||
setSearchSubmitting(true)
|
||||
try {
|
||||
if (turnstileSiteKey) {
|
||||
if (!searchToken) {
|
||||
setSearchError('Please complete the verification before searching.')
|
||||
return
|
||||
}
|
||||
const verifyResponse = await fetch('/api/turnstile/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token: searchToken, action: 'team-search' }),
|
||||
})
|
||||
if (!verifyResponse.ok) {
|
||||
setSearchError('Verification failed — please try again.')
|
||||
setSearchToken('')
|
||||
setSearchTurnstileReset((value) => value + 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
await onTeamSearch(event)
|
||||
} finally {
|
||||
setSearchSubmitting(false)
|
||||
setSearchToken('')
|
||||
setSearchTurnstileReset((value) => value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||
@@ -1324,7 +1455,7 @@ function Landing({
|
||||
|
||||
<form
|
||||
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
||||
onSubmit={onTeamSearch}
|
||||
onSubmit={handleSearchSubmit}
|
||||
>
|
||||
<input
|
||||
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
||||
@@ -1333,11 +1464,31 @@ function Landing({
|
||||
onChange={(event) => setTeamQuery(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={searchSubmitting || (Boolean(turnstileSiteKey) && !searchToken)}
|
||||
type="submit"
|
||||
>
|
||||
Search teams
|
||||
{searchSubmitting ? 'Searching…' : 'Search teams'}
|
||||
</button>
|
||||
{turnstileSiteKey ? (
|
||||
<div className="sm:col-span-2">
|
||||
<TurnstileWidget
|
||||
siteKey={turnstileSiteKey}
|
||||
size="flexible"
|
||||
action="team-search"
|
||||
onVerify={(token) => {
|
||||
setSearchToken(token)
|
||||
setSearchError('')
|
||||
}}
|
||||
onExpire={() => setSearchToken('')}
|
||||
onError={() => setSearchToken('')}
|
||||
resetSignal={searchTurnstileReset}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchError ? (
|
||||
<p className="text-xs font-semibold text-red-600 sm:col-span-2">{searchError}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user