fix: early name validation, fighter glow, reduced visual noise

- Add /api/auth/check-name endpoint for early name availability check
- JoinBoutPage checks name availability at naming step (not after webhook)
- FightCardPage: replace poster containers with circular glow behind sprites
- Reduce synthwave grid and CRT overlay opacity for cleaner backgrounds
- BotProfilePage: webhook management, owner-only fields
- useNostr: normalize bot data, guard auto-restore, clearAllState helper
- bots.ts: expose owner-only webhook info on stats endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 11:03:16 +00:00
co-authored by Claude Opus 4.6
parent 2d8cdcc60a
commit 9a11c48487
7 changed files with 405 additions and 120 deletions
+108 -32
View File
@@ -55,40 +55,73 @@ function store(key: string, value: unknown) {
else localStorage.setItem(key, JSON.stringify(value))
}
/** Normalize server bot data to fill missing fields */
function normalizeBotData(data: Record<string, unknown>): BotData {
return {
satsWon: 0,
satsWagered: 0,
hasWallet: false,
...data,
} as BotData
}
/** Clear all auth-related state */
function clearAllState() {
pubkey.value = null
bot.value = null
profilePicUrl.value = null
store('bf_pubkey', null)
store('bf_bot', null)
store('bf_pic', null)
}
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
const bot = ref<BotData | null>(loadStored('bf_bot'))
const profilePicUrl = ref<string | null>(loadStored('bf_pic'))
const isLoading = ref(false)
// Guard: only auto-restore once across all component mounts
let autoRestoreRan = false
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
let freshlyGenerated = false
export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr)
// Restore session on first load — re-verify with server
if (pubkey.value && !bot.value) {
// Restore session on first load — re-verify with server (once only)
if (!autoRestoreRan && pubkey.value && !bot.value) {
autoRestoreRan = true
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value }),
}).then(r => r.json()).then(data => {
if (data.exists) {
bot.value = data.bot
store('bf_bot', data.bot)
bot.value = normalizeBotData(data.bot)
store('bf_bot', bot.value)
}
}).catch(() => {})
}
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
// Try NIP-07 extension first, fall back to stored nsec
let pk: string
if (window.nostr) {
pk = await window.nostr.getPublicKey()
try {
pk = await window.nostr.getPublicKey()
} catch {
throw new Error('Nostr extension denied access. Approve the request and try again.')
}
} else {
const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) {
const secretKey = hexToBytes(storedNsec)
pk = getPublicKey(secretKey)
try {
const secretKey = hexToBytes(storedNsec)
pk = getPublicKey(secretKey)
} catch {
throw new Error('Stored key is corrupt. Generate a new identity.')
}
} else {
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
}
@@ -99,10 +132,18 @@ export function useNostr() {
pubkey.value = pk
store('bf_pubkey', pk)
// Fetch Nostr profile pic from relay
const pic = await fetchNostrProfilePic(pk)
profilePicUrl.value = pic
store('bf_pic', pic)
// Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists)
if (freshlyGenerated) {
freshlyGenerated = false
profilePicUrl.value = null
store('bf_pic', null)
} else {
// Non-blocking: start fetch but don't block login on it
fetchNostrProfilePic(pk).then(pic => {
profilePicUrl.value = pic
store('bf_pic', pic)
}).catch(() => {})
}
// Check if this pubkey has a bot
const res = await fetch('/api/auth/login', {
@@ -114,9 +155,9 @@ export function useNostr() {
if (res.ok) {
const data = await res.json()
if (data.exists) {
bot.value = data.bot
store('bf_bot', data.bot)
return { pubkey: pk, bot: data.bot }
bot.value = normalizeBotData(data.bot)
store('bf_bot', bot.value)
return { pubkey: pk, bot: bot.value }
}
}
@@ -137,13 +178,13 @@ export function useNostr() {
const nsecHex = bytesToHex(secretKey)
const nsecBech32 = nsecEncode(secretKey)
// Clear stale state from previous identity
bot.value = null
profilePicUrl.value = null
store('bf_bot', null)
store('bf_pic', null)
// Clear all stale state from previous identity
clearAllState()
// Store locally
// Mark as freshly generated so login() skips relay pic fetch
freshlyGenerated = true
// Store new key
localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk
store('bf_pubkey', pk)
@@ -153,15 +194,32 @@ export function useNostr() {
/** Login with an existing nsec (hex) */
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
const secretKey = hexToBytes(nsecHex)
let secretKey: Uint8Array
try {
secretKey = hexToBytes(nsecHex)
} catch {
throw new Error('Invalid secret key format.')
}
const pk = getPublicKey(secretKey)
// Clear stale state before switching identity
bot.value = null
profilePicUrl.value = null
store('bf_bot', null)
store('bf_pic', null)
localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk
store('bf_pubkey', pk)
isLoading.value = true
try {
// Fetch Nostr profile pic (non-blocking)
fetchNostrProfilePic(pk).then(pic => {
profilePicUrl.value = pic
store('bf_pic', pic)
}).catch(() => {})
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -171,9 +229,9 @@ export function useNostr() {
if (res.ok) {
const data = await res.json()
if (data.exists) {
bot.value = data.bot
store('bf_bot', data.bot)
return { pubkey: pk, bot: data.bot }
bot.value = normalizeBotData(data.bot)
store('bf_bot', bot.value)
return { pubkey: pk, bot: bot.value }
}
}
@@ -199,7 +257,11 @@ export function useNostr() {
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Registration failed')
if (!res.ok) {
// Surface detailed error info from webhook verification failures
const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed')
throw new Error(msg)
}
bot.value = {
id: data.id,
@@ -247,6 +309,24 @@ export function useNostr() {
}
}
async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await fetch('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
})
const data = await res.json()
if (!res.ok) {
const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Update failed')
throw new Error(msg)
}
return { latencyMs: data.latencyMs || 0 }
}
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in')
@@ -288,12 +368,7 @@ export function useNostr() {
}
function logout() {
pubkey.value = null
bot.value = null
profilePicUrl.value = null
store('bf_pubkey', null)
store('bf_bot', null)
store('bf_pic', null)
clearAllState()
// Don't clear bf_nsec on logout — user may want to log back in
}
@@ -319,6 +394,7 @@ export function useNostr() {
registerBot,
registerHuman,
updateCustomization,
updateWebhook,
getStoredNsec,
logout,
}
+154 -52
View File
@@ -9,7 +9,7 @@ import type { SpriteCustomization } from '../game/sprites'
const route = useRoute()
const router = useRouter()
const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr()
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook } = useNostr()
const botName = route.params.name as string
interface BotCustomization {
@@ -34,6 +34,7 @@ interface BotStats {
winStreak: number
bestStreak: number
tier: number
isActive: boolean
tierName: string
tierColor: string
winRate: number
@@ -44,6 +45,10 @@ interface BotStats {
satsWagered: number
hasWallet: boolean
createdAt: string
// Owner-only webhook fields
webhookUrl?: string
consecutiveErrors?: number
isHuman?: boolean
recentFights: {
id: string
opponent: string
@@ -86,6 +91,15 @@ const showCustomize = ref(false)
const isSaving = ref(false)
const custError = ref('')
// Webhook management
const showWebhook = ref(false)
const webhookInput = ref('')
const isTestingWebhook = ref(false)
const isUpdatingWebhook = ref(false)
const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; latencyMs: number; error?: string; message?: string } | null>(null)
const webhookError = ref('')
const webhookSuccess = ref('')
const ARCHETYPES = [
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
@@ -210,9 +224,49 @@ async function saveCustomization() {
isSaving.value = false
}
async function testWebhook() {
if (!stats.value || isTestingWebhook.value) return
isTestingWebhook.value = true
webhookTestResult.value = null
webhookError.value = ''
try {
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/test`, { method: 'POST' })
webhookTestResult.value = await res.json()
if (webhookTestResult.value?.validResponse && stats.value) {
stats.value = { ...stats.value, consecutiveErrors: 0, isActive: true }
}
} catch {
webhookError.value = 'Network error testing webhook.'
}
isTestingWebhook.value = false
}
async function saveWebhook() {
const url = webhookInput.value.trim()
if (!url || isUpdatingWebhook.value) return
try { new URL(url) } catch { webhookError.value = 'Invalid URL.'; return }
isUpdatingWebhook.value = true
webhookError.value = ''
webhookSuccess.value = ''
try {
await updateWebhook(url)
webhookSuccess.value = 'Webhook updated and verified.'
if (stats.value) {
stats.value = { ...stats.value, webhookUrl: url, consecutiveErrors: 0 }
}
webhookInput.value = ''
} catch (err) {
webhookError.value = err instanceof Error ? err.message : 'Update failed.'
}
isUpdatingWebhook.value = false
}
onMounted(async () => {
try {
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats`)
const pk = pubkey.value || ''
const qs = pk ? `?pubkey=${encodeURIComponent(pk)}` : ''
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats${qs}`)
if (res.ok) stats.value = await res.json()
else loadError.value = `Failed to load bot (${res.status})`
} catch (err) {
@@ -317,42 +371,23 @@ const tierClass = (t: number) => `tier-${t}`
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
/>
</div>
<!-- Bot player: human controller + wire + bot sprite -->
<div v-else class="flex items-end justify-center gap-0 mb-4 relative">
<!-- Bot player: human controller + bot sprite -->
<div v-else class="flex items-end justify-center gap-2 mb-4">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
:size="150"
:win-rate="(stats.winRate || 0) / 100"
anim="idle"
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0 relative z-10"
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0"
/>
<!-- Wire from gamepad to bot with electricity -->
<svg class="absolute bottom-8 left-0 w-full h-24 z-0 pointer-events-none" preserveAspectRatio="none">
<defs>
<linearGradient id="wire-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#444455"/>
<stop offset="50%" stop-color="#555566"/>
<stop offset="100%" stop-color="#444455"/>
</linearGradient>
<filter id="elec-glow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<path d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="url(#wire-grad)" stroke-width="3" stroke-linecap="round"/>
<path class="elec-pulse-1" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#00f0ff" stroke-width="1.5" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="8 20" opacity="0.8"/>
<path class="elec-pulse-2" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#ff2d7b" stroke-width="1" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="5 25" opacity="0.6"/>
<circle class="elec-spark-1" cx="45%" cy="55%" r="2" fill="#00f0ff" filter="url(#elec-glow)" opacity="0"/>
<circle class="elec-spark-2" cx="62%" cy="45%" r="2" fill="#ff2d7b" filter="url(#elec-glow)" opacity="0"/>
</svg>
<SpritePreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype"
:tier="stats.tier"
:size="140"
:customization="stats.customization || undefined"
class="drop-shadow-[0_0_20px_var(--glow)] relative z-10"
class="drop-shadow-[0_0_20px_var(--glow)]"
:style="{ '--glow': stats.tierColor + '80' } as any"
/>
</div>
@@ -561,6 +596,100 @@ const tierClass = (t: number) => `tier-${t}`
</button>
</div>
<!-- Webhook management (owner only, bots only) -->
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
<!-- Deactivated warning -->
<div v-if="stats.isActive === false" class="p-3 border-2 border-ko/40 bg-ko/5 mb-3">
<p class="font-display font-bold text-xs tracking-wider text-ko mb-1">BOT DEACTIVATED</p>
<p class="font-mono text-[10px] text-text-muted">
Too many webhook errors. Test your webhook to reactivate.
</p>
</div>
<button
class="w-full py-2 border border-border text-text-secondary font-display font-bold text-[10px]
tracking-wider hover:border-neon-cyan/40 hover:text-neon-cyan transition-all text-center"
@click="showWebhook = !showWebhook"
>
{{ showWebhook ? 'HIDE' : 'WEBHOOK' }} SETTINGS
</button>
<div v-if="showWebhook" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
<!-- Current webhook info -->
<div>
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">CURRENT WEBHOOK</p>
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full flex-shrink-0"
:class="(stats.consecutiveErrors ?? 0) === 0 ? 'bg-neon-green' : 'bg-ko'"
/>
<p class="font-mono text-[10px] text-text-secondary break-all flex-1">
{{ stats.webhookUrl || '—' }}
</p>
</div>
<p v-if="(stats.consecutiveErrors ?? 0) > 0" class="font-mono text-[10px] text-ko mt-1">
{{ stats.consecutiveErrors }} consecutive errors
</p>
</div>
<!-- Test button -->
<button
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
:disabled="isTestingWebhook"
@click="testWebhook"
>
<span v-if="isTestingWebhook" class="w-3 h-3 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isTestingWebhook ? 'TESTING...' : 'TEST WEBHOOK' }}
</button>
<!-- Test result -->
<div v-if="webhookTestResult" class="p-2.5 border text-[10px] font-mono"
:class="webhookTestResult.validResponse
? 'border-neon-green/30 bg-neon-green/5 text-neon-green'
: 'border-ko/30 bg-ko/5 text-ko'">
<p class="font-display font-bold text-xs tracking-wider mb-1">
{{ webhookTestResult.validResponse ? 'PASSED' : 'FAILED' }}
</p>
<p>{{ webhookTestResult.message || webhookTestResult.error }}</p>
<p v-if="webhookTestResult.latencyMs" class="text-text-muted mt-0.5">
Latency: {{ webhookTestResult.latencyMs }}ms
</p>
</div>
<!-- Update webhook URL -->
<div class="border-t border-border/50 pt-3">
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">UPDATE WEBHOOK URL</p>
<div class="flex gap-2">
<input
v-model="webhookInput"
type="url"
placeholder="https://your-bot.example.com"
class="flex-1 px-2 py-1.5 bg-black/30 border border-border font-mono text-[10px] text-text-primary
placeholder-text-muted/50 focus:outline-none focus:border-neon-cyan/50"
@keyup.enter="saveWebhook"
/>
<button
class="px-3 py-1.5 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!webhookInput.trim() || isUpdatingWebhook"
@click="saveWebhook"
>
{{ isUpdatingWebhook ? '...' : 'UPDATE' }}
</button>
</div>
</div>
<p v-if="webhookError" class="font-mono text-[10px] text-ko">{{ webhookError }}</p>
<p v-if="webhookSuccess" class="font-mono text-[10px] text-neon-green">{{ webhookSuccess }}</p>
</div>
</div>
<!-- Sign out (only if owner) -->
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
<button
@@ -608,30 +737,3 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</template>
<style scoped>
/* Electricity pulses along wire */
.elec-pulse-1 {
animation: elec-flow 1.2s linear infinite;
}
.elec-pulse-2 {
animation: elec-flow 0.9s linear infinite reverse;
}
@keyframes elec-flow {
0% { stroke-dashoffset: 0; opacity: 0.9; }
50% { opacity: 0.4; }
100% { stroke-dashoffset: -56; opacity: 0.9; }
}
/* Spark flashes at wire midpoints */
.elec-spark-1 {
animation: spark-flash 0.8s ease-in-out infinite;
}
.elec-spark-2 {
animation: spark-flash 1.1s ease-in-out infinite 0.4s;
}
@keyframes spark-flash {
0%, 70%, 100% { opacity: 0; r: 1; }
75% { opacity: 1; r: 4; }
85% { opacity: 0.6; r: 2; }
}
</style>
+42 -20
View File
@@ -2,7 +2,6 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
import PosterSprite from '../components/PosterSprite.vue'
import PixelGlove from '../components/PixelGlove.vue'
interface BotInfo {
@@ -195,32 +194,34 @@ onUnmounted(() => {
<!-- Sprites -->
<div class="flex items-end justify-center w-full max-w-4xl">
<div class="flex-1 flex flex-col items-center min-w-0">
<PosterSprite
v-if="fighterA"
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
:seed="fighterA.avatarSeed || fighterA.name"
:archetype="fighterA.archetype || 'standard'"
:tier="fighterA.tier"
:size="160"
pose="win"
glow-color="cyan"
class="relative z-10 fighter-bob
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
/>
<div v-if="fighterA" class="relative fighter-bob">
<!-- Circular glow behind fighter -->
<div class="fighter-glow fighter-glow-cyan" />
<SpritePreview
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
:seed="fighterA.avatarSeed || fighterA.name"
:archetype="fighterA.archetype || 'standard'"
:tier="fighterA.tier"
:size="120"
pose="win"
class="relative z-10
sm:!w-[180px] sm:!h-[180px] lg:!w-[260px] lg:!h-[260px]"
/>
</div>
</div>
<div class="flex-1 flex flex-col items-center min-w-0 -ml-6 sm:-ml-12 lg:-ml-20">
<div style="transform: scaleX(-1)">
<PosterSprite
v-if="fighterB"
<div v-if="fighterB" class="relative fighter-bob-delayed" style="transform: scaleX(-1)">
<!-- Circular glow behind fighter -->
<div class="fighter-glow fighter-glow-pink" />
<SpritePreview
:key="`b-${featured.id}-${fighterB.avatarSeed}`"
:seed="fighterB.avatarSeed || fighterB.name"
:archetype="fighterB.archetype || 'standard'"
:tier="fighterB.tier"
:size="160"
:size="120"
pose="idle"
glow-color="pink"
class="relative z-10 fighter-bob-delayed
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
class="relative z-10
sm:!w-[180px] sm:!h-[180px] lg:!w-[260px] lg:!h-[260px]"
/>
</div>
</div>
@@ -557,6 +558,27 @@ onUnmounted(() => {
animation: punchRight 2s ease-in-out infinite 0.3s;
}
/* Circular glow behind fighters */
.fighter-glow {
position: absolute;
top: 50%;
left: 50%;
width: 140%;
height: 140%;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.fighter-glow-cyan {
background: radial-gradient(circle, rgba(0, 240, 255, 0.25) 0%, rgba(0, 240, 255, 0.10) 35%, rgba(0, 240, 255, 0.03) 60%, transparent 80%);
box-shadow: 0 0 60px rgba(0, 240, 255, 0.15), 0 0 120px rgba(0, 240, 255, 0.05);
}
.fighter-glow-pink {
background: radial-gradient(circle, rgba(255, 45, 120, 0.25) 0%, rgba(255, 45, 120, 0.10) 35%, rgba(255, 45, 120, 0.03) 60%, transparent 80%);
box-shadow: 0 0 60px rgba(255, 45, 120, 0.15), 0 0 120px rgba(255, 45, 120, 0.05);
}
/* Fighter bobbing */
.fighter-bob {
animation: fighterBob 2.5s ease-in-out infinite;
+46 -5
View File
@@ -144,6 +144,7 @@ function handleGenerateLogin() {
}
async function handleNsecBackupDone() {
if (isLoading) return
showNsecBackup.value = false
try {
const result = await login()
@@ -206,7 +207,9 @@ function pickCharacter(id: string) {
step.value = 'name-bot'
}
function confirmName() {
const isCheckingName = ref(false)
async function confirmName() {
const name = botName.value.trim()
if (!name || name.length < 2) {
error.value = 'Name must be at least 2 characters.'
@@ -217,6 +220,21 @@ function confirmName() {
return
}
error.value = ''
isCheckingName.value = true
try {
const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`)
if (res.ok) {
const data = await res.json()
if (!data.available) {
error.value = 'That name is taken. Pick another.'
return
}
}
} catch {
// If check fails, proceed anyway — server will catch it at registration
} finally {
isCheckingName.value = false
}
step.value = 'bot-setup'
}
@@ -269,6 +287,21 @@ async function confirmHumanName() {
return
}
error.value = ''
isCheckingName.value = true
try {
const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`)
if (res.ok) {
const data = await res.json()
if (!data.available) {
error.value = 'That name is taken. Pick another.'
return
}
}
} catch {
// proceed — server catches at registration
} finally {
isCheckingName.value = false
}
step.value = 'human-guide'
}
@@ -598,10 +631,14 @@ function handleSignOut() {
<button
class="flex-1 py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-bold text-sm tracking-wider
hover:bg-neon-cyan/20 transition-all"
hover:bg-neon-cyan/20 transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-2"
:disabled="isCheckingName"
@click="confirmName"
>
NEXT
<span v-if="isCheckingName" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isCheckingName ? 'CHECKING...' : 'NEXT' }}
</button>
</div>
</template>
@@ -852,10 +889,14 @@ function handleSignOut() {
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
hover:bg-neon-pink/20 transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-2"
:disabled="isCheckingName"
@click="confirmHumanName"
>
NEXT
<span v-if="isCheckingName" class="w-4 h-4 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
{{ isCheckingName ? 'CHECKING...' : 'NEXT' }}
</button>
</div>
</template>
+4 -4
View File
@@ -39,8 +39,8 @@
/* Synthwave grid background */
.synthwave-grid {
background-image:
linear-gradient(rgba(184, 61, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(184, 61, 255, 0.06) 1px, transparent 1px);
linear-gradient(rgba(184, 61, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(184, 61, 255, 0.025) 1px, transparent 1px);
background-size: 40px 40px;
}
@@ -48,8 +48,8 @@
.crt-overlay {
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.12) 0px,
rgba(0, 0, 0, 0.12) 1px,
rgba(0, 0, 0, 0.04) 0px,
rgba(0, 0, 0, 0.04) 1px,
transparent 1px,
transparent 3px
);