Files
OpenSquawk/app/pages/bridge/connect.vue
2026-02-16 17:25:53 +01:00

1005 lines
30 KiB
Vue

<template>
<div class="bridge-connect-page">
<div class="bridge-connect-background" aria-hidden="true">
<div class="bridge-connect-background__aurora"/>
<div class="bridge-connect-background__photo"/>
<div class="bridge-connect-background__noise"/>
</div>
<div
v-if="successBlastVisible"
:key="successBlastKey"
class="bridge-success-overlay"
aria-hidden="true"
>
<div class="bridge-success-blast">
<div class="bridge-success-ring bridge-success-ring--one"/>
<div class="bridge-success-ring bridge-success-ring--two"/>
<div class="bridge-success-ring bridge-success-ring--three"/>
<span
v-for="spark in successSparks"
:key="spark"
class="bridge-success-spark"
:style="{
'--spark-angle': `${(spark / successSparks.length) * 360}deg`,
'--spark-delay': `${spark * 0.028}s`,
}"
/>
<div class="bridge-success-core">
<span class="bridge-success-check"></span>
</div>
<p class="bridge-success-label">Bridge linked</p>
</div>
</div>
<main class="relative mx-auto w-full max-w-6xl px-5 pb-14 pt-10 sm:px-7 lg:px-10">
<nav class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<NuxtLink
to="/bridge"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-white/[0.15] bg-white/5 px-4 py-2 text-sm font-medium text-white transition hover:border-white/[0.3] hover:bg-white/10 sm:w-auto"
>
<span aria-hidden="true"></span>
Bridge overview
</NuxtLink>
<div class="inline-flex items-center justify-center rounded-full border border-white/[0.12] bg-white/5 px-4 py-2 text-[11px] uppercase tracking-[0.28em] text-white/[0.65]">
Connect Console
</div>
</nav>
<header class="mx-auto mt-9 max-w-3xl space-y-4 text-center sm:text-left">
<p class="text-xs font-semibold uppercase tracking-[0.42em] text-[#16BBD7]">OpenSquawk Bridge</p>
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">
Link your simulator in one clean flow
</h1>
<p class="text-sm text-white/[0.72] sm:text-base">
Just two things matter: signed in account + a pairing code. If your Bridge app opened this page with a URL token, you can link instantly.
</p>
</header>
<section class="mt-10 grid gap-5 lg:grid-cols-[1.08fr_1fr]">
<article class="bridge-surface bridge-surface--warm p-5 sm:p-6 lg:p-7">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-xs uppercase tracking-[0.26em] text-[#7edeee]/85">Step 1</p>
<h2 class="mt-2 text-2xl font-semibold">Pairing code</h2>
</div>
<span
class="rounded-full border px-3 py-1 text-[10px] uppercase tracking-[0.26em]"
:class="hasToken ? 'border-[#16BBD7]/[0.55] bg-[#16BBD7]/[0.14] text-[#9ae8f4]' : 'border-white/[0.15] bg-white/[0.06] text-white/60'"
>
{{ hasToken ? 'Code ready' : 'Waiting for code' }}
</span>
</div>
<div v-if="!hasToken" class="mt-6 space-y-5">
<p class="text-sm text-white/[0.72]">
Paste the 6-character pairing code from your desktop Bridge app.
</p>
<form class="space-y-4" @submit.prevent="applyPairingCode">
<div class="rounded-3xl border border-[#16BBD7]/[0.35] bg-[#081129]/[0.72] p-4">
<label for="pairing-code" class="block text-xs font-semibold uppercase tracking-[0.28em] text-[#9be6f2]/[0.86]">
Pairing code
</label>
<input
id="pairing-code"
v-model="manualTokenInput"
type="text"
inputmode="text"
autocapitalize="characters"
autocomplete="one-time-code"
spellcheck="false"
maxlength="6"
placeholder="A1B2C3"
class="mt-3 w-full rounded-2xl border border-[#16BBD7]/[0.5] bg-[#050c1f] px-4 py-4 text-center font-mono text-3xl font-semibold uppercase tracking-[0.44em] text-[#9be6f2] placeholder:text-[#9be6f2]/[0.35] focus:border-[#72d9ea] focus:outline-none"
@input="onManualTokenInput"
>
<p class="mt-3 text-xs uppercase tracking-[0.24em] text-white/[0.52]">A-Z and 0-9 only</p>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#081226] transition hover:bg-[#13aac5] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/[0.55]"
:disabled="!manualTokenReady"
>
Continue with this code
</button>
<p v-if="manualTokenError" class="text-sm text-amber-200">{{ manualTokenError }}</p>
</form>
</div>
<div v-else class="mt-6 space-y-5">
<p class="text-sm text-white/[0.72]">
Confirmed pairing code. Tap it to copy if you need to paste it back into the Bridge app.
</p>
<button
type="button"
class="bridge-token-btn"
:title="'Click to copy code'"
@click="copyToken"
>
<span class="bridge-token-btn__code">{{ token }}</span>
<span class="bridge-token-btn__hint">{{ copiedToken ? 'Copied' : '' }}</span>
</button>
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-[#16BBD7]/[0.4] bg-[#16BBD7]/[0.12] px-3 py-1 text-[10px] uppercase tracking-[0.25em] text-[#9be6f2]">
{{ hasQueryToken ? 'Loaded from URL' : 'Set manually' }}
</span>
<button
type="button"
class="text-xs uppercase tracking-[0.24em] text-white/[0.68] transition hover:text-white"
@click="clearPairingCode"
>
Use another code
</button>
</div>
</div>
</article>
<article class="bridge-surface p-5 sm:p-6 lg:p-7">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-xs uppercase tracking-[0.26em] text-[#7edeee]/85">Step 2</p>
<h2 class="mt-2 text-2xl font-semibold">Account + link</h2>
</div>
<span
class="rounded-full border px-3 py-1 text-[10px] uppercase tracking-[0.26em]"
:class="isAuthenticated ? 'border-emerald-300/[0.45] bg-emerald-400/[0.12] text-emerald-100' : 'border-white/[0.15] bg-white/[0.06] text-white/60'"
>
{{ isAuthenticated ? 'Signed in' : 'Login needed' }}
</span>
</div>
<div class="mt-6 space-y-4">
<div class="rounded-2xl border border-white/[0.12] bg-[#0B132A]/[0.78] px-4 py-4">
<p class="text-xs uppercase tracking-[0.24em] text-white/[0.52]">Signed in account</p>
<template v-if="isAuthenticated">
<p class="mt-2 text-base font-semibold text-white">{{ authDisplayName }}</p>
<p class="mt-2 text-xs text-white/60">
Wrong user?
<NuxtLink to="/logout" class="font-semibold text-[#86e8f7] transition hover:text-[#b4f3fb]">Logout and switch</NuxtLink>
</p>
</template>
<template v-else>
<p class="mt-2 text-sm text-white/[0.68]">Sign in once, then link this pairing code to your account.</p>
<NuxtLink
:to="loginTarget"
class="mt-4 inline-flex w-full items-center justify-center rounded-2xl bg-[#16BBD7] px-4 py-3 text-sm font-semibold text-[#081226] transition hover:bg-[#13aac5]"
>
Go to login
</NuxtLink>
</template>
</div>
<div class="rounded-2xl border border-white/[0.12] bg-[#0B132A]/[0.78] px-4 py-4">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold">Bridge link status</p>
<span
class="rounded-full px-3 py-1 text-[11px] uppercase tracking-[0.24em]"
:class="alreadyLinked ? 'bg-emerald-400/[0.18] text-emerald-100' : 'bg-white/10 text-white/[0.62]'"
>
{{ alreadyLinked ? 'Linked' : 'Not linked' }}
</span>
</div>
<p class="mt-3 text-sm" :class="alreadyLinked ? 'text-emerald-100/[0.9]' : 'text-white/[0.68]'">
{{ connectionLabel }}
</p>
<p class="mt-1 text-xs text-white/[0.44]">Connected since {{ formattedConnectedAt }}</p>
<div class="mt-5 space-y-3">
<div v-if="alreadyLinked" class="space-y-3">
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-rose-300/30 bg-rose-500/10 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!isAuthenticated || disconnectLoading"
@click="disconnectBridge"
>
<span v-if="disconnectLoading" class="flex items-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-rose-100/35 border-t-rose-100"/>
Unlinking
</span>
<span v-else>Unlink Bridge</span>
</button>
</div>
<button
v-else
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#081226] transition hover:bg-[#13aac5] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/[0.55]"
:disabled="!isAuthenticated || !hasToken || connectLoading"
@click="connectBridge"
>
<span v-if="connectLoading" class="flex items-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-[#081226]/[0.35] border-t-[#081226]"/>
Linking
</span>
<span v-else>Link Bridge now</span>
</button>
</div>
<p v-if="disconnectError" class="mt-3 text-sm text-red-300">{{ disconnectError }}</p>
<p v-if="connectError" class="mt-3 text-sm text-red-300">{{ connectError }}</p>
<p v-if="!hasToken" class="mt-3 text-sm text-white/60">Enter or load a pairing code first.</p>
<p v-else-if="!isAuthenticated" class="mt-3 text-sm text-white/60">Sign in first to enable linking.</p>
<div
v-if="successBannerVisible"
class="bridge-success-banner mt-4 rounded-2xl border border-emerald-400/40 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100"
>
<p class="font-semibold text-emerald-200">Bridge linked</p>
<p class="mt-1 text-emerald-100/80">You can close this page and return to the desktop app.</p>
</div>
</div>
</div>
</article>
</section>
<section class="mt-5 grid gap-3 sm:grid-cols-3">
<article class="bridge-mini-card">
<p class="bridge-mini-card__label">Code</p>
<p class="bridge-mini-card__value">{{ hasToken ? 'Ready' : 'Missing' }}</p>
</article>
<article class="bridge-mini-card">
<p class="bridge-mini-card__label">Auth</p>
<p class="bridge-mini-card__value">{{ isAuthenticated ? 'Ready' : 'Missing' }}</p>
</article>
<article class="bridge-mini-card">
<p class="bridge-mini-card__label">Bridge</p>
<p class="bridge-mini-card__value">{{ alreadyLinked ? 'Linked' : 'Waiting' }}</p>
</article>
</section>
<p v-if="statusLoading" class="mt-4 text-xs uppercase tracking-[0.34em] text-white/[0.42]">Refreshing status </p>
<p v-if="statusError" class="mt-2 text-sm text-red-300">{{ statusError }}</p>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useHead, useRoute, useRouter } from '#imports'
import { useAuthStore } from '~/stores/auth'
interface BridgeStatusPayload {
token: string
connected: boolean
user: {
id: string
email: string
name?: string | null
} | null
simConnected: boolean
flightActive: boolean
connectedAt: string | null
lastStatusAt: string | null
}
useHead({ title: 'Link Bridge · OpenSquawk' })
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { user, accessToken, isAuthenticated, initialized } = storeToRefs(auth)
const connectLoading = ref(false)
const connectError = ref('')
const connectSuccess = ref(false)
const disconnectLoading = ref(false)
const disconnectError = ref('')
const connectionStatus = ref<BridgeStatusPayload | null>(null)
const statusInitialized = ref(false)
const statusError = ref('')
const statusLoading = ref(false)
const statusRequestActive = ref(false)
const copiedToken = ref(false)
const manualTokenInput = ref('')
const manualTokenError = ref('')
const successBlastVisible = ref(false)
const successBlastKey = ref(0)
const autoConnectAttemptKey = ref('')
const token = computed(() => {
const value = route.query.token
const rawToken = Array.isArray(value)
? value[0] || ''
: typeof value === 'string'
? value
: ''
return rawToken.trim()
})
const hasToken = computed(() => token.value.length >= 6)
const hasQueryToken = computed(() => {
const value = route.query.token
if (Array.isArray(value)) {
return Boolean(value[0])
}
return typeof value === 'string' && value.trim().length > 0
})
const manualTokenRemaining = computed(() => Math.max(0, 6 - manualTokenInput.value.length))
const manualTokenReady = computed(() => manualTokenRemaining.value === 0)
const loginTarget = computed(() => `/login?redirect=${encodeURIComponent(route.fullPath || '/bridge')}`)
const authDisplayName = computed(() => {
if (!user.value) return ''
return user.value.name || user.value.email
})
const connectionLabel = computed(() => {
if (!hasToken.value) {
return 'Pairing code missing'
}
if (!connectionStatus.value?.connected) {
return 'Waiting for confirmation'
}
const name = connectionStatus.value.user?.name || connectionStatus.value.user?.email
return name ? `Linked as ${name}` : 'Linked successfully'
})
const formattedConnectedAt = computed(() => formatTimestamp(connectionStatus.value?.connectedAt ?? null))
const successBannerVisible = computed(() => connectSuccess.value || Boolean(connectionStatus.value?.connected))
const alreadyLinked = computed(() => Boolean(connectionStatus.value?.connected))
const successSparks = Array.from({ length: 14 }, (_unused, index) => index)
function sanitizePairingCode(input: string) {
return input.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
}
let successBlastTimer: ReturnType<typeof setTimeout> | null = null
function triggerSuccessBlast() {
successBlastKey.value += 1
successBlastVisible.value = true
if (successBlastTimer) {
clearTimeout(successBlastTimer)
}
successBlastTimer = setTimeout(() => {
successBlastVisible.value = false
successBlastTimer = null
}, 2200)
}
function onManualTokenInput() {
manualTokenInput.value = sanitizePairingCode(manualTokenInput.value)
if (manualTokenError.value && manualTokenReady.value) {
manualTokenError.value = ''
}
}
async function applyPairingCode() {
manualTokenInput.value = sanitizePairingCode(manualTokenInput.value)
if (!manualTokenReady.value) {
manualTokenError.value = 'Please enter a valid 6-character pairing code.'
return
}
manualTokenError.value = ''
connectError.value = ''
await router.replace({
query: {
...route.query,
token: manualTokenInput.value,
},
})
}
async function clearPairingCode() {
manualTokenInput.value = ''
manualTokenError.value = ''
connectError.value = ''
disconnectError.value = ''
const query = { ...route.query }
delete query.token
await router.replace({ query })
}
function formatTimestamp(value: string | null) {
if (!value) {
return '—'
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return '—'
}
return new Intl.DateTimeFormat('en-GB', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(parsed)
}
async function connectBridge() {
if (!hasToken.value || !accessToken.value) {
return
}
connectLoading.value = true
connectError.value = ''
disconnectError.value = ''
try {
await $fetch('/api/bridge/connect', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken.value}`,
'x-bridge-token': token.value,
},
})
connectSuccess.value = true
await fetchStatus(true)
} catch (err: any) {
connectError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'Could not link the token.'
} finally {
connectLoading.value = false
}
}
async function disconnectBridge() {
if (!hasToken.value || !accessToken.value) {
return
}
disconnectLoading.value = true
disconnectError.value = ''
connectError.value = ''
try {
await $fetch('/api/bridge/disconnect', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken.value}`,
'x-bridge-token': token.value,
},
})
connectSuccess.value = false
autoConnectAttemptKey.value = `${token.value}:${accessToken.value}`
await fetchStatus(true)
} catch (err: any) {
disconnectError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'Could not unlink the token.'
} finally {
disconnectLoading.value = false
}
}
async function maybeAutoConnect() {
if (!hasToken.value || !initialized.value || !isAuthenticated.value || !accessToken.value || connectLoading.value) {
return
}
const attemptKey = `${token.value}:${accessToken.value}`
if (autoConnectAttemptKey.value === attemptKey) {
return
}
if (connectionStatus.value?.connected) {
autoConnectAttemptKey.value = attemptKey
return
}
autoConnectAttemptKey.value = attemptKey
await connectBridge()
}
async function fetchStatus(force = false) {
if (!hasToken.value) {
return
}
if (statusRequestActive.value) {
return
}
statusRequestActive.value = true
if (force || !statusInitialized.value) {
statusLoading.value = true
}
try {
const response = await $fetch<BridgeStatusPayload>('/api/bridge/me', {
headers: {
'x-bridge-token': token.value,
},
})
connectionStatus.value = response
statusError.value = ''
statusInitialized.value = true
if (response.connected) {
connectSuccess.value = true
}
} catch (err: any) {
statusError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'We could not refresh the status.'
} finally {
statusRequestActive.value = false
statusLoading.value = false
}
}
async function copyToken() {
if (!hasToken.value) return
if (typeof navigator === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(token.value)
copiedToken.value = true
setTimeout(() => {
copiedToken.value = false
}, 1800)
} catch {
// Ignore clipboard errors.
}
}
let poller: ReturnType<typeof setInterval> | null = null
function stopPolling() {
if (poller) {
clearInterval(poller)
poller = null
}
}
function startPolling() {
stopPolling()
if (!hasToken.value) {
return
}
fetchStatus(!statusInitialized.value)
poller = setInterval(() => {
fetchStatus()
}, 5000)
}
watch(token, () => {
connectError.value = ''
disconnectError.value = ''
connectSuccess.value = false
autoConnectAttemptKey.value = ''
connectionStatus.value = null
statusInitialized.value = false
statusError.value = ''
if (successBlastTimer) {
clearTimeout(successBlastTimer)
successBlastTimer = null
}
successBlastVisible.value = false
startPolling()
})
watch(
() => isAuthenticated.value,
(value) => {
if (value && hasToken.value) {
fetchStatus(true)
}
},
)
watch(
[hasToken, initialized, isAuthenticated, accessToken, () => connectionStatus.value?.connected ?? false],
() => {
void maybeAutoConnect()
},
{ immediate: true },
)
watch(
() => successBannerVisible.value,
(visible, wasVisible) => {
if (visible && !wasVisible) {
triggerSuccessBlast()
}
},
)
onMounted(() => {
if (!initialized.value) {
auth.fetchUser().catch(() => {})
}
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
if (successBlastTimer) {
clearTimeout(successBlastTimer)
successBlastTimer = null
}
})
</script>
<style scoped>
.bridge-connect-page {
position: relative;
min-height: 100vh;
overflow: hidden;
color: #fff;
background:
radial-gradient(130% 80% at 14% -8%, rgba(22, 187, 215, 0.18), transparent 65%),
radial-gradient(110% 72% at 86% -12%, rgba(70, 98, 255, 0.22), transparent 70%),
linear-gradient(180deg, #070d1d 0%, #070b1a 48%, #050816 100%);
}
.bridge-connect-background {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 0;
}
.bridge-connect-background__aurora {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 28% 14%, rgba(113, 242, 255, 0.12), transparent 48%),
radial-gradient(circle at 75% 9%, rgba(22, 187, 215, 0.12), transparent 44%),
radial-gradient(circle at 52% 86%, rgba(6, 182, 212, 0.1), transparent 56%);
}
.bridge-connect-background__photo {
position: absolute;
inset: -8%;
background-image: url('/img/bridge/goldengate_angle.jpeg');
background-position: center 30%;
background-size: cover;
opacity: 0.34;
filter: saturate(1.08) contrast(1.06) brightness(0.82);
transform: scale(1.06);
-webkit-mask-image: radial-gradient(circle at 50% 30%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.86) 26%, rgba(0, 0, 0, 0.34) 58%, transparent 79%);
mask-image: radial-gradient(circle at 50% 30%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.86) 26%, rgba(0, 0, 0, 0.34) 58%, transparent 79%);
}
.bridge-connect-background__noise {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 50%, rgba(6, 11, 24, 0.12), rgba(6, 11, 24, 0.72));
}
.bridge-surface {
position: relative;
border-radius: 1.8rem;
border: 1px solid rgba(255, 255, 255, 0.13);
background:
linear-gradient(150deg, rgba(13, 23, 46, 0.82), rgba(7, 14, 32, 0.84));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 26px 70px rgba(2, 7, 22, 0.5);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
animation: bridge-surface-in 580ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bridge-surface--warm {
background:
linear-gradient(165deg, rgba(16, 32, 64, 0.86), rgba(8, 16, 36, 0.86));
}
.bridge-token-btn {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 1.3rem;
border: 1px solid rgba(22, 187, 215, 0.55);
background:
radial-gradient(circle at 20% 20%, rgba(22, 187, 215, 0.24), transparent 45%),
rgba(5, 12, 31, 0.84);
padding: 1.05rem 1rem;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.bridge-token-btn:hover {
transform: translateY(-1px);
border-color: rgba(122, 224, 241, 0.8);
box-shadow: 0 14px 34px rgba(5, 35, 55, 0.45);
}
.bridge-token-btn__code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: clamp(1.6rem, 4.8vw, 2.4rem);
font-weight: 700;
line-height: 1;
letter-spacing: 0.42em;
text-transform: uppercase;
color: rgba(165, 240, 250, 0.98);
}
.bridge-token-btn__hint {
font-size: 0.69rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.56);
}
.bridge-mini-card {
border-radius: 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.11);
background: rgba(10, 18, 39, 0.68);
padding: 0.85rem 0.9rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09);
}
.bridge-mini-card__label {
font-size: 0.62rem;
letter-spacing: 0.23em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
}
.bridge-mini-card__value {
margin-top: 0.35rem;
font-size: 0.94rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
}
.bridge-success-banner {
position: relative;
overflow: hidden;
animation: bridge-success-card-in 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bridge-success-banner::before {
content: '';
position: absolute;
inset: -1px;
background: linear-gradient(112deg, transparent 12%, rgba(167, 243, 208, 0.82) 48%, transparent 82%);
transform: translateX(-120%);
animation: bridge-success-sheen 1200ms ease-out forwards;
pointer-events: none;
}
.bridge-success-overlay {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
overflow: hidden;
}
.bridge-success-blast {
position: relative;
display: grid;
place-items: center;
width: min(74vmin, 440px);
aspect-ratio: 1;
animation: bridge-success-fade 2200ms ease-out forwards;
}
.bridge-success-core {
position: relative;
z-index: 3;
display: grid;
place-items: center;
width: 6.25rem;
height: 6.25rem;
border-radius: 999px;
border: 2px solid rgba(74, 222, 128, 0.78);
background: radial-gradient(circle at 30% 30%, rgba(167, 243, 208, 0.92), rgba(16, 185, 129, 0.72) 62%, rgba(5, 150, 105, 0.84));
box-shadow:
0 0 60px rgba(16, 185, 129, 0.85),
inset 0 0 28px rgba(220, 252, 231, 0.55);
animation: bridge-success-core 700ms cubic-bezier(0.22, 1, 0.36, 1);
}
.bridge-success-check {
font-size: 2.5rem;
font-weight: 700;
color: rgba(4, 32, 18, 0.8);
text-shadow: 0 2px 8px rgba(220, 252, 231, 0.45);
}
.bridge-success-label {
position: absolute;
bottom: 14%;
z-index: 3;
margin: 0;
border-radius: 999px;
border: 1px solid rgba(167, 243, 208, 0.52);
background: rgba(6, 95, 70, 0.72);
padding: 0.55rem 1rem;
font-size: 0.76rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(236, 253, 245, 0.96);
animation: bridge-success-label 820ms cubic-bezier(0.16, 1, 0.3, 1);
}
.bridge-success-ring {
position: absolute;
inset: auto;
border-radius: 999px;
border: 2px solid rgba(110, 231, 183, 0.62);
opacity: 0;
}
.bridge-success-ring--one {
width: 38%;
aspect-ratio: 1;
animation: bridge-success-ring 980ms cubic-bezier(0.16, 1, 0.3, 1);
}
.bridge-success-ring--two {
width: 54%;
aspect-ratio: 1;
animation: bridge-success-ring 980ms 120ms cubic-bezier(0.16, 1, 0.3, 1);
}
.bridge-success-ring--three {
width: 72%;
aspect-ratio: 1;
animation: bridge-success-ring 980ms 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.bridge-success-spark {
position: absolute;
left: 50%;
top: 50%;
width: 0.33rem;
height: 3.6rem;
border-radius: 999px;
background: linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(16, 185, 129, 0));
opacity: 0;
transform-origin: 50% 0%;
animation: bridge-success-spark 920ms var(--spark-delay) cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes bridge-surface-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bridge-success-card-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bridge-success-sheen {
to {
transform: translateX(120%);
}
}
@keyframes bridge-success-fade {
0% {
opacity: 0;
transform: scale(0.88);
}
15% {
opacity: 1;
transform: scale(1);
}
76% {
opacity: 1;
}
100% {
opacity: 0;
transform: scale(1.08);
}
}
@keyframes bridge-success-core {
0% {
transform: scale(0.45);
opacity: 0;
}
60% {
transform: scale(1.12);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes bridge-success-label {
from {
opacity: 0;
transform: translateY(14px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bridge-success-ring {
0% {
opacity: 0.62;
transform: scale(0.25);
}
100% {
opacity: 0;
transform: scale(1.72);
}
}
@keyframes bridge-success-spark {
0% {
opacity: 0;
transform: rotate(var(--spark-angle)) translateY(-0.5rem) scaleY(0.4);
}
24% {
opacity: 1;
}
100% {
opacity: 0;
transform: rotate(var(--spark-angle)) translateY(-11.25rem) scaleY(1);
}
}
@media (max-width: 640px) {
.bridge-connect-background__photo {
opacity: 0.28;
-webkit-mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.78) 30%, rgba(0, 0, 0, 0.25) 62%, transparent 88%);
mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.78) 30%, rgba(0, 0, 0, 0.25) 62%, transparent 88%);
}
.bridge-token-btn__code {
letter-spacing: 0.3em;
}
}
@media (prefers-reduced-motion: reduce) {
.bridge-surface,
.bridge-success-banner,
.bridge-success-banner::before,
.bridge-success-blast,
.bridge-success-core,
.bridge-success-label,
.bridge-success-ring,
.bridge-success-spark {
animation: none !important;
}
}
</style>