Rework the product preview section around three real screens captured at deviceScaleFactor 2 instead of the previous 900 px shots taken at a 75% browser zoom, which read as blurry. - capture the conversation workspace, the agent customization page and the visitor widget, then crop each one to a consistent frame - drop the tight 480 px widget crop and its leftover gray strip - restructure the preview section with one lead line and a caption per shot, and fold the local start-up steps into a shorter quick start - keep the browser overlay out of the captures and note the method in the asset manifest - finish the visible ReplyPier rename (sidebar wordmark, widget footer, page title, login screen, demo page) so screenshots and UI agree
1235 lines
38 KiB
Vue
1235 lines
38 KiB
Vue
<!--
|
||
Copyright 2024-2026 ChatterMate
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
-->
|
||
|
||
<script setup lang="ts">
|
||
import BrandMark from '@/components/common/BrandMark.vue'
|
||
import { computed, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { authService } from '@/services/auth'
|
||
import { resolveLandingRoute } from '@/router/landing'
|
||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||
import { useForgotPassword } from '@/composables/useForgotPassword'
|
||
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
|
||
import { useTheme } from '@/composables/useTheme'
|
||
import { navIconSvg } from '@/components/layout/navIcons'
|
||
import { apiPath, getApiUrl } from '@/config/api'
|
||
import type { AxiosError } from 'axios'
|
||
interface ErrorResponse {
|
||
detail: string
|
||
}
|
||
|
||
const router = useRouter()
|
||
const { loadModule } = useEnterpriseFeatures()
|
||
|
||
const trackLogin = async (method: string) => {
|
||
const mod = await loadModule('/src/modules/enterprise/utils/analytics.ts') as any
|
||
mod?.trackLogin?.(method)
|
||
}
|
||
const { mode: themeMode, toggle: toggleTheme } = useTheme()
|
||
const themeTitle = computed(() =>
|
||
themeMode.value === 'dark' ? '主题:深色 — 点击切换为浅色'
|
||
: themeMode.value === 'light' ? '主题:浅色 — 点击切换为跟随系统'
|
||
: '主题:跟随系统 — 点击切换为深色'
|
||
)
|
||
|
||
const email = ref('')
|
||
const password = ref('')
|
||
const showPassword = ref(false)
|
||
const error = ref('')
|
||
const isLoading = ref(false)
|
||
|
||
// Check if enterprise module is available
|
||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||
|
||
// Forgot password composable - only initialize if enterprise module is available
|
||
const showForgotPasswordModal = ref(false)
|
||
const forgotPassword = hasEnterpriseModule ? useForgotPassword() : null
|
||
|
||
// Destructure with fallbacks for when enterprise module is not available
|
||
const isForgotPasswordLoading = forgotPassword?.isLoading ?? ref(false)
|
||
const forgotPasswordError = forgotPassword?.error ?? ref('')
|
||
const forgotPasswordSuccess = forgotPassword?.success ?? ref('')
|
||
const forgotPasswordStep = forgotPassword?.currentStep ?? ref(1)
|
||
const forgotPasswordEmail = forgotPassword?.email ?? ref('')
|
||
const forgotPasswordOtp = forgotPassword?.otp ?? ref('')
|
||
const newPassword = forgotPassword?.newPassword ?? ref('')
|
||
const confirmPassword = forgotPassword?.confirmPassword ?? ref('')
|
||
const passwordValidation = forgotPassword?.passwordValidation ?? ref({
|
||
score: 0,
|
||
hasMinLength: false,
|
||
hasUpperCase: false,
|
||
hasLowerCase: false,
|
||
hasNumber: false,
|
||
hasSpecialChar: false
|
||
})
|
||
const requestPasswordReset = forgotPassword?.requestPasswordReset ?? (() => Promise.resolve(false))
|
||
const verifyAndResetPassword = forgotPassword?.verifyAndResetPassword ?? (() => Promise.resolve(false))
|
||
const resetForgotPasswordForm = forgotPassword?.resetForm ?? (() => {})
|
||
const goBackToEmailStep = forgotPassword?.goBackToEmailStep ?? (() => {})
|
||
|
||
// One ordered candidate list, shared with '/', the catch-all and the 403 page.
|
||
// It used to end at '/403', which is not somewhere to land — and the route was
|
||
// not even registered, so the user silently ended up on /ai-agents instead.
|
||
const getInitialRoute = () => resolveLandingRoute()
|
||
|
||
const handleLogin = async () => {
|
||
try {
|
||
isLoading.value = true
|
||
error.value = ''
|
||
|
||
await authService.login(email.value, password.value)
|
||
|
||
trackLogin('email')
|
||
|
||
// Check if this is Shopify flow (new managed installation)
|
||
const urlParams = new URLSearchParams(window.location.search)
|
||
const isShopifyFlow = urlParams.get('shopify_flow') === '1'
|
||
const shopId = urlParams.get('shop_id')
|
||
const returnTo = urlParams.get('return_to')
|
||
|
||
if (isShopifyFlow && shopId) {
|
||
console.log('🔗 Shopify flow detected, linking organization')
|
||
|
||
try {
|
||
|
||
|
||
// If return_to is specified, redirect there
|
||
if (returnTo) {
|
||
console.log('📍 Redirecting to return_to:', returnTo)
|
||
window.location.href = returnTo
|
||
return
|
||
}
|
||
} catch (linkError: any) {
|
||
console.error('❌ Failed to link organization:', linkError)
|
||
error.value = linkError.response?.data?.detail || '关联组织失败'
|
||
isLoading.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
// Check if this is an OLD embedded Shopify login (opened in popup) - for backward compatibility
|
||
const isEmbedded = router.currentRoute.value.query.embedded === 'true'
|
||
const legacyShopId = router.currentRoute.value.query.shop_id as string
|
||
|
||
if (isEmbedded && legacyShopId && window.opener) {
|
||
// This is a popup for Shopify embedded app (old flow)
|
||
console.log('Login successful in popup, notifying parent window')
|
||
|
||
// Show success state
|
||
isLoading.value = true
|
||
error.value = ''
|
||
|
||
// Notify the parent window of successful login
|
||
if (window.opener && !window.opener.closed) {
|
||
window.opener.postMessage(
|
||
{ type: 'login_success', shop_id: legacyShopId },
|
||
window.location.origin
|
||
)
|
||
|
||
// Close the popup after a brief delay to ensure message is sent
|
||
setTimeout(() => {
|
||
window.close()
|
||
}, 300)
|
||
} else {
|
||
// If opener is gone, just close the window
|
||
setTimeout(() => {
|
||
window.close()
|
||
}, 300)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// Check for Shopify flow (new simplified flow)
|
||
if (router.currentRoute.value.query.shopify_flow === '1') {
|
||
const shop = router.currentRoute.value.query.shop as string
|
||
const shopId = router.currentRoute.value.query.shop_id as string
|
||
const host = router.currentRoute.value.query.host as string
|
||
const returnTo = router.currentRoute.value.query.return_to as string
|
||
|
||
// Redirect to auth complete page
|
||
const query: any = { shop, shop_id: shopId, host }
|
||
if (returnTo) {
|
||
query.return_to = returnTo
|
||
}
|
||
|
||
router.push({
|
||
path: '/shopify/auth-complete',
|
||
query
|
||
})
|
||
return
|
||
}
|
||
|
||
// Check if there's a Shopify redirect pending in localStorage (legacy flow)
|
||
const shopifyRedirectData = localStorage.getItem('shopifyRedirect')
|
||
if (shopifyRedirectData) {
|
||
try {
|
||
const redirectInfo = JSON.parse(shopifyRedirectData)
|
||
// Clear the stored redirect
|
||
localStorage.removeItem('shopifyRedirect')
|
||
|
||
// Build the full URL with all query parameters
|
||
const queryParams = new URLSearchParams()
|
||
for (const [key, value] of Object.entries(redirectInfo)) {
|
||
queryParams.append(key, value as string)
|
||
}
|
||
|
||
// Redirect to the backend Shopify auth endpoint
|
||
window.location.href = apiPath(`/shopify/auth?${queryParams.toString()}`)
|
||
return // Don't do the normal navigation
|
||
} catch (e) {
|
||
console.error('Failed to parse shopifyRedirect data:', e)
|
||
// Clear invalid data
|
||
localStorage.removeItem('shopifyRedirect')
|
||
}
|
||
}
|
||
|
||
// Check for redirect query parameter (internal frontend route)
|
||
const redirectPath = router.currentRoute.value.query.redirect as string
|
||
if (redirectPath) {
|
||
// If it's an internal route (starts with /), use router.push
|
||
if (redirectPath.startsWith('/')) {
|
||
router.push(redirectPath)
|
||
return
|
||
}
|
||
// Otherwise treat as API redirect (legacy behavior)
|
||
window.location.href = `${getApiUrl()}${redirectPath}`
|
||
return
|
||
}
|
||
|
||
// Determine initial route based on permissions
|
||
const initialRoute = getInitialRoute()
|
||
router.push(initialRoute)
|
||
|
||
} catch (err) {
|
||
console.log(err)
|
||
// authService.login rethrows the backend's detail as a plain Error, so
|
||
// reading only the axios response showed "Login failed" for everything.
|
||
const detail = (err as AxiosError<ErrorResponse>).response?.data?.detail
|
||
error.value = (typeof detail === 'string' && detail) || (err instanceof Error && err.message) || '登录失败'
|
||
console.error('Login error:', err)
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const navigateToSignup = () => {
|
||
// Preserve embedded, shop_id, return_to, redirect query params if present
|
||
const isEmbedded = router.currentRoute.value.query.embedded
|
||
const shopId = router.currentRoute.value.query.shop_id
|
||
const returnTo = router.currentRoute.value.query.return_to
|
||
const shopifyFlow = router.currentRoute.value.query.shopify_flow
|
||
const redirect = router.currentRoute.value.query.redirect
|
||
|
||
const query: any = {}
|
||
|
||
if (isEmbedded) query.embedded = isEmbedded
|
||
if (shopId) query.shop_id = shopId
|
||
if (returnTo) query.return_to = returnTo
|
||
if (shopifyFlow) query.shopify_flow = shopifyFlow
|
||
if (redirect) query.redirect = redirect
|
||
|
||
if (Object.keys(query).length > 0) {
|
||
console.log('Navigating to signup with params:', query)
|
||
router.push({
|
||
path: '/signup',
|
||
query
|
||
})
|
||
} else {
|
||
router.push('/signup')
|
||
}
|
||
}
|
||
|
||
const openForgotPasswordModal = () => {
|
||
resetForgotPasswordForm()
|
||
showForgotPasswordModal.value = true
|
||
}
|
||
|
||
const closeForgotPasswordModal = () => {
|
||
showForgotPasswordModal.value = false
|
||
resetForgotPasswordForm()
|
||
}
|
||
|
||
const handleRequestPasswordReset = async () => {
|
||
const success = await requestPasswordReset()
|
||
// Keep modal open to proceed to step 2
|
||
}
|
||
|
||
const handleVerifyAndResetPassword = async () => {
|
||
const success = await verifyAndResetPassword()
|
||
if (success) {
|
||
// Wait a moment to show success message, then close modal
|
||
setTimeout(() => {
|
||
closeForgotPasswordModal()
|
||
// Optionally pre-fill the email in login form
|
||
email.value = forgotPasswordEmail.value
|
||
}, 2000)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="login">
|
||
<!-- Form column -->
|
||
<section class="form-col">
|
||
<div class="form-top reveal" style="--i: 0">
|
||
<BrandMark :size="34" />
|
||
<span class="wordmark" style="font-size: 21px">Reply<i>Pier</i></span>
|
||
<button
|
||
class="icon-btn"
|
||
@click="toggleTheme"
|
||
:title="themeTitle"
|
||
:aria-label="themeTitle"
|
||
v-html="navIconSvg(themeMode === 'dark' ? 'moon' : themeMode === 'light' ? 'sun' : 'monitor', 18)"
|
||
></button>
|
||
</div>
|
||
|
||
<div class="form-wrap">
|
||
<div class="eyebrow reveal" style="--i: 1">Sign in · 登录</div>
|
||
<h1 class="title reveal" style="--i: 2">欢迎回来</h1>
|
||
<p class="lede reveal" style="--i: 3">登录工作台,继续今天的接待。</p>
|
||
|
||
<form class="login-form reveal" style="--i: 4" @submit.prevent="handleLogin" novalidate>
|
||
<div class="field">
|
||
<label for="email">邮箱</label>
|
||
<div class="input-wrap">
|
||
<span class="ic" v-html="navIconSvg('mail', 17)"></span>
|
||
<input
|
||
id="email"
|
||
v-model="email"
|
||
class="input"
|
||
type="email"
|
||
required
|
||
placeholder="you@company.com"
|
||
autocomplete="email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<div class="label-row">
|
||
<label for="password">密码</label>
|
||
<a
|
||
v-if="hasEnterpriseModule"
|
||
href="#"
|
||
@click.prevent="openForgotPasswordModal"
|
||
>忘记密码?</a>
|
||
</div>
|
||
<div class="input-wrap">
|
||
<span class="ic" v-html="navIconSvg('lock', 17)"></span>
|
||
<input
|
||
id="password"
|
||
v-model="password"
|
||
class="input"
|
||
:type="showPassword ? 'text' : 'password'"
|
||
required
|
||
placeholder="••••••••"
|
||
autocomplete="current-password"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="icon-btn sm peek"
|
||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||
@click="showPassword = !showPassword"
|
||
v-html="navIconSvg('eye', 15)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="error" class="auth-error" role="alert">{{ error }}</div>
|
||
|
||
<button class="btn btn-primary btn-lg btn-block" type="submit" :disabled="isLoading">
|
||
<span v-if="isLoading">
|
||
{{ router.currentRoute.value.query.embedded === 'true' ? '连接中…' : '登录中…' }}
|
||
</span>
|
||
<template v-else>
|
||
登录工作台
|
||
<span class="icon-arrow" v-html="navIconSvg('arrowRight', 15)"></span>
|
||
</template>
|
||
</button>
|
||
</form>
|
||
|
||
<p v-if="hasEnterpriseModule" class="form-note reveal" style="--i: 5">
|
||
还没有账号?
|
||
<a href="#" class="inline-link" @click.prevent="navigateToSignup">注册</a>
|
||
</p>
|
||
<p v-else class="form-note reveal" style="--i: 5">
|
||
还没有账号?请联系组织管理员,从「人工客服」页面邀请你加入团队。
|
||
</p>
|
||
|
||
<div class="install-hint-slot">
|
||
<InstallPrompt />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-foot reveal" style="--i: 6">
|
||
<span>© 2026 ReplyPier</span>
|
||
<span class="mono">APACHE-2.0 · SELF-HOSTED</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Brand stage — always night ink, even in the light theme -->
|
||
<section class="stage" data-theme="dark" aria-hidden="true">
|
||
<div class="stage-top">
|
||
<span><span class="pulse"></span>Open-source AI customer support</span>
|
||
<span>v0.1 · 本地部署</span>
|
||
</div>
|
||
<div class="vertical">妥帖接待每一次对话</div>
|
||
|
||
<div class="stage-body">
|
||
<div>
|
||
<h2 class="headline reveal" style="--i: 2">让每一次对话,<br />都被<em>妥善</em>接待。</h2>
|
||
<p class="stage-lede reveal" style="--i: 3">
|
||
AI 依据你的知识库即时作答;需要时,连同完整上下文交给人工。接待、评价与洞察,尽在一处。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="specimen">
|
||
<div class="spec">青禾测试套餐的退款期多长,校验码是什么?</div>
|
||
<div class="spec">
|
||
退款期限为购买后 <strong>17 天</strong>内,客服校验码为 <strong>QH-7319</strong>。
|
||
<div class="spec-meta">
|
||
<span class="who">AI 客服</span>
|
||
<span class="cite">
|
||
<span v-html="navIconSvg('book', 12)"></span>依据 · 演示规则
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="spec">
|
||
<span v-html="navIconSvg('transfer', 12)"></span>已转接人工 · 客服 A 接管
|
||
</div>
|
||
<div class="spec">
|
||
<span class="stars">
|
||
<span v-for="n in 5" :key="n" v-html="navIconSvg('star', 15, 'icon')"></span>
|
||
</span>
|
||
<div>人工客服回复及时,问题已解决。</div>
|
||
<div class="spec-meta"><span>访客评价 · 5.0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stage-foot reveal" style="--i: 5">
|
||
<div class="fig"><b class="num">24<small>/7</small></b><span>全天候 AI 接待</span></div>
|
||
<div class="fig"><b class="num">7<small>家</small></b><span>主流模型自由切换</span></div>
|
||
<div class="fig"><b class="num">100<small>%</small></b><span>开源,可二次开发</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Forgot Password Modal - only show if enterprise module is available -->
|
||
<div v-if="hasEnterpriseModule && showForgotPasswordModal" class="modal-overlay">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>{{ forgotPasswordStep === 1 ? '重置密码' : '验证并重置' }}</h2>
|
||
<button class="close-btn" @click="closeForgotPasswordModal" aria-label="关闭">×</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<!-- Step 1: Request OTP -->
|
||
<div v-if="forgotPasswordStep === 1" class="forgot-password-step">
|
||
<p class="step-description">输入你的邮箱,我们会发送验证码以重置密码。</p>
|
||
|
||
<div class="form-group">
|
||
<label for="forgot-email">邮箱地址</label>
|
||
<div class="input-wrapper">
|
||
<input
|
||
id="forgot-email"
|
||
v-model="forgotPasswordEmail"
|
||
type="email"
|
||
required
|
||
placeholder="输入邮箱"
|
||
:disabled="isForgotPasswordLoading"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="forgotPasswordError" class="error-message" role="alert">
|
||
{{ forgotPasswordError }}
|
||
</div>
|
||
|
||
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
|
||
{{ forgotPasswordSuccess }}
|
||
</div>
|
||
|
||
<button
|
||
class="modal-submit-btn"
|
||
@click="handleRequestPasswordReset"
|
||
:disabled="isForgotPasswordLoading || !forgotPasswordEmail"
|
||
>
|
||
{{ isForgotPasswordLoading ? '发送中…' : '发送验证码' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Step 2: Verify OTP and Reset Password -->
|
||
<div v-if="forgotPasswordStep === 2" class="forgot-password-step">
|
||
<p class="step-description">输入发送到你邮箱的验证码和新密码。</p>
|
||
|
||
<div class="form-group">
|
||
<label for="forgot-otp">验证码</label>
|
||
<div class="input-wrapper">
|
||
<input
|
||
id="forgot-otp"
|
||
v-model="forgotPasswordOtp"
|
||
type="text"
|
||
required
|
||
placeholder="输入 6 位验证码"
|
||
maxlength="6"
|
||
:disabled="isForgotPasswordLoading"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="new-password">新密码</label>
|
||
<div class="input-wrapper">
|
||
<input
|
||
id="new-password"
|
||
v-model="newPassword"
|
||
type="password"
|
||
required
|
||
placeholder="输入新密码"
|
||
:disabled="isForgotPasswordLoading"
|
||
/>
|
||
</div>
|
||
<div class="password-requirements">
|
||
<p class="requirements-title">密码需包含:</p>
|
||
<ul>
|
||
<li :class="{ valid: passwordValidation.hasMinLength }">至少 8 个字符</li>
|
||
<li :class="{ valid: passwordValidation.hasUpperCase }">包含大写字母</li>
|
||
<li :class="{ valid: passwordValidation.hasLowerCase }">包含小写字母</li>
|
||
<li :class="{ valid: passwordValidation.hasNumber }">包含数字</li>
|
||
<li :class="{ valid: passwordValidation.hasSpecialChar }">包含特殊字符(!@#$%^&*)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="confirm-password">确认密码</label>
|
||
<div class="input-wrapper">
|
||
<input
|
||
id="confirm-password"
|
||
v-model="confirmPassword"
|
||
type="password"
|
||
required
|
||
placeholder="再次输入新密码"
|
||
:disabled="isForgotPasswordLoading"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="forgotPasswordError" class="error-message" role="alert">
|
||
{{ forgotPasswordError }}
|
||
</div>
|
||
|
||
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
|
||
{{ forgotPasswordSuccess }}
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button
|
||
class="modal-back-btn"
|
||
@click="goBackToEmailStep"
|
||
:disabled="isForgotPasswordLoading"
|
||
>
|
||
返回
|
||
</button>
|
||
<button
|
||
class="modal-submit-btn"
|
||
@click="handleVerifyAndResetPassword"
|
||
:disabled="isForgotPasswordLoading || !forgotPasswordOtp || !newPassword || !confirmPassword"
|
||
>
|
||
{{ isForgotPasswordLoading ? '重置中…' : '重置密码' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 墨与瓷 sign-in: a quiet porcelain form beside a night-ink stage that tells
|
||
the product story with real conversation fragments. */
|
||
|
||
.login {
|
||
display: grid;
|
||
grid-template-columns: minmax(440px, 40%) 1fr;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font-family: var(--font-sans);
|
||
}
|
||
|
||
/* ── Form column ───────────────────────────────────── */
|
||
.form-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 36px 56px 32px;
|
||
position: relative;
|
||
}
|
||
|
||
.form-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-top .icon-btn {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.form-wrap {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
width: 100%;
|
||
max-width: 380px;
|
||
margin: 0 auto;
|
||
padding: 48px 0;
|
||
}
|
||
|
||
.form-wrap .title {
|
||
font-size: 40px;
|
||
}
|
||
|
||
.form-wrap .lede {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.login-form {
|
||
margin-top: 40px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 22px;
|
||
}
|
||
|
||
.label-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.label-row a {
|
||
font-size: 12.5px;
|
||
color: var(--muted);
|
||
border-bottom: 1px solid var(--line-2);
|
||
padding-bottom: 1px;
|
||
transition: color var(--t-fast), border-color var(--t-fast);
|
||
}
|
||
|
||
.label-row a:hover {
|
||
color: var(--ink);
|
||
border-color: var(--ink);
|
||
}
|
||
|
||
.input-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.input-wrap .input {
|
||
width: 100%;
|
||
padding-left: 44px;
|
||
}
|
||
|
||
.input-wrap > .ic {
|
||
position: absolute;
|
||
left: 15px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--faint);
|
||
pointer-events: none;
|
||
display: inline-flex;
|
||
}
|
||
|
||
.input-wrap .peek {
|
||
position: absolute;
|
||
right: 6px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.login-form .btn-primary .icon-arrow {
|
||
display: inline-flex;
|
||
transition: transform var(--t-fast);
|
||
}
|
||
|
||
.login-form .btn-primary:hover .icon-arrow {
|
||
transform: translateX(3px);
|
||
}
|
||
|
||
.auth-error {
|
||
padding: 10px 14px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--vermilion-line);
|
||
background: var(--vermilion-soft);
|
||
color: var(--vermilion);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-note {
|
||
margin-top: 28px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid var(--line);
|
||
font-size: 12.5px;
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.inline-link {
|
||
color: var(--ink);
|
||
border-bottom: 1px solid var(--line-2);
|
||
}
|
||
|
||
.inline-link:hover {
|
||
border-color: var(--ink);
|
||
}
|
||
|
||
.form-foot {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.form-foot .mono {
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
/* Install hint (mobile only) */
|
||
.install-hint-slot {
|
||
display: none;
|
||
margin-top: var(--space-lg);
|
||
padding-bottom: var(--safe-bottom);
|
||
}
|
||
|
||
/* ── Brand stage (always night ink) ────────────────── */
|
||
.stage {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 48px 64px 44px;
|
||
isolation: isolate;
|
||
}
|
||
|
||
.stage::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -20%;
|
||
z-index: -1;
|
||
background:
|
||
radial-gradient(40% 35% at 78% 22%, color-mix(in srgb, var(--vermilion) 22%, transparent), transparent 70%),
|
||
radial-gradient(45% 40% at 20% 85%, color-mix(in srgb, var(--celadon) 16%, transparent), transparent 70%),
|
||
radial-gradient(30% 30% at 60% 60%, color-mix(in srgb, var(--brass) 10%, transparent), transparent 70%);
|
||
filter: blur(10px);
|
||
animation: drift 22s ease-in-out infinite alternate;
|
||
}
|
||
|
||
@keyframes drift {
|
||
to {
|
||
transform: translate3d(-3%, 2%, 0) scale(1.06);
|
||
}
|
||
}
|
||
|
||
/* Fine ruled lines, like letter paper */
|
||
.stage::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: -1;
|
||
background-image: linear-gradient(var(--line) 1px, transparent 1px);
|
||
background-size: 100% 44px;
|
||
mask-image: linear-gradient(180deg, transparent, #000 30%, #000 70%, transparent);
|
||
-webkit-mask-image: linear-gradient(180deg, transparent, #000 30%, #000 70%, transparent);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.stage-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 16px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.2em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.stage-top span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stage-top .pulse {
|
||
color: var(--celadon);
|
||
}
|
||
|
||
.vertical {
|
||
position: absolute;
|
||
right: 16px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
writing-mode: vertical-rl;
|
||
font-family: var(--font-serif);
|
||
font-size: 14px;
|
||
letter-spacing: 0.7em;
|
||
color: var(--faint);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.vertical::before,
|
||
.vertical::after {
|
||
content: '';
|
||
width: 1px;
|
||
height: 64px;
|
||
background: var(--line-2);
|
||
}
|
||
|
||
.stage-body {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 344px);
|
||
gap: 40px;
|
||
align-items: center;
|
||
padding-right: 56px;
|
||
}
|
||
|
||
.headline {
|
||
font-family: var(--font-serif);
|
||
font-weight: 600;
|
||
font-size: clamp(28px, 2.7vw, 40px);
|
||
line-height: 1.42;
|
||
letter-spacing: 0.05em;
|
||
text-wrap: balance;
|
||
}
|
||
|
||
.headline em {
|
||
font-style: normal;
|
||
color: var(--vermilion);
|
||
text-emphasis: filled dot var(--vermilion);
|
||
text-emphasis-position: under right;
|
||
-webkit-text-emphasis: filled dot var(--vermilion);
|
||
-webkit-text-emphasis-position: under;
|
||
}
|
||
|
||
.stage-lede {
|
||
margin-top: 22px;
|
||
max-width: 26em;
|
||
color: var(--muted);
|
||
font-size: 14.5px;
|
||
line-height: 1.95;
|
||
}
|
||
|
||
.specimen {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.spec {
|
||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||
border: 1px solid var(--line-2);
|
||
border-radius: 16px;
|
||
padding: 14px 16px;
|
||
box-shadow: var(--shadow-lg);
|
||
backdrop-filter: blur(8px);
|
||
font-size: 13.5px;
|
||
line-height: 1.75;
|
||
opacity: 0;
|
||
animation: rise 0.8s var(--ease) forwards, bob 7s ease-in-out infinite;
|
||
}
|
||
|
||
.spec:nth-child(1) {
|
||
width: 72%;
|
||
animation-delay: 0.3s, 1.1s;
|
||
border-top-left-radius: 5px;
|
||
}
|
||
|
||
.spec:nth-child(2) {
|
||
width: 90%;
|
||
align-self: flex-end;
|
||
animation-delay: 0.7s, 1.5s;
|
||
border-top-right-radius: 5px;
|
||
background: color-mix(in srgb, var(--celadon) 10%, var(--surface));
|
||
border-color: var(--celadon-line);
|
||
}
|
||
|
||
.spec:nth-child(3) {
|
||
align-self: center;
|
||
animation-delay: 1.1s, 1.9s;
|
||
padding: 8px 14px;
|
||
border-radius: 999px;
|
||
font-size: 12.5px;
|
||
color: var(--vermilion);
|
||
border-color: var(--vermilion-line);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.spec:nth-child(4) {
|
||
width: 84%;
|
||
align-self: flex-end;
|
||
animation-delay: 1.5s, 2.3s;
|
||
}
|
||
|
||
@keyframes rise {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(16px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
@keyframes bob {
|
||
50% {
|
||
translate: 0 -5px;
|
||
}
|
||
}
|
||
|
||
.spec-meta {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 11.5px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.spec-meta .who {
|
||
color: var(--celadon);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.spec .cite {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 1px 8px;
|
||
border-radius: 999px;
|
||
border: 1px dashed var(--celadon-line);
|
||
color: var(--celadon);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.spec strong {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.spec .stars {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.spec .stars .icon {
|
||
width: 15px;
|
||
height: 15px;
|
||
}
|
||
|
||
.stage-foot {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, auto);
|
||
justify-content: start;
|
||
border-top: 1px solid var(--line-2);
|
||
padding-top: 22px;
|
||
margin-right: 48px;
|
||
}
|
||
|
||
.fig {
|
||
padding-right: 44px;
|
||
margin-right: 44px;
|
||
border-right: 1px solid var(--line);
|
||
}
|
||
|
||
.fig:last-child {
|
||
border-right: 0;
|
||
}
|
||
|
||
.fig b {
|
||
display: block;
|
||
font-family: var(--font-num);
|
||
font-weight: 350;
|
||
font-size: 30px;
|
||
line-height: 1.1;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.fig b small {
|
||
font-size: 15px;
|
||
color: var(--muted);
|
||
margin-left: 3px;
|
||
}
|
||
|
||
.fig span {
|
||
font-size: 12.5px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* ── Forgot-password modal ─────────────────────────── */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-modal);
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 24px;
|
||
background: var(--scrim);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
|
||
.modal-content {
|
||
width: 100%;
|
||
max-width: 460px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-card-lg);
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 22px 24px 0;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-family: var(--font-serif);
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
background: none;
|
||
color: var(--muted);
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
background: var(--surface-2);
|
||
border-color: var(--line);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px 24px 24px;
|
||
}
|
||
|
||
.forgot-password-step {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.step-description {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.input-wrapper input {
|
||
width: 100%;
|
||
height: 44px;
|
||
padding: 0 14px;
|
||
border-radius: var(--radius-input);
|
||
border: 1px solid var(--line-2);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color var(--t-fast), box-shadow var(--t-fast);
|
||
}
|
||
|
||
.input-wrapper input:focus {
|
||
border-color: var(--ink);
|
||
box-shadow: var(--ring);
|
||
}
|
||
|
||
.input-wrapper input:disabled {
|
||
background: var(--surface-2);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.error-message {
|
||
padding: 9px 12px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--vermilion-line);
|
||
background: var(--vermilion-soft);
|
||
color: var(--vermilion);
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.success-message {
|
||
padding: 9px 12px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--celadon-line);
|
||
background: var(--celadon-soft);
|
||
color: var(--celadon);
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.modal-submit-btn {
|
||
height: 42px;
|
||
padding: 0 18px;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-btn);
|
||
background: var(--primary-bg);
|
||
color: var(--primary-fg);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background-color var(--t-fast), opacity var(--t-fast);
|
||
}
|
||
|
||
.modal-submit-btn:hover:not(:disabled) {
|
||
background: var(--primary-hover);
|
||
}
|
||
|
||
.modal-submit-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.modal-back-btn {
|
||
height: 42px;
|
||
padding: 0 16px;
|
||
border: 1px solid var(--line-2);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--surface);
|
||
color: var(--text2);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.modal-back-btn:hover:not(:disabled) {
|
||
border-color: var(--line-3);
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.password-requirements {
|
||
margin-top: 10px;
|
||
padding: 12px 14px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.requirements-title {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.password-requirements ul {
|
||
list-style: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.password-requirements li {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
padding-left: 14px;
|
||
position: relative;
|
||
}
|
||
|
||
.password-requirements li::before {
|
||
content: '·';
|
||
position: absolute;
|
||
left: 4px;
|
||
}
|
||
|
||
.password-requirements li.valid {
|
||
color: var(--celadon);
|
||
}
|
||
|
||
.password-requirements li.valid::before {
|
||
content: '✓';
|
||
left: 0;
|
||
}
|
||
|
||
/* ── Responsive ────────────────────────────────────── */
|
||
@media (max-width: 1180px) {
|
||
.stage-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.specimen {
|
||
display: none;
|
||
}
|
||
.stage {
|
||
padding: 40px 36px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.login {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.stage {
|
||
display: none;
|
||
}
|
||
.form-col {
|
||
padding: 28px 24px;
|
||
}
|
||
.install-hint-slot {
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.spec {
|
||
opacity: 1;
|
||
animation: none;
|
||
}
|
||
.stage::before {
|
||
animation: none;
|
||
}
|
||
}
|
||
</style>
|