Files
sub2api/frontend/src/views/auth/RegisterView.vue
mini 0fceb100e0 chore(i18n): consolidate PURO auth heading keys into zh.ts
Login and Register heading strings moved from hardcoded Chinese to
auth.puroLoginTitle / puroLoginSub / puroRegisterTitle / puroRegisterSub.

Landing (LandingView) and Docs (DocsView) intentionally keep hardcoded
Chinese this cycle (see spec §6 note 5 — English version deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:54:49 +08:00

832 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<AuthLayout>
<template #narrative>
<div class="auth-narrative-inner">
<div class="brand"><span class="hex"></span><span>PURO AI</span></div>
<div class="auth-narrative-hero">
<div class="auth-narrative-headline">
<span class="num-5">5</span> 个订阅<br>
<span class="num-1">1</span> key
</div>
<p class="auth-narrative-sub">
省去切换账号的繁琐<br>
省去为多个高昂订阅重复买单<br>
<span class="auth-narrative-tagline">PURO纯粹 AI 调用回归本质</span>
</p>
</div>
<div class="auth-narrative-foot">Claude · ChatGPT · Codex · Gemini</div>
</div>
</template>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('auth.puroRegisterTitle') }}</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('auth.puroRegisterSub') }}</p>
</div>
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<!-- Registration Disabled Message -->
<div
v-if="!registrationEnabled && settingsLoaded"
class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-amber-500" />
</div>
<p class="text-sm text-amber-700 dark:text-amber-400">
{{ t('auth.registrationDisabled') }}
</p>
</div>
</div>
<!-- Registration Form -->
<form v-else @submit.prevent="handleRegister" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.passwordLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.createPasswordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<p v-else class="input-hint">
{{ t('auth.passwordHint') }}
</p>
</div>
<!-- Invitation Code Input (Required when enabled) -->
<div v-if="invitationCodeEnabled">
<label for="invitation_code" class="input-label">
{{ t('auth.invitationCodeLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
</div>
<input
id="invitation_code"
v-model="formData.invitation_code"
type="text"
:disabled="isLoading"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
}"
:placeholder="t('auth.invitationCodePlaceholder')"
@input="handleInvitationCodeInput"
/>
<!-- Validation indicator -->
<div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
</div>
<!-- Invitation code validation result -->
<transition name="fade">
<div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
<Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" />
<span class="text-sm text-green-700 dark:text-green-400">
{{ t('auth.invitationCodeValid') }}
</span>
</div>
<p v-else-if="invitationValidation.invalid" class="input-error-text">
{{ invitationValidation.message }}
</p>
<p v-else-if="errors.invitation_code" class="input-error-text">
{{ errors.invitation_code }}
</p>
</transition>
</div>
<!-- Promo Code Input (Optional) -->
<div v-if="promoCodeEnabled">
<label for="promo_code" class="input-label">
{{ t('auth.promoCodeLabel') }}
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="gift" size="md" :class="promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
</div>
<input
id="promo_code"
v-model="formData.promo_code"
type="text"
:disabled="isLoading"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
}"
:placeholder="t('auth.promoCodePlaceholder')"
@input="handlePromoCodeInput"
/>
<!-- Validation indicator -->
<div v-if="promoValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div v-else-if="promoValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<div v-else-if="promoValidation.invalid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
</div>
<!-- Promo code validation result -->
<transition name="fade">
<div v-if="promoValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
<Icon name="gift" size="sm" class="text-green-600 dark:text-green-400" />
<span class="text-sm text-green-700 dark:text-green-400">
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
</span>
</div>
<p v-else-if="promoValidation.invalid" class="input-error-text">
{{ promoValidation.message }}
</p>
</transition>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="userPlus" size="md" class="mr-2" />
{{
isLoading
? t('auth.processing')
: emailVerifyEnabled
? t('auth.continue')
: t('auth.createAccount')
}}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.alreadyHaveAccount') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t, locale } = useI18n()
// ==================== Router & Stores ====================
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const settingsLoaded = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
// Public settings
const registrationEnabled = ref<boolean>(true)
const emailVerifyEnabled = ref<boolean>(false)
const promoCodeEnabled = ref<boolean>(true)
const invitationCodeEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
// Promo code validation
const promoValidating = ref<boolean>(false)
const promoValidation = reactive({
valid: false,
invalid: false,
bonusAmount: null as number | null,
message: ''
})
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
// Invitation code validation
const invitationValidating = ref<boolean>(false)
const invitationValidation = reactive({
valid: false,
invalid: false,
message: ''
})
let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
const formData = reactive({
email: '',
password: '',
promo_code: '',
invitation_code: ''
})
const errors = reactive({
email: '',
password: '',
turnstile: '',
invitation_code: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled
emailVerifyEnabled.value = settings.email_verify_enabled
promoCodeEnabled.value = settings.promo_code_enabled
invitationCodeEnabled.value = settings.invitation_code_enabled
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
const promoParam = route.query.promo as string
if (promoParam) {
formData.promo_code = promoParam
// Validate the promo code from URL
await validatePromoCodeDebounced(promoParam)
}
}
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
settingsLoaded.value = true
}
})
onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
}
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
})
// ==================== Promo Code Validation ====================
function handlePromoCodeInput(): void {
const code = formData.promo_code.trim()
// Clear previous validation
promoValidation.valid = false
promoValidation.invalid = false
promoValidation.bonusAmount = null
promoValidation.message = ''
if (!code) {
promoValidating.value = false
return
}
// Debounce validation
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
}
promoValidateTimeout = setTimeout(() => {
validatePromoCodeDebounced(code)
}, 500)
}
async function validatePromoCodeDebounced(code: string): Promise<void> {
if (!code.trim()) return
promoValidating.value = true
try {
const result = await validatePromoCode(code)
if (result.valid) {
promoValidation.valid = true
promoValidation.invalid = false
promoValidation.bonusAmount = result.bonus_amount || 0
promoValidation.message = ''
} else {
promoValidation.valid = false
promoValidation.invalid = true
promoValidation.bonusAmount = null
// 根据错误码显示对应的翻译
promoValidation.message = getPromoErrorMessage(result.error_code)
}
} catch (error) {
console.error('Failed to validate promo code:', error)
promoValidation.valid = false
promoValidation.invalid = true
promoValidation.message = t('auth.promoCodeInvalid')
} finally {
promoValidating.value = false
}
}
function getPromoErrorMessage(errorCode?: string): string {
switch (errorCode) {
case 'PROMO_CODE_NOT_FOUND':
return t('auth.promoCodeNotFound')
case 'PROMO_CODE_EXPIRED':
return t('auth.promoCodeExpired')
case 'PROMO_CODE_DISABLED':
return t('auth.promoCodeDisabled')
case 'PROMO_CODE_MAX_USED':
return t('auth.promoCodeMaxUsed')
case 'PROMO_CODE_ALREADY_USED':
return t('auth.promoCodeAlreadyUsed')
default:
return t('auth.promoCodeInvalid')
}
}
// ==================== Invitation Code Validation ====================
function handleInvitationCodeInput(): void {
const code = formData.invitation_code.trim()
// Clear previous validation
invitationValidation.valid = false
invitationValidation.invalid = false
invitationValidation.message = ''
errors.invitation_code = ''
if (!code) {
return
}
// Debounce validation
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
invitationValidateTimeout = setTimeout(() => {
validateInvitationCodeDebounced(code)
}, 500)
}
async function validateInvitationCodeDebounced(code: string): Promise<void> {
invitationValidating.value = true
try {
const result = await validateInvitationCode(code)
if (result.valid) {
invitationValidation.valid = true
invitationValidation.invalid = false
invitationValidation.message = ''
} else {
invitationValidation.valid = false
invitationValidation.invalid = true
invitationValidation.message = getInvitationErrorMessage(result.error_code)
}
} catch {
invitationValidation.valid = false
invitationValidation.invalid = true
invitationValidation.message = t('auth.invitationCodeInvalid')
} finally {
invitationValidating.value = false
}
}
function getInvitationErrorMessage(errorCode?: string): string {
switch (errorCode) {
case 'INVITATION_CODE_NOT_FOUND':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_INVALID':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_USED':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_DISABLED':
return t('auth.invitationCodeInvalid')
default:
return t('auth.invitationCodeInvalid')
}
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
function buildEmailSuffixNotAllowedMessage(): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
registrationEmailSuffixWhitelist.value
)
if (normalizedWhitelist.length === 0) {
return t('auth.emailSuffixNotAllowed')
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
suffixes: normalizedWhitelist.join(separator)
})
}
function validateForm(): boolean {
// Reset errors
errors.email = ''
errors.password = ''
errors.turnstile = ''
errors.invitation_code = ''
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
isValid = false
} else if (!validateEmail(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
} else if (
!isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
) {
errors.email = buildEmailSuffixNotAllowedMessage()
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Invitation code validation (required when enabled)
if (invitationCodeEnabled.value) {
if (!formData.invitation_code.trim()) {
errors.invitation_code = t('auth.invitationCodeRequired')
isValid = false
}
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleRegister(): Promise<void> {
// Clear previous error
errorMessage.value = ''
// Validate form
if (!validateForm()) {
return
}
// Check promo code validation status
if (formData.promo_code.trim()) {
// If promo code is being validated, wait
if (promoValidating.value) {
errorMessage.value = t('auth.promoCodeValidating')
return
}
// If promo code is invalid, block submission
if (promoValidation.invalid) {
errorMessage.value = t('auth.promoCodeInvalidCannotRegister')
return
}
}
// Check invitation code validation status (if enabled and code provided)
if (invitationCodeEnabled.value) {
// If still validating, wait
if (invitationValidating.value) {
errorMessage.value = t('auth.invitationCodeValidating')
return
}
// If invitation code is invalid, block submission
if (invitationValidation.invalid) {
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
return
}
// If invitation code is required but not validated yet
if (formData.invitation_code.trim() && !invitationValidation.valid) {
errorMessage.value = t('auth.invitationCodeValidating')
// Trigger validation
await validateInvitationCodeDebounced(formData.invitation_code.trim())
if (!invitationValidation.valid) {
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
return
}
}
}
isLoading.value = true
try {
// If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage
sessionStorage.setItem(
'register_data',
JSON.stringify({
email: formData.email,
password: formData.password,
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
})
)
// Navigate to email verification page
await router.push('/email-verify')
return
}
// Otherwise, directly register
await authStore.register({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
})
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
// Redirect to dashboard
await router.push('/dashboard')
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset()
turnstileToken.value = ''
}
// Handle registration error
errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.registrationFailed')
})
// Also show error toast
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.auth-narrative-inner {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
color: var(--text-0);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 18px;
}
.brand .hex {
color: var(--cyan);
font-size: 24px;
}
.auth-narrative-headline {
font-size: clamp(40px, 5vw, 64px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em;
margin-bottom: 24px;
}
.auth-narrative-headline .num-5 { color: var(--amber); }
.auth-narrative-headline .num-1 { color: var(--cyan); }
.auth-narrative-sub {
font-size: 15px;
line-height: 1.7;
color: var(--text-1);
}
.auth-narrative-tagline {
display: block;
margin-top: 12px;
font-size: 12px;
color: var(--text-3);
}
.auth-narrative-foot {
font-size: 12px;
color: var(--text-3);
font-family: var(--font-mono);
}
</style>