edit login & otp
@@ -3,8 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#16a34a" />
|
||||||
<title>منتایم</title>
|
<title>منتایم</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -1,4 +1,53 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<rect width="32" height="32" rx="8" fill="#0d9488"/>
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="white">M</text>
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
|
||||||
|
xlink:href="data:image/png;base64,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
|
||||||
|
MF41KGsAAAAASUVORK5CYII=" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 268 B After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "منتایم",
|
||||||
|
"short_name": "منتایم",
|
||||||
|
"description": "نوبتدهی آنلاین و مدیریت کسبوکار",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"lang": "fa",
|
||||||
|
"dir": "rtl",
|
||||||
|
"theme_color": "#16a34a",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logApiServerTiming } from './server-timing';
|
||||||
|
|
||||||
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -36,6 +38,8 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logApiServerTiming(path, res);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => null)) as
|
const body = (await res.json().catch(() => null)) as
|
||||||
| { message?: string | string[] }
|
| { message?: string | string[] }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
<template>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 rounded-lg py-2 text-sm font-medium transition"
|
class="flex-1 rounded-lg py-2 text-sm font-medium transition"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
v-model="model"
|
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' : ''"
|
:class="ltr ? 'text-left' : ''"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
<AppShell>
|
<AppShell>
|
||||||
<header class="flex items-center justify-between px-4 py-3">
|
<header class="flex items-center justify-between px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg font-bold text-text">{{ title }}</p>
|
<div v-if="brand" class="flex items-center gap-3">
|
||||||
<p v-if="subtitle" class="text-sm text-muted">{{ subtitle }}</p>
|
<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>
|
</div>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
@@ -14,11 +20,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
import AppShell from '@/components/shell/AppShell.vue';
|
import AppShell from '@/components/shell/AppShell.vue';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
brand?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<header
|
<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"
|
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>
|
<div class="min-w-0">
|
||||||
<p class="font-bold text-text">منتایم</p>
|
<AppLogo variant="horizontal" class="h-8" />
|
||||||
<p v-if="auth.user" class="text-xs text-muted">{{ auth.user.mobile }}</p>
|
<p v-if="auth.user" class="text-xs text-muted">{{ auth.user.mobile }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -54,6 +54,7 @@ import { computed } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import AppShell from '@/components/shell/AppShell.vue';
|
import AppShell from '@/components/shell/AppShell.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||||
import type { NavItem } from '@/types/icons';
|
import type { NavItem } from '@/types/icons';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const router = createRouter({
|
|||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
if (auth.token) {
|
if (auth.token && !auth.user) {
|
||||||
await auth.loadUser();
|
await auth.loadUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUser() {
|
async function loadUser(force = false) {
|
||||||
if (!token.value) {
|
if (!token.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (user.value && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
user.value = await fetchMe(token.value);
|
user.value = await fetchMe(token.value);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
--color-border: #e2e8f0;
|
--color-border: #e2e8f0;
|
||||||
--color-text: #0f172a;
|
--color-text: #0f172a;
|
||||||
--color-muted: #64748b;
|
--color-muted: #64748b;
|
||||||
|
--color-placeholder: rgb(100 116 139 / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
--color-border: #334155;
|
--color-border: #334155;
|
||||||
--color-text: #f1f5f9;
|
--color-text: #f1f5f9;
|
||||||
--color-muted: #94a3b8;
|
--color-muted: #94a3b8;
|
||||||
|
--color-placeholder: rgb(148 163 184 / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -130,8 +130,7 @@ watch(selectedCategoryId, () => {
|
|||||||
loadBusinesses();
|
loadBusinesses();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
await loadCategories();
|
void Promise.all([loadCategories(), loadBusinesses()]);
|
||||||
await loadBusinesses();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,46 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<AuthLayout title="منتایم" subtitle="ورود به حساب کاربری">
|
<AuthLayout brand subtitle="ورود به حساب کاربری">
|
||||||
<p
|
<div class="flex flex-1 flex-col justify-start gap-8 pt-10">
|
||||||
v-if="error"
|
<section class="flex flex-col items-center text-center">
|
||||||
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"
|
<AppLogo variant="vertical" class="mb-3 h-28 w-auto object-contain" />
|
||||||
>
|
<p class="max-w-xs text-sm leading-7 text-muted">
|
||||||
{{ error }}
|
نوبتدهی آنلاین برای مشتریان<br />
|
||||||
</p>
|
مدیریت ساده برای کسبوکار شما
|
||||||
|
</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">
|
<div class="flex flex-col gap-8">
|
||||||
<AppInput
|
<AuthModeSwitch v-model="mode" />
|
||||||
id="mobile"
|
|
||||||
v-model="mobile"
|
|
||||||
label="شماره موبایل"
|
|
||||||
type="tel"
|
|
||||||
inputmode="numeric"
|
|
||||||
placeholder="09121234567"
|
|
||||||
autocomplete="tel"
|
|
||||||
ltr
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AppInput
|
<form class="flex flex-col gap-4" @submit.prevent="onSubmit">
|
||||||
v-if="mode === 'password'"
|
<AppInput
|
||||||
id="password"
|
id="mobile"
|
||||||
v-model="password"
|
v-model="mobile"
|
||||||
label="رمز عبور"
|
label="شماره موبایل"
|
||||||
type="password"
|
type="tel"
|
||||||
autocomplete="current-password"
|
inputmode="numeric"
|
||||||
ltr
|
placeholder="09121234567"
|
||||||
required
|
autocomplete="tel"
|
||||||
/>
|
ltr
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<AppButton type="submit" :loading="loading">
|
<AppInput
|
||||||
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
|
v-if="mode === 'password'"
|
||||||
</AppButton>
|
id="password"
|
||||||
</form>
|
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">
|
<AppButton type="submit" :loading="loading">
|
||||||
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
|
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
|
||||||
</p>
|
</AppButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="mode === 'password'" class="text-center text-xs text-muted">
|
||||||
|
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -54,6 +68,7 @@ import AuthModeSwitch from '@/components/auth/AuthModeSwitch.vue';
|
|||||||
import type { AuthLoginMode } from '@/components/auth/AuthModeSwitch.vue';
|
import type { AuthLoginMode } from '@/components/auth/AuthModeSwitch.vue';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
import AppInput from '@/components/ui/AppInput.vue';
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<AppInput
|
<OtpPinInput
|
||||||
id="code"
|
ref="pinRef"
|
||||||
v-model="code"
|
v-model="code"
|
||||||
label="کد ۶ رقمی"
|
label="کد ۴ رقمی"
|
||||||
type="text"
|
:disabled="loading"
|
||||||
inputmode="numeric"
|
@complete="onSubmit"
|
||||||
maxlength="8"
|
|
||||||
placeholder="123456"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
ltr
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<AppButton type="submit" :loading="loading">ورود</AppButton>
|
<p v-if="loading" class="text-center text-sm text-muted">در حال تأیید...</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted">
|
<p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted">
|
||||||
@@ -43,12 +38,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router';
|
||||||
import { requestOtp, verifyOtp } from '@/api/auth';
|
import { requestOtp, verifyOtp } from '@/api/auth';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
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 AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
@@ -59,21 +54,34 @@ const auth = useAuthStore();
|
|||||||
const mobile = computed(() => String(route.query.mobile ?? ''));
|
const mobile = computed(() => String(route.query.mobile ?? ''));
|
||||||
const devCode = computed(() => route.query.devCode as string | undefined);
|
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 loading = ref(false);
|
||||||
const resendLoading = ref(false);
|
const resendLoading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const secondsLeft = ref(Number(route.query.expires) || 120);
|
const secondsLeft = ref(Number(route.query.expires) || 120);
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (!mobile.value) {
|
if (!mobile.value) {
|
||||||
router.replace({ name: 'login' });
|
router.replace({ name: 'login' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
if (secondsLeft.value > 0) secondsLeft.value -= 1;
|
if (secondsLeft.value > 0) secondsLeft.value -= 1;
|
||||||
}, 1000);
|
}, 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(() => {
|
onUnmounted(() => {
|
||||||
@@ -81,15 +89,21 @@ onUnmounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
|
if (loading.value || code.value.length !== 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
error.value = '';
|
error.value = '';
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await verifyOtp(mobile.value, code.value.trim());
|
const res = await verifyOtp(mobile.value, code.value);
|
||||||
auth.setSession(res.accessToken, res.user);
|
auth.setSession(res.accessToken, res.user);
|
||||||
await router.replace({ name: 'app-home' });
|
await router.replace({ name: 'app-home' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value =
|
error.value =
|
||||||
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
|
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
|
||||||
|
code.value = '';
|
||||||
|
pinRef.value?.clear();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -101,10 +115,17 @@ async function resend() {
|
|||||||
try {
|
try {
|
||||||
const res = await requestOtp(mobile.value);
|
const res = await requestOtp(mobile.value);
|
||||||
secondsLeft.value = res.expiresInSeconds;
|
secondsLeft.value = res.expiresInSeconds;
|
||||||
|
code.value = '';
|
||||||
|
pinRef.value?.clear();
|
||||||
if (res.otpCode) {
|
if (res.otpCode) {
|
||||||
await router.replace({
|
await router.replace({
|
||||||
query: { ...route.query, devCode: res.otpCode, expires: String(res.expiresInSeconds) },
|
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) {
|
} catch (e) {
|
||||||
error.value =
|
error.value =
|
||||||
|
|||||||