edit login & otp

This commit is contained in:
2026-05-31 14:12:18 +03:30
parent 2f485914b4
commit 27c44710be
34 changed files with 448 additions and 67 deletions
+4
View File
@@ -1,3 +1,5 @@
import { logApiServerTiming } from './server-timing';
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export class ApiError extends Error {
@@ -36,6 +38,8 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
headers,
});
logApiServerTiming(path, res);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as
| { message?: string | string[] }
+60
View File
@@ -0,0 +1,60 @@
import { reactive } from 'vue';
export const apiMetrics = reactive({
inFlight: 0,
lastDurationMs: null as number | null,
lastServerMs: null as number | null,
lastMethod: 'GET',
lastPath: '',
lastOk: true,
lastRequestId: null as string | null,
});
export function parseTimingFromResponse(res: Response): {
serverMs: number | null;
requestId: string | null;
} {
let serverMs: number | null = null;
const headerMs = res.headers.get('X-Response-Time-Ms');
if (headerMs) {
const n = Number(headerMs);
if (Number.isFinite(n)) {
serverMs = n;
}
}
if (serverMs == null) {
const serverTiming = res.headers.get('Server-Timing');
const match = serverTiming?.match(/dur=(\d+(?:\.\d+)?)/);
if (match) {
serverMs = Math.round(Number(match[1]));
}
}
return {
serverMs,
requestId: res.headers.get('X-Request-Id'),
};
}
export function trackApiRequest(
method: string,
path: string,
): (ok: boolean, res?: Response) => void {
apiMetrics.inFlight += 1;
const start = performance.now();
return (ok: boolean, res?: Response) => {
apiMetrics.inFlight = Math.max(0, apiMetrics.inFlight - 1);
apiMetrics.lastDurationMs = Math.round(performance.now() - start);
apiMetrics.lastMethod = method.toUpperCase();
apiMetrics.lastPath = path.split('?')[0] ?? path;
apiMetrics.lastOk = ok;
if (res) {
const timing = parseTimingFromResponse(res);
apiMetrics.lastServerMs = timing.serverMs;
apiMetrics.lastRequestId = timing.requestId;
} else {
apiMetrics.lastServerMs = null;
apiMetrics.lastRequestId = null;
}
};
}
+15
View File
@@ -0,0 +1,15 @@
/** Log Server-Timing from API responses (DevTools Network tab also shows the header). */
export function logApiServerTiming(path: string, res: Response): void {
const timing = res.headers.get('server-timing');
if (!timing) {
return;
}
const enabled =
import.meta.env.DEV || import.meta.env.VITE_LOG_API_TIMING === 'true';
if (!enabled) {
return;
}
const match = /total;dur=(\d+)/i.exec(timing);
const ms = match?.[1] ?? timing;
console.info(`[API] ${path} — server-timing: total;dur=${ms}`);
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="mb-4 flex rounded-xl border border-border bg-surface-3 p-1">
<div class="flex rounded-xl border border-border bg-surface-3 p-1">
<button
type="button"
class="flex-1 rounded-lg py-2 text-sm font-medium transition"
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="space-y-1.5">
<label v-if="label" class="block text-sm font-medium text-muted">{{ label }}</label>
<div class="flex justify-center gap-3" dir="ltr">
<input
v-for="(_, index) in length"
:key="index"
ref="inputRefs"
:value="cells[index]"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
class="h-14 w-14 rounded-xl border border-border bg-surface-2 text-center text-xl font-semibold text-text outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
:aria-label="`رقم ${index + 1}`"
@input="onInput(index, $event)"
@keydown="onKeydown(index, $event)"
@paste="onPaste"
@focus="($event.target as HTMLInputElement).select()"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
modelValue?: string;
label?: string;
length?: number;
disabled?: boolean;
}>(),
{
modelValue: '',
length: 4,
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
complete: [value: string];
}>();
const inputRefs = ref<HTMLInputElement[]>([]);
const cells = computed(() => {
const digits = props.modelValue.replace(/\D/g, '').slice(0, props.length);
return Array.from({ length: props.length }, (_, index) => digits[index] ?? '');
});
function updateValue(next: string) {
const normalized = next.replace(/\D/g, '').slice(0, props.length);
emit('update:modelValue', normalized);
if (normalized.length === props.length) {
emit('complete', normalized);
}
}
function focusAt(index: number) {
nextTick(() => {
inputRefs.value[index]?.focus();
});
}
function onInput(index: number, event: Event) {
const input = event.target as HTMLInputElement;
const digit = input.value.replace(/\D/g, '').slice(-1);
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index ? digit : cell,
);
updateValue(next.join(''));
if (digit && index < props.length - 1) {
focusAt(index + 1);
}
}
function onKeydown(index: number, event: KeyboardEvent) {
if (event.key !== 'Backspace') {
return;
}
if (cells.value[index]) {
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index ? '' : cell,
);
updateValue(next.join(''));
return;
}
if (index > 0) {
event.preventDefault();
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index - 1 ? '' : cell,
);
updateValue(next.join(''));
focusAt(index - 1);
}
}
function onPaste(event: ClipboardEvent) {
event.preventDefault();
const pasted = event.clipboardData?.getData('text') ?? '';
const digits = pasted.replace(/\D/g, '').slice(0, props.length);
if (!digits) {
return;
}
updateValue(digits);
focusAt(Math.min(digits.length, props.length) - 1);
}
watch(
() => props.modelValue,
(value) => {
if (!value) {
focusAt(0);
}
},
);
defineExpose({
focus: () => focusAt(0),
clear: () => {
updateValue('');
focusAt(0);
},
});
</script>
+36
View File
@@ -0,0 +1,36 @@
<template>
<img
v-if="variant === 'mark'"
src="/icons/icon-192.png"
alt="منتایم"
:class="imgClass"
/>
<template v-else>
<img
src="/brand/logo-dark.png"
alt="منتایم"
:class="['dark:hidden', imgClass]"
/>
<img
src="/brand/logo-lite.png"
alt="منتایم"
:class="['hidden dark:block', imgClass]"
/>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue';
export type LogoVariant = 'auto' | 'mark' | 'horizontal' | 'vertical';
const props = withDefaults(
defineProps<{
variant?: LogoVariant;
class?: string;
}>(),
{ variant: 'auto' },
);
const imgClass = computed(() => props.class ?? 'h-8 w-auto object-contain');
</script>
+1 -1
View File
@@ -6,7 +6,7 @@
<input
:id="id"
v-model="model"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-[var(--color-placeholder)] focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
:class="ltr ? 'text-left' : ''"
v-bind="$attrs"
/>
+10 -2
View File
@@ -2,8 +2,14 @@
<AppShell>
<header class="flex items-center justify-between px-4 py-3">
<div>
<p class="text-lg font-bold text-text">{{ title }}</p>
<p v-if="subtitle" class="text-sm text-muted">{{ subtitle }}</p>
<div v-if="brand" class="flex items-center gap-3">
<AppLogo variant="horizontal" class="h-7 shrink-0" />
<p v-if="subtitle" class="text-sm text-muted">{{ subtitle }}</p>
</div>
<template v-else>
<p v-if="title" class="text-lg font-bold text-text">{{ title }}</p>
<p v-if="subtitle" class="text-sm text-muted">{{ subtitle }}</p>
</template>
</div>
<ThemeToggle />
</header>
@@ -14,11 +20,13 @@
</template>
<script setup lang="ts">
import AppLogo from '@/components/brand/AppLogo.vue';
import AppShell from '@/components/shell/AppShell.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
defineProps<{
title?: string;
subtitle?: string;
brand?: boolean;
}>();
</script>
+3 -2
View File
@@ -3,8 +3,8 @@
<header
class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur"
>
<div>
<p class="font-bold text-text">منتایم</p>
<div class="min-w-0">
<AppLogo variant="horizontal" class="h-8" />
<p v-if="auth.user" class="text-xs text-muted">{{ auth.user.mobile }}</p>
</div>
<div class="flex items-center gap-2">
@@ -54,6 +54,7 @@ import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/shell/AppShell.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import AppLogo from '@/components/brand/AppLogo.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
import type { NavItem } from '@/types/icons';
import { useAuthStore } from '@/stores/auth';
+1 -1
View File
@@ -60,7 +60,7 @@ const router = createRouter({
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (auth.token) {
if (auth.token && !auth.user) {
await auth.loadUser();
}
+4 -1
View File
@@ -26,10 +26,13 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem(TOKEN_KEY);
}
async function loadUser() {
async function loadUser(force = false) {
if (!token.value) {
return;
}
if (user.value && !force) {
return;
}
try {
user.value = await fetchMe(token.value);
} catch {
+2
View File
@@ -9,6 +9,7 @@
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-muted: #64748b;
--color-placeholder: rgb(100 116 139 / 0.35);
}
.dark {
@@ -18,6 +19,7 @@
--color-border: #334155;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
--color-placeholder: rgb(148 163 184 / 0.35);
}
@layer base {
+2 -3
View File
@@ -130,8 +130,7 @@ watch(selectedCategoryId, () => {
loadBusinesses();
});
onMounted(async () => {
await loadCategories();
await loadBusinesses();
onMounted(() => {
void Promise.all([loadCategories(), loadBusinesses()]);
});
</script>
+52 -37
View File
@@ -1,46 +1,60 @@
<template>
<AuthLayout title="منتایم" subtitle="ورود به حساب کاربری">
<p
v-if="error"
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{{ error }}
</p>
<AuthLayout brand subtitle="ورود به حساب کاربری">
<div class="flex flex-1 flex-col justify-start gap-8 pt-10">
<section class="flex flex-col items-center text-center">
<AppLogo variant="vertical" class="mb-3 h-28 w-auto object-contain" />
<p class="max-w-xs text-sm leading-7 text-muted">
نوبت‌دهی آنلاین برای مشتریان<br />
مدیریت ساده برای کسب‌وکار شما
</p>
</section>
<AuthModeSwitch v-model="mode" />
<section class="flex flex-col gap-4">
<p
v-if="error"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{{ error }}
</p>
<form class="mt-auto space-y-4" @submit.prevent="onSubmit">
<AppInput
id="mobile"
v-model="mobile"
label="شماره موبایل"
type="tel"
inputmode="numeric"
placeholder="09121234567"
autocomplete="tel"
ltr
required
/>
<div class="flex flex-col gap-8">
<AuthModeSwitch v-model="mode" />
<AppInput
v-if="mode === 'password'"
id="password"
v-model="password"
label="رمز عبور"
type="password"
autocomplete="current-password"
ltr
required
/>
<form class="flex flex-col gap-4" @submit.prevent="onSubmit">
<AppInput
id="mobile"
v-model="mobile"
label="شماره موبایل"
type="tel"
inputmode="numeric"
placeholder="09121234567"
autocomplete="tel"
ltr
required
/>
<AppButton type="submit" :loading="loading">
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
</AppButton>
</form>
<AppInput
v-if="mode === 'password'"
id="password"
v-model="password"
label="رمز عبور"
type="password"
autocomplete="current-password"
ltr
required
/>
<p v-if="mode === 'password'" class="mt-3 text-center text-xs text-muted">
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
</p>
<AppButton type="submit" :loading="loading">
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
</AppButton>
</form>
</div>
<p v-if="mode === 'password'" class="text-center text-xs text-muted">
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
</p>
</section>
</div>
</AuthLayout>
</template>
@@ -54,6 +68,7 @@ import AuthModeSwitch from '@/components/auth/AuthModeSwitch.vue';
import type { AuthLoginMode } from '@/components/auth/AuthModeSwitch.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import AppLogo from '@/components/brand/AppLogo.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useAuthStore } from '@/stores/auth';
+37 -16
View File
@@ -14,19 +14,14 @@
</p>
<form class="space-y-4" @submit.prevent="onSubmit">
<AppInput
id="code"
<OtpPinInput
ref="pinRef"
v-model="code"
label="کد ۶ رقمی"
type="text"
inputmode="numeric"
maxlength="8"
placeholder="123456"
autocomplete="one-time-code"
ltr
required
label="کد ۴ رقمی"
:disabled="loading"
@complete="onSubmit"
/>
<AppButton type="submit" :loading="loading">ورود</AppButton>
<p v-if="loading" class="text-center text-sm text-muted">در حال تأیید...</p>
</form>
<p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted">
@@ -43,12 +38,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { requestOtp, verifyOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import OtpPinInput from '@/components/auth/OtpPinInput.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useAuthStore } from '@/stores/auth';
@@ -59,21 +54,34 @@ const auth = useAuthStore();
const mobile = computed(() => String(route.query.mobile ?? ''));
const devCode = computed(() => route.query.devCode as string | undefined);
const code = ref(devCode.value ?? '');
const pinRef = ref<InstanceType<typeof OtpPinInput> | null>(null);
const code = ref('');
const loading = ref(false);
const resendLoading = ref(false);
const error = ref('');
const secondsLeft = ref(Number(route.query.expires) || 120);
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
onMounted(async () => {
if (!mobile.value) {
router.replace({ name: 'login' });
return;
}
timer = setInterval(() => {
if (secondsLeft.value > 0) secondsLeft.value -= 1;
}, 1000);
if (devCode.value) {
code.value = devCode.value.replace(/\D/g, '').slice(0, 4);
await nextTick();
if (code.value.length === 4) {
await onSubmit();
}
return;
}
pinRef.value?.focus();
});
onUnmounted(() => {
@@ -81,15 +89,21 @@ onUnmounted(() => {
});
async function onSubmit() {
if (loading.value || code.value.length !== 4) {
return;
}
error.value = '';
loading.value = true;
try {
const res = await verifyOtp(mobile.value, code.value.trim());
const res = await verifyOtp(mobile.value, code.value);
auth.setSession(res.accessToken, res.user);
await router.replace({ name: 'app-home' });
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
code.value = '';
pinRef.value?.clear();
} finally {
loading.value = false;
}
@@ -101,10 +115,17 @@ async function resend() {
try {
const res = await requestOtp(mobile.value);
secondsLeft.value = res.expiresInSeconds;
code.value = '';
pinRef.value?.clear();
if (res.otpCode) {
await router.replace({
query: { ...route.query, devCode: res.otpCode, expires: String(res.expiresInSeconds) },
});
code.value = res.otpCode.replace(/\D/g, '').slice(0, 4);
await nextTick();
if (code.value.length === 4) {
await onSubmit();
}
}
} catch (e) {
error.value =