Files
tehran_taj/apps/user-app/src/views/user/LoginView.vue
T
s.esmaeiliandClaude Sonnet 5 c05352d6fd refactor(ui): replace modal dialogs with toast/sheet components
Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 13:57:25 +03:30

349 lines
9.9 KiB
Vue

<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Per the earlier decision: OTP-based flow ported structurally, but the
SMS-send step is stubbed — no SMS provider exists. What's real vs stubbed,
all carried over unchanged from the previous version:
- Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. A non-match shows legacy's own
"اطلاعات کاربری در سیستم وجود ندارد" text, not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
there is no real code to check against. On "verify", the session store
logs in as the matched real customer, so downstream features (balance,
ledger, favourites-as-logged-in) work against real seeded data.
- `token.fireBaseToken` doesn't exist in this port (Firebase deferred).
- `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
-->
<template>
<div class="auth">
<PageBar title="ورود" back-to="home" />
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<h2 class="auth__title">ورود به منتزر</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
</p>
</section>
<section class="auth__card">
<!-- Step 2: OTP -->
<template v-if="body.step === 2">
<OtpInput
class="auth__otp"
input-classes="otp-input"
inputType="tel"
:num-inputs="4"
:should-auto-focus="true"
:defaultFocus="body.defaultFocus"
@on-complete="sendToken"
/>
<p v-if="timer > 0" class="auth__timer">
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
</p>
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
</template>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
<FormItem
v-show="body.step === 1"
name="username"
label="تلفن همراه"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
>
<Input type="number" v-model:value="formModel.username" />
</FormItem>
<button
type="submit"
class="auth__cta"
:class="{ 'is-loading': body.loading }"
:disabled="body.loading || (timer > 0 && body.step === 2)"
>
{{ btnText }}
</button>
</Form>
</section>
<p class="auth__switch">
اگر ثبت نام نکرده‌اید
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
</p>
</container>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
const body = reactive({
step: 1,
loading: false,
defaultFocus: false,
})
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
setTimeout(() => {
if (timer.value > 0) {
timer.value -= 1
countDownTimer()
}
}, 1000)
}
}
/**
* Step 1 — ask the server to send an SMS code.
*
* This used to download every customer via GET /customers and match the phone
* number here in the browser, which handed any visitor the whole customer book
* (SEC-08). The lookup now happens server-side, exactly as the reference did it
* (henza_server ApiMobileController::userAuth, :39-49).
*
* The response is deliberately the same whether or not the number is
* registered, so an unknown number can no longer be told apart from a known
* one. That means we advance to step 2 either way; a number that does not exist
* simply never receives a code, and the verify call then rejects it.
*/
const sendPhone = async () => {
try {
body.loading = true
await api.requestOtp(String(formModel.username ?? ''))
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e: unknown) {
// 429 = the send throttle (too soon, or too many in the window). Its message
// is already Persian and user-facing, so show it rather than a generic one.
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
} finally {
body.loading = false
}
}
/**
* Step 2 — submit the code.
*
* The server compares it (reference: ApiMobileController::userVerfiy, :51-57,
* which aborts 422 on a mismatch) and returns a real token plus that one
* customer. Previously any 4-digit code was accepted client-side.
*/
const sendToken = async (otpCode: string) => {
try {
body.loading = true
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
session.login(token, customer.id, customer.mobile)
// D21 — ask the server which kind of principal this token is, so the
// expert-only entries appear for a sales expert. Failure is non-fatal:
// the entries simply stay hidden, and the server refuses those routes
// for anyone who is not an expert anyway.
try {
const who = await api.getExpertIdentity()
session.setSalesExpert(who.isSalesExpert)
} catch {
session.setSalesExpert(false)
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
toast.success('با موفقیت وارد شدید')
} catch (e: unknown) {
toast.error(
e instanceof Error && /\(429\)/.test(e.message)
? (extractServerMessage(e) ?? 'تعداد تلاش‌ها بیش از حد مجاز است.')
: 'کد وارد شده صحیح نیست',
)
} finally {
body.loading = false
}
}
/** Pulls the API's Persian `message` out of the thrown error's body text. */
function extractServerMessage(e: Error): string | null {
const match = e.message.match(/\{.*\}/)
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string }
return typeof parsed.message === 'string' ? parsed.message : null
} catch {
return null
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style scoped lang="less">
.auth {
background: @canvas;
min-height: 100vh;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
}
.auth__intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
font-weight: 800;
color: @ink;
}
.auth__sub {
margin: 6px 0 0;
font-size: 12.5px;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.auth__otp {
display: flex;
justify-content: center;
gap: 10px;
direction: ltr;
padding-bottom: 6px;
}
.auth__timer {
margin: 12px 0 16px;
text-align: center;
font-size: 11.5px;
color: @inkMuted;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
}
.auth__cta {
width: 100%;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @primary;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.auth__cta:active {
transform: scale(0.99);
}
.auth__cta:disabled {
opacity: 0.45;
cursor: default;
}
.auth__switch {
margin: 20px 0 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
.auth__switch button {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @wine;
}
</style>
<style lang="less">
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
be scoped to this component. */
.otp-input {
width: 48px;
height: 52px;
padding: 5px;
margin: 0;
font-size: 19px;
font-weight: 700;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
border-radius: 13px;
text-align: center;
transition: border-color 0.18s ease;
}
.otp-input:focus {
outline: none;
border-color: @primary;
}
.otp-input.error {
border-color: @danger !important;
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>