feat: redesign the login screen
Frontend CI / unit (test, type-check, build) (push) Successful in 1m23s
Frontend CI / browser E2E (frontend + pinned backend) (push) Failing after 1m24s

This commit is contained in:
2026-10-25 18:58:02 +03:30
parent 4dc8a7b25f
commit 83cc9a61f9
3 changed files with 225 additions and 297 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+25 -145
View File
@@ -18,158 +18,51 @@ import { formatNumber } from '@/utils/storage'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip)
const gridLinesPlugin = {
id: 'marketGridLines',
beforeDatasetsDraw(chart: Chart) {
const { ctx, chartArea, scales } = chart
if (!chartArea || !scales.y) return
const { left, right, top, bottom } = chartArea
const yScale = scales.y
ctx.save()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.07)'
ctx.lineWidth = 1
yScale.ticks.forEach((tick) => {
const y = yScale.getPixelForValue(tick.value)
ctx.beginPath()
ctx.moveTo(left, y)
ctx.lineTo(right, y)
ctx.stroke()
})
const verticalCount = 5
for (let i = 0; i <= verticalCount; i += 1) {
const x = left + ((right - left) / verticalCount) * i
ctx.beginPath()
ctx.moveTo(x, top)
ctx.lineTo(x, bottom)
ctx.stroke()
}
ctx.restore()
},
}
ChartJS.register(gridLinesPlugin)
const marketStore = useMarketStore()
const AXIS_SCALE = 6100
function createLineGradient(chart: Chart) {
const { ctx, chartArea } = chart
if (!chartArea) return '#32d74b'
const gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0)
gradient.addColorStop(0, '#22e865')
gradient.addColorStop(0.45, '#6fdc3a')
gradient.addColorStop(0.78, '#c8e630')
gradient.addColorStop(1, '#ffd55e')
return gradient
}
function createFillGradient(chart: Chart) {
const { ctx, chartArea } = chart
if (!chartArea) return 'rgba(50, 215, 75, 0.15)'
if (!chartArea) return 'rgba(232, 196, 92, 0.2)'
const { left, top, right, bottom } = chartArea
const width = right - left
const height = bottom - top
// Horizontal: green (left) → purple (right)
const horizontal = ctx.createLinearGradient(left, 0, right, 0)
horizontal.addColorStop(0, 'rgba(34, 220, 100, 0.72)')
horizontal.addColorStop(0.35, 'rgba(34, 200, 90, 0.55)')
horizontal.addColorStop(0.62, 'rgba(70, 150, 120, 0.38)')
horizontal.addColorStop(0.82, 'rgba(118, 55, 201, 0.52)')
horizontal.addColorStop(1, 'rgba(156, 80, 233, 0.62)')
// Vertical fade: opaque under line → transparent at bottom
const vertical = ctx.createLinearGradient(0, top, 0, bottom)
vertical.addColorStop(0, 'rgba(255, 255, 255, 0.92)')
vertical.addColorStop(0.35, 'rgba(255, 255, 255, 0.45)')
vertical.addColorStop(0.7, 'rgba(255, 255, 255, 0.12)')
vertical.addColorStop(1, 'rgba(255, 255, 255, 0)')
// Composite horizontal + vertical fade on offscreen canvas
const offscreen = document.createElement('canvas')
offscreen.width = Math.max(1, Math.round(width))
offscreen.height = Math.max(1, Math.round(height))
const offCtx = offscreen.getContext('2d')
if (!offCtx) return horizontal
offCtx.fillStyle = horizontal
offCtx.fillRect(0, 0, offscreen.width, offscreen.height)
offCtx.globalCompositeOperation = 'destination-in'
offCtx.fillStyle = vertical
offCtx.fillRect(0, 0, offscreen.width, offscreen.height)
const pattern = ctx.createPattern(offscreen, 'no-repeat')
if (!pattern) return horizontal
// Align pattern with chart area
const matrix = new DOMMatrix().translate(left, top)
pattern.setTransform(matrix)
return pattern
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom)
gradient.addColorStop(0, 'rgba(244, 214, 120, 0.55)')
gradient.addColorStop(0.55, 'rgba(118, 75, 190, 0.28)')
gradient.addColorStop(1, 'rgba(118, 75, 190, 0)')
return gradient
}
function isLastPoint(context: ScriptableContext<'line'>) {
const dataset = context.dataset.data as number[]
return context.dataIndex === dataset.length - 1
}
function buildYAxisConfig(values: number[]) {
if (!values.length) {
return { min: 0, max: 1, ticks: [0, 0.33, 0.66, 1] }
}
function paddedDomain(values: number[]) {
if (!values.length) return { min: 0, max: 1 }
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || max * 0.02
const padding = range * 0.025
const yMin = min - padding
const yMax = max + padding
const step = (yMax - yMin) / 3
return {
min: yMin,
max: yMax,
ticks: [yMin, yMin + step, yMin + step * 2, yMax],
}
const range = max - min || max * 0.04
const padding = range * 0.18
return { min: min - padding, max: max + padding }
}
const yAxisConfig = computed(() => buildYAxisConfig(marketStore.priceHistory))
const yDomain = computed(() => paddedDomain(marketStore.priceHistory))
const chartData = computed(() => ({
labels: marketStore.labels,
datasets: [
{
data: marketStore.priceHistory,
borderColor: (context: ScriptableContext<'line'>) =>
createLineGradient(context.chart),
backgroundColor: (context: ScriptableContext<'line'>) =>
createFillGradient(context.chart),
borderColor: '#f4d678',
backgroundColor: (context: ScriptableContext<'line'>) => createFillGradient(context.chart),
fill: true,
tension: 0.42,
pointRadius: (context: ScriptableContext<'line'>) =>
isLastPoint(context) ? 3 : 0,
pointHoverRadius: (context: ScriptableContext<'line'>) =>
isLastPoint(context) ? 4 : 0,
pointBackgroundColor: '#ffd55e',
pointBorderColor: '#ffd55e',
pointBorderWidth: 0,
pointHoverBackgroundColor: '#ffd55e',
pointHoverBorderColor: '#fff8dc',
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: '#fff8dc',
pointHoverBorderColor: '#f4d678',
pointHoverBorderWidth: 2,
borderWidth: 2,
borderWidth: 2.5,
},
],
}))
const chartOptions = computed(() => {
const yAxis = yAxisConfig.value
const domain = yDomain.value
return {
responsive: true,
@@ -207,22 +100,9 @@ const chartOptions = computed(() => {
border: { display: false },
},
y: {
position: 'left' as const,
display: true,
min: yAxis.min,
max: yAxis.max,
afterBuildTicks: (axis: { ticks: { value: number }[] }) => {
axis.ticks = yAxis.ticks.map((value) => ({ value }))
},
ticks: {
color: 'rgba(174, 185, 210, 0.42)',
font: { family: 'Vazirmatn', size: 10, weight: 600 },
padding: 2,
autoSkip: false,
maxTicksLimit: 4,
callback: (value: string | number) =>
formatNumber(Math.round(Number(value) / AXIS_SCALE)),
},
display: false,
min: domain.min,
max: domain.max,
grid: { display: false },
border: { display: false },
},
@@ -239,7 +119,7 @@ const chartOptions = computed(() => {
<style scoped>
.market-chart {
height: 138px;
height: 196px;
width: 100%;
position: relative;
margin-top: 4px;
+200 -152
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { StaleSessionError } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useMarketStore } from '@/stores/market'
import { useToast } from '@/composables/useToast'
import { illustrations } from '@/assets/illustrations'
import loginHero from '@/assets/login/login-hero.jpg'
const router = useRouter()
const authStore = useAuthStore()
@@ -17,6 +16,7 @@ const isRegister = ref(false)
const username = ref('')
const password = ref('')
const displayName = ref('')
const showPassword = ref(false)
const loading = ref(false)
async function submit() {
@@ -48,220 +48,268 @@ async function submit() {
loading.value = false
}
}
function forgotPassword() {
toast.error('بازیابی رمز هنوز فعال نشده است.')
}
</script>
<template>
<div class="login-page">
<div class="login-page__glow login-page__glow--top" aria-hidden="true" />
<div class="login-page__glow login-page__glow--bottom" aria-hidden="true" />
<div class="login-screen">
<div class="login-screen__frame">
<img :src="loginHero" alt="" class="login-screen__hero" />
<div class="login-card card-enter">
<div class="login-card__hero">
<img :src="illustrations.treasureChest" alt="" class="login-card__icon" aria-hidden="true" />
<h1 class="login-card__title">سرزمین طلا</h1>
<p class="login-card__subtitle">
{{ isRegister ? 'حساب جدید بساز و ماجراجویی مالی رو شروع کن' : 'وارد حسابت شو و ادامه بده' }}
</p>
</div>
<form class="login-form" @submit.prevent="submit">
<form class="login-screen__form" @submit.prevent="submit">
<label v-if="isRegister" class="login-field">
<span class="login-field__label">نام نمایشی</span>
<span class="login-field__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19.2c1.4-3 3.8-4.5 7-4.5s5.6 1.5 7 4.5" stroke-linecap="round" />
</svg>
</span>
<input
v-model="displayName"
type="text"
class="login-field__input"
placeholder="مثلاً علی"
placeholder="نام نمایشی"
autocomplete="name"
/>
</label>
<label class="login-field">
<span class="login-field__label">نام کاربری</span>
<span class="login-field__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19.2c1.4-3 3.8-4.5 7-4.5s5.6 1.5 7 4.5" stroke-linecap="round" />
</svg>
</span>
<input
v-model="username"
type="text"
class="login-field__input"
placeholder="demo"
placeholder="نام کاربری"
autocomplete="username"
dir="ltr"
/>
</label>
<label class="login-field">
<span class="login-field__label">رمز عبور</span>
<span class="login-field__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="6" y="10.5" width="12" height="8.5" rx="2" />
<path d="M9 10.5V8.2a3 3 0 0 1 6 0v2.3" stroke-linecap="round" />
</svg>
</span>
<input
v-model="password"
type="password"
class="login-field__input"
placeholder="••••••"
autocomplete="current-password"
dir="ltr"
:type="showPassword ? 'text' : 'password'"
placeholder="رمز عبور"
:autocomplete="isRegister ? 'new-password' : 'current-password'"
/>
<button
type="button"
class="login-field__eye"
:aria-label="showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<svg v-if="!showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M2.8 12S6.2 6.8 12 6.8 21.2 12 21.2 12 17.8 17.2 12 17.2 2.8 12 2.8 12Z" />
<circle cx="12" cy="12" r="2.4" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 5.5 19.5 19" stroke-linecap="round" />
<path d="M9.2 9.4A3.2 3.2 0 0 0 12 15.2c.7 0 1.3-.2 1.8-.6M6.2 7.8C4.4 9 2.8 12 2.8 12S6.2 17.2 12 17.2c1.3 0 2.5-.3 3.5-.8M10.2 6.9c.6-.1 1.2-.1 1.8-.1 5.8 0 9.2 5.2 9.2 5.2s-.8 1.2-2.2 2.5" stroke-linecap="round" />
</svg>
</button>
</label>
<PrimaryButton
type="submit"
variant="gold"
size="lg"
full-width
:disabled="loading"
>
<button type="submit" class="login-gold" :disabled="loading">
{{ loading ? 'لطفاً صبر کن...' : isRegister ? 'ثبت‌نام' : 'ورود' }}
</PrimaryButton>
</button>
<p class="login-or"><span>یا</span></p>
<button type="button" class="login-outline" @click="isRegister = !isRegister">
{{ isRegister ? 'ورود' : 'ثبت‌نام' }}
</button>
<button type="button" class="login-forgot" @click="forgotPassword">
رمز عبور را فراموش کرده‌اید؟
</button>
</form>
<button type="button" class="login-toggle" @click="isRegister = !isRegister">
<template v-if="isRegister">
حساب داری؟ <span class="login-toggle__action">وارد شو</span>
</template>
<template v-else>
حساب نداری؟ <span class="login-toggle__action">ثبت‌نام کن</span>
</template>
</button>
<p v-if="!isRegister" class="login-hint">حساب آزمایشی: demo / 123456</p>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
.login-screen {
min-height: 100dvh;
background: #07111f;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-screen__frame {
position: relative;
width: min(100%, 430px);
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
background: #040817;
background: #071427;
display: flex;
flex-direction: column;
}
.login-page__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
.login-screen__hero {
width: 100%;
flex: 1 1 auto;
height: 0;
min-height: 0;
object-fit: cover;
object-position: top center;
display: block;
}
.login-page__glow--top {
top: -80px;
right: -40px;
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(118, 55, 201, 0.35) 0%, transparent 70%);
}
.login-page__glow--bottom {
bottom: -60px;
left: -30px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(244, 174, 33, 0.2) 0%, transparent 70%);
}
.login-card {
.login-screen__form {
position: relative;
z-index: 1;
width: 100%;
max-width: 380px;
padding: 28px 22px 22px;
border-radius: 24px;
background: linear-gradient(145deg, rgba(29, 42, 78, 0.98), rgba(12, 23, 50, 0.98));
border: 1px solid rgba(118, 75, 190, 0.45);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 16px 40px rgba(0, 0, 0, 0.35),
0 0 24px rgba(118, 55, 201, 0.15);
}
.login-card__hero {
flex: 0 0 auto;
margin-top: -56px;
padding: 56px 22px calc(16px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 24px;
}
.login-card__icon {
width: 88px;
height: 88px;
object-fit: contain;
margin-bottom: 12px;
}
.login-card__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--gold-light);
margin-bottom: 8px;
}
.login-card__subtitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
line-height: 1.6;
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
background: linear-gradient(180deg, rgba(7, 20, 39, 0) 0%, rgba(7, 20, 39, 0.72) 28px, #071427 56px);
}
.login-field {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
display: block;
}
.login-field__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-main);
}
.login-field__input {
.login-field input {
width: 100%;
min-height: 48px;
padding: 12px 14px;
height: 52px;
border-radius: 14px;
border: 1px solid rgba(118, 75, 190, 0.45);
background: #040817;
color: var(--text-main);
border: 1.5px solid #9eb0c9;
background: #10233f;
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-size: 15px;
font-weight: 600;
text-align: right;
padding: 0 46px 0 46px;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.login-field__input:focus {
border-color: rgba(244, 174, 33, 0.65);
box-shadow: 0 0 0 3px rgba(244, 174, 33, 0.12);
.login-field input::placeholder {
color: #8ea2bf;
font-weight: 600;
}
.login-toggle {
width: 100%;
margin-top: 16px;
padding: 8px;
background: none;
border: none;
color: var(--text-muted);
.login-field input:focus {
border-color: rgba(232, 184, 74, 0.7);
}
.login-field input:-webkit-autofill {
-webkit-text-fill-color: #fff;
caret-color: #fff;
box-shadow: 0 0 0 1000px #10233f inset;
}
.login-field__icon,
.login-field__eye {
position: absolute;
top: 0;
height: 52px;
width: 42px;
display: grid;
place-items: center;
color: #9eb0c9;
}
.login-field__icon {
right: 6px;
}
.login-field__eye {
left: 4px;
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
}
.login-field__icon svg,
.login-field__eye svg {
width: 20px;
height: 20px;
}
.login-gold,
.login-outline,
.login-forgot {
font-family: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
}
.login-toggle__action {
color: var(--gold-light);
.login-gold {
height: 52px;
border: 0;
border-radius: 14px;
color: #1b2433;
font-size: 18px;
font-weight: 800;
background: linear-gradient(180deg, #f6c453 0%, #e3a31a 100%);
box-shadow: 0 8px 16px rgba(210, 150, 20, 0.25);
}
.login-hint {
margin-top: 14px;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
.login-gold:disabled,
.login-outline:disabled {
opacity: 0.7;
cursor: default;
}
.login-or {
margin: 0;
display: flex;
align-items: center;
gap: 12px;
color: #8ea0b8;
font-size: 14px;
font-weight: 700;
}
.login-or::before,
.login-or::after {
content: '';
flex: 1;
height: 1px;
}
.login-or::before {
background: linear-gradient(90deg, transparent, rgba(158, 176, 201, 0.85));
}
.login-or::after {
background: linear-gradient(90deg, rgba(158, 176, 201, 0.85), transparent);
}
.login-outline {
height: 50px;
border-radius: 14px;
border: 1.5px solid #e0b44a;
background: transparent;
color: #f2d48a;
font-size: 17px;
font-weight: 800;
}
.login-forgot {
border: 0;
background: transparent;
color: #9aadc6;
font-size: 13px;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 4px;
padding: 4px 0 0;
}
</style>