Files
nademitex_user/src/views/auth/LoginView.vue
T

560 lines
13 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-09-13 10:46:57 +03:30
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
2026-09-13 10:46:57 +03:30
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'
2026-09-13 10:46:57 +03:30
import fabricUrl from '@/assets/images/login-fabric.jpg'
const router = useRouter()
const auth = useAuthStore()
2026-09-13 10:46:57 +03:30
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 ?? 'خطا در ارسال کد تأیید.'
}
}
2026-09-13 10:46:57 +03:30
onMounted(async () => {
try {
const contact = await getPublicContact()
supportPhone.value = contact.supportPhone
} catch {
supportPhone.value = null
}
})
</script>
<template>
2026-09-13 10:46:57 +03:30
<div class="page login-page">
<img class="login-page__bg" :src="fabricUrl" alt="" />
2026-09-13 10:46:57 +03:30
<div class="login-page__scroll">
<div class="login-page__stage">
<header class="login-top">
<p class="login-top__welcome">به دنیای پارچه خوش آمدید</p>
</header>
2026-09-13 10:46:57 +03:30
<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>
2026-09-13 10:46:57 +03:30
<form class="login-card" @submit.prevent="submit">
<h2 class="login-card__title">ورود به حساب کاربری</h2>
<p class="login-card__lead">برای ادامه، شماره موبایل خود را وارد کنید.</p>
2026-09-13 10:46:57 +03:30
<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>
2026-09-13 10:46:57 +03:30
<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;
2026-09-13 15:36:48 +03:30
background: var(--color-primary);
2026-09-13 10:46:57 +03:30
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 {
2026-09-13 15:36:48 +03:30
background: var(--color-primary-hover);
2026-09-13 10:46:57 +03:30
}
.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;
2026-09-13 15:36:48 +03:30
color: var(--color-primary);
2026-09-13 10:46:57 +03:30
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>