Files
nademitex_user/src/views/auth/LoginView.vue
T
2026-09-13 15:36:48 +03:30

560 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconQemashMark from '@/components/icons/IconQemashMark.vue'
import { getPublicContact, telHref } from '@/services/settings'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import fabricUrl from '@/assets/images/login-fabric.jpg'
const router = useRouter()
const auth = useAuthStore()
const supportPhone = ref<string | null>(null)
const supportHref = computed(() => telHref(supportPhone.value))
const phone = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const displayPhone = computed(() => (phone.value ? toPersianDigits(phone.value) : ''))
function phoneErrorMessage(value: string): string {
if (!value) return 'شماره موبایل را وارد کنید.'
if (!validatePhone(value)) return 'شماره موبایل معتبر نیست. (مثال: ۰۹۱۲۱۲۳۴۵۶۷)'
return ''
}
function clearPhoneError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'phone')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function onPhoneInput(value: string) {
phone.value = normalizePhone(value)
clearPhoneError()
}
function onPhoneBlur() {
if (!phone.value) return
const message = phoneErrorMessage(phone.value)
if (message) {
fieldErrors.value = { ...fieldErrors.value, phone: message }
}
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
phone.value = normalized
const message = phoneErrorMessage(normalized)
if (message) {
fieldErrors.value = { phone: message }
formError.value = FORM_VALIDATION_SUMMARY
return
}
const result = await auth.requestOtp(normalized, { isRegister: false })
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'login' } })
} else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
onMounted(async () => {
try {
const contact = await getPublicContact()
supportPhone.value = contact.supportPhone
} catch {
supportPhone.value = null
}
})
</script>
<template>
<div class="page login-page">
<img class="login-page__bg" :src="fabricUrl" alt="" />
<div class="login-page__scroll">
<div class="login-page__stage">
<header class="login-top">
<p class="login-top__welcome">به دنیای پارچه خوش آمدید</p>
</header>
<div class="login-brand">
<IconQemashMark :size="78" />
<h1 class="login-brand__fa">نادمی تکس</h1>
<p class="login-brand__en">NADEMITEX</p>
<p class="login-brand__tag">انتخاب حرفه‌ای‌ها</p>
</div>
<form class="login-card" @submit.prevent="submit">
<h2 class="login-card__title">ورود به حساب کاربری</h2>
<p class="login-card__lead">برای ادامه، شماره موبایل خود را وارد کنید.</p>
<FormErrorBanner :message="formError" />
<div class="phone-field" dir="ltr" :class="{ 'is-invalid': Boolean(fieldErrors.phone) }">
<span class="phone-field__locale" aria-hidden="true">
<svg class="iran-flag" viewBox="0 0 21 15" xmlns="http://www.w3.org/2000/svg">
<rect width="21" height="5" fill="#239F40" />
<rect y="5" width="21" height="5" fill="#fff" />
<rect y="10" width="21" height="5" fill="#DA0001" />
<circle cx="10.5" cy="7.5" r="1.35" fill="#DA0001" />
</svg>
<svg class="phone-field__caret" viewBox="0 0 10 6" fill="none">
<path
d="M1 1.2 5 5l4-3.8"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<input
class="phone-field__input"
:value="displayPhone"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
maxlength="11"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:aria-invalid="Boolean(fieldErrors.phone)"
aria-label="شماره موبایل"
@input="onPhoneInput(($event.target as HTMLInputElement).value)"
@blur="onPhoneBlur"
/>
</div>
<p v-if="fieldErrors.phone" class="phone-field__error">{{ fieldErrors.phone }}</p>
<button class="login-submit" type="submit" :disabled="auth.loading">
<span v-if="auth.loading" class="login-submit__spinner" aria-hidden="true" />
<span class="login-submit__chevron" aria-hidden="true">
<IconChevronLeft :size="18" color="#F4EDE4" />
</span>
<span>دریافت کد تأیید</span>
</button>
<p class="login-register">
حساب کاربری ندارید؟
<RouterLink to="/register">ثبت‌نام کنید</RouterLink>
</p>
<div class="login-or" role="separator">
<span>یا</span>
</div>
<div class="login-help">
<a
class="login-help__item"
:href="supportHref"
:class="{ 'is-disabled': !supportHref }"
:aria-disabled="!supportHref"
>
<span class="login-help__icon">
<IconHeadset :size="22" color="#3A3936" />
</span>
پشتیبانی
</a>
<a
class="login-help__item"
:href="supportHref"
:class="{ 'is-disabled': !supportHref }"
:aria-disabled="!supportHref"
>
<span class="login-help__icon">
<IconPhone :size="22" color="#3A3936" />
</span>
تماس با ما
</a>
</div>
<p class="login-card__foot">پارچه، آغاز یک اعتماد ماندگار…</p>
</form>
<div class="login-spacer" aria-hidden="true" />
<p class="login-watermark">MORE<br />THAN<br />FABRIC</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
isolation: isolate;
height: 100%;
min-height: 100%;
color: #f4ede4;
background: #1a1612;
overflow: hidden;
}
.login-page__bg {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
pointer-events: none;
user-select: none;
}
.login-page__scroll {
position: relative;
z-index: 1;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.login-page__stage {
display: flex;
flex-direction: column;
min-height: 100%;
width: 100%;
max-width: 360px;
margin-inline: auto;
padding: calc(10px + env(safe-area-inset-top, 0px)) 16px
calc(14px + env(safe-area-inset-bottom, 0px));
}
.login-top {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 36px;
}
.login-top__welcome {
color: #f3e6d6;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.6;
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-top: 28px;
margin-bottom: 20px;
color: #f3e6d6;
}
.login-brand__fa {
margin: 14px 0 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.02em;
}
.login-brand__en {
margin: 6px 0 0;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.28em;
color: #ead7c0;
}
.login-brand__tag {
margin: 10px 0 0;
font-size: 0.875rem;
font-weight: 400;
color: #efe2d2;
}
.login-card {
width: 100%;
margin: 0;
padding: 22px 18px 16px;
border: none;
border-radius: 28px;
background: #f4f1ed;
box-shadow: 0 18px 40px rgba(12, 10, 8, 0.22);
color: #2a2622;
}
.login-card__title {
margin: 0;
text-align: center;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.5;
color: #1c1b19;
}
.login-card__lead {
margin: 6px 0 0;
text-align: center;
font-size: 0.75rem;
line-height: 1.7;
color: #8a847c;
}
.login-card :deep(.form-error-banner) {
margin-top: 14px;
margin-bottom: 0;
}
.phone-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 48px;
margin-top: 18px;
padding: 0 8px 0 14px;
border: 1px solid #e4e0db;
border-radius: 999px;
background: #f7f5f1;
}
.phone-field.is-invalid {
border-color: color-mix(in srgb, var(--color-danger) 55%, #e4e0db);
}
.phone-field__locale {
display: inline-flex;
align-items: center;
gap: 4px;
color: #9a9590;
flex-shrink: 0;
}
.iran-flag {
width: 22px;
height: 16px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.phone-field__caret {
width: 10px;
height: 6px;
}
.phone-field__input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #2a2622;
font-family: inherit;
font-size: 0.9375rem;
letter-spacing: 0.04em;
text-align: start;
outline: none;
}
.phone-field__input::placeholder {
color: #b0aaa3;
}
.phone-field__error {
margin: 8px 4px 0;
color: var(--color-danger);
font-size: 0.75rem;
line-height: 1.5;
}
.login-submit {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
margin-top: 12px;
padding: 0 44px;
border: none;
border-radius: 999px;
background: var(--color-primary);
color: #f7f4ef;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login-submit:not(:disabled):hover,
.login-submit:not(:disabled):focus-visible {
background: var(--color-primary-hover);
}
.login-submit:focus-visible {
outline: 2px solid #c9a56a;
outline-offset: 2px;
}
.login-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-submit__chevron {
position: absolute;
inset-inline-end: 16px;
display: grid;
place-items: center;
}
.login-submit__spinner {
position: absolute;
inset-inline-start: 16px;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.28);
border-top-color: #fff;
border-radius: 50%;
animation: login-spin 0.6s linear infinite;
}
.login-register {
margin: 12px 0 0;
text-align: center;
font-size: 0.8125rem;
line-height: 1.6;
color: #8a847c;
}
.login-register a {
font-weight: 700;
color: var(--color-primary);
text-decoration: none;
}
.login-or {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 4px 14px;
color: #9a9590;
font-size: 0.75rem;
}
.login-or::before,
.login-or::after {
content: '';
flex: 1;
height: 1px;
background: #e6e1db;
}
.login-help {
display: flex;
justify-content: center;
gap: 48px;
}
.login-help__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #6f6b66;
font-size: 0.75rem;
text-decoration: none;
}
.login-help__item.is-disabled {
pointer-events: none;
}
.login-help__icon {
width: 52px;
height: 52px;
border: 1px solid #d8d3cd;
border-radius: 50%;
display: grid;
place-items: center;
background: transparent;
}
.login-card__foot {
margin: 18px 0 2px;
text-align: center;
font-size: 0.75rem;
line-height: 1.7;
color: #8a847c;
}
.login-spacer {
flex: 1 1 auto;
min-height: 16px;
}
.login-watermark {
margin: 0;
padding-bottom: 8px;
color: rgba(244, 237, 228, 0.58);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.2em;
line-height: 1.35;
text-align: left;
direction: ltr;
}
@keyframes login-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 359px) {
.login-page__stage {
padding-inline: 12px;
}
.login-brand {
margin-top: 16px;
margin-bottom: 14px;
}
.login-brand__fa {
font-size: 1.5rem;
}
.login-help {
gap: 36px;
}
}
@media (min-width: 768px) {
.login-page {
padding: 0;
max-width: none;
margin: 0;
height: 100%;
min-height: 0;
overflow: hidden;
}
}
</style>