start frant 050228

This commit is contained in:
2026-05-18 20:18:28 +03:30
parent 26e59e03e0
commit cb65e5e601
33 changed files with 1493 additions and 425 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
<template>
<div class="app-shell">
<RouterView />
</div>
<RouterView />
</template>
<script setup lang="ts"></script>
+7 -2
View File
@@ -14,14 +14,19 @@ type ApiOptions = RequestInit & {
token?: string;
};
export function getApiToken(): string | null {
return localStorage.getItem('montime_access_token');
}
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
const token = options.token ?? getApiToken() ?? undefined;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
};
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const { token: _token, ...fetchOptions } = options;
+7
View File
@@ -0,0 +1,7 @@
<template>
<div
class="mx-auto flex min-h-dvh w-full max-w-[768px] flex-col bg-surface shadow-xl shadow-black/5 dark:shadow-black/30"
>
<slot />
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<template>
<button
:type="type"
class="inline-flex w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
:class="variantClass"
:disabled="disabled || loading"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'danger';
type?: 'button' | 'submit';
disabled?: boolean;
loading?: boolean;
}>(),
{ variant: 'primary', type: 'button', disabled: false, loading: false },
);
const variantClass = computed(() => {
switch (props.variant) {
case 'ghost':
return 'bg-transparent text-muted hover:bg-surface-3 hover:text-text';
case 'danger':
return 'bg-red-500/15 text-red-600 dark:text-red-400 hover:bg-red-500/25';
default:
return 'bg-brand-600 text-white hover:bg-brand-700';
}
});
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div class="space-y-1.5">
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
{{ label }}
</label>
<input
:id="id"
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="ltr ? 'text-left' : ''"
v-bind="$attrs"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ inheritAttrs: false });
withDefaults(
defineProps<{
label?: string;
id?: string;
ltr?: boolean;
}>(),
{ ltr: false },
);
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-surface-2 text-muted transition hover:text-text"
:aria-label="isDark ? 'حالت روشن' : 'حالت تاریک'"
@click="toggle"
>
<span v-if="isDark" aria-hidden="true">☀️</span>
<span v-else aria-hidden="true">🌙</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from '@/composables/useTheme';
const { isDark: mode, toggle } = useTheme();
const isDark = computed(() => mode.value === 'dark');
</script>
+20
View File
@@ -0,0 +1,20 @@
export type AppMode = 'web' | 'admin';
/** `web` = montime.ir (customers + business owners). `admin` = adm.montime.ir only. */
export function getAppMode(): AppMode {
const fromEnv = import.meta.env.VITE_APP_MODE as AppMode | undefined;
if (fromEnv === 'web' || fromEnv === 'admin') {
return fromEnv;
}
if (typeof window !== 'undefined') {
const host = window.location.hostname;
if (host === 'adm.montime.ir' || host.startsWith('adm.')) {
return 'admin';
}
}
return 'web';
}
export function useAppMode() {
return { mode: getAppMode(), isAdmin: getAppMode() === 'admin', isWeb: getAppMode() === 'web' };
}
+21
View File
@@ -0,0 +1,21 @@
import { useColorMode } from '@vueuse/core';
export function useTheme() {
const mode = useColorMode({
storageKey: 'montime-theme',
attribute: 'class',
modes: {
light: 'light',
dark: 'dark',
},
});
function toggle() {
mode.value = mode.value === 'dark' ? 'light' : 'dark';
}
return {
isDark: mode,
toggle,
};
}
+67
View File
@@ -0,0 +1,67 @@
<template>
<AppShell>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b border-amber-500/30 bg-surface/95 px-4 py-3 backdrop-blur"
>
<div>
<p class="font-bold text-text">پنل مدیریت</p>
<p class="text-xs text-amber-600 dark:text-amber-400">adm.montime.ir</p>
</div>
<div class="flex items-center gap-2">
<ThemeToggle />
<button
type="button"
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3"
@click="onLogout"
>
خروج
</button>
</div>
</header>
<main class="flex-1 space-y-4 px-4 py-4">
<slot />
</main>
<nav class="sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2">
<div class="grid grid-cols-3 gap-1">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex flex-col items-center rounded-xl py-2 text-xs"
:class="
route.name === item.name
? 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
: 'text-muted hover:bg-surface-3'
"
>
<span class="text-lg">{{ item.icon }}</span>
{{ item.label }}
</RouterLink>
</div>
</nav>
</AppShell>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/shell/AppShell.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const items = [
{ to: '/admin', name: 'admin-home', label: 'داشبورد', icon: '📊' },
{ to: '/admin/businesses', name: 'admin-businesses', label: 'کسب‌وکار', icon: '🏪' },
{ to: '/admin/users', name: 'admin-users', label: 'کاربران', icon: '👥' },
];
function onLogout() {
auth.logout();
router.push({ name: 'admin-login' });
}
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<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>
<ThemeToggle />
</header>
<main class="flex flex-1 flex-col px-4 pb-8">
<slot />
</main>
</AppShell>
</template>
<script setup lang="ts">
import AppShell from '@/components/shell/AppShell.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
defineProps<{
title?: string;
subtitle?: string;
}>();
</script>
+84
View File
@@ -0,0 +1,84 @@
<template>
<AppShell>
<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>
<p v-if="auth.user" class="text-xs text-muted">{{ auth.user.mobile }}</p>
</div>
<div class="flex items-center gap-2">
<ThemeToggle />
<button
type="button"
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3 hover:text-text"
@click="onLogout"
>
خروج
</button>
</div>
</header>
<main class="flex-1 px-4 py-4">
<slot />
</main>
<nav
class="sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2 backdrop-blur"
>
<div
class="grid gap-1"
:class="navItems.length === 4 ? 'grid-cols-4' : 'grid-cols-3'"
>
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex flex-col items-center rounded-xl px-2 py-2 text-xs transition"
:class="
route.name === item.name
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'text-muted hover:bg-surface-3 hover:text-text'
"
>
<span class="text-lg" aria-hidden="true">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</AppShell>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/shell/AppShell.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const navItems = computed(() => {
const items = [
{ to: '/app', name: 'app-home', label: 'خانه', icon: '🏠' },
{ to: '/app/bookings', name: 'app-bookings', label: 'رزروها', icon: '📅' },
{ to: '/app/profile', name: 'app-profile', label: 'پروفایل', icon: '👤' },
];
if (auth.isBusinessOwner) {
items.splice(2, 0, {
to: '/app/owner',
name: 'app-owner',
label: 'کسب‌وکار',
icon: '🏪',
});
}
return items;
});
function onLogout() {
auth.logout();
router.push({ name: 'login' });
}
</script>
+98 -26
View File
@@ -1,47 +1,119 @@
import { createRouter, createWebHistory } from 'vue-router';
import { getAppMode } from '@/composables/useAppMode';
import { useAuthStore } from '@/stores/auth';
const mode = getAppMode();
const webRoutes = [
{ path: '/', redirect: '/login' },
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true, shell: 'auth' },
},
{
path: '/verify',
name: 'verify',
component: () => import('@/views/auth/VerifyOtpView.vue'),
meta: { guest: true, shell: 'auth' },
},
{
path: '/app',
name: 'app-home',
component: () => import('@/views/app/AppHomeView.vue'),
meta: { requiresAuth: true, shell: 'web' },
},
{
path: '/app/bookings',
name: 'app-bookings',
component: () => import('@/views/app/AppBookingsView.vue'),
meta: { requiresAuth: true, shell: 'web' },
},
{
path: '/app/profile',
name: 'app-profile',
component: () => import('@/views/app/AppProfileView.vue'),
meta: { requiresAuth: true, shell: 'web' },
},
{
path: '/app/owner',
name: 'app-owner',
component: () => import('@/views/app/AppOwnerView.vue'),
meta: { requiresAuth: true, requiresBusinessOwner: true, shell: 'web' },
},
{ path: '/home', redirect: '/app' },
];
const adminRoutes = [
{ path: '/', redirect: '/admin/login' },
{
path: '/admin/login',
name: 'admin-login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true, shell: 'auth', adminAuth: true },
},
{
path: '/admin/verify',
name: 'admin-verify',
component: () => import('@/views/auth/VerifyOtpView.vue'),
meta: { guest: true, shell: 'auth', adminAuth: true },
},
{
path: '/admin',
name: 'admin-home',
component: () => import('@/views/admin/AdminHomeView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, shell: 'admin' },
},
{
path: '/admin/businesses',
name: 'admin-businesses',
component: () => import('@/views/admin/AdminBusinessesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, shell: 'admin' },
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/admin/AdminUsersView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, shell: 'admin' },
},
];
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/login',
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { guest: true },
},
{
path: '/verify',
name: 'verify',
component: () => import('@/views/VerifyOtpView.vue'),
meta: { guest: true },
},
{
path: '/home',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: { requiresAuth: true },
},
],
routes: mode === 'admin' ? adminRoutes : webRoutes,
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
const appMode = getAppMode();
if (auth.token && !auth.user) {
await auth.loadUser();
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login' };
return appMode === 'admin' ? { name: 'admin-login' } : { name: 'login' };
}
if (to.meta.requiresAdmin && !auth.isAdmin) {
return appMode === 'admin' ? { name: 'admin-login' } : { name: 'login' };
}
if (to.meta.requiresBusinessOwner && !auth.isBusinessOwner && !auth.isAdmin) {
return { name: 'app-home' };
}
if (to.meta.guest && auth.isLoggedIn) {
return { name: 'home' };
if (to.meta.adminAuth) {
return auth.isAdmin ? { name: 'admin-home' } : undefined;
}
return { name: 'app-home' };
}
if (appMode === 'admin' && auth.isLoggedIn && !auth.isAdmin && !to.meta.guest) {
auth.logout();
return { name: 'admin-login' };
}
});
+7 -1
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { computed, ref } from 'vue';
import type { User } from '@/api/auth';
import { fetchMe } from '@/api/auth';
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => Boolean(token.value));
const isAdmin = computed(() => user.value?.role === 'admin');
const isBusinessOwner = computed(() => user.value?.role === 'business_owner');
const isCustomer = computed(() => user.value?.role === 'customer');
function setSession(accessToken: string, profile: User) {
token.value = accessToken;
@@ -38,6 +41,9 @@ export const useAuthStore = defineStore('auth', () => {
token,
user,
isLoggedIn,
isAdmin,
isBusinessOwner,
isCustomer,
setSession,
logout,
loadUser,
+38 -161
View File
@@ -1,168 +1,45 @@
:root {
--bg: #0f1419;
--surface: #1a2332;
--surface-2: #243044;
--text: #f0f4f8;
--muted: #94a3b8;
--accent: #22c55e;
--accent-hover: #16a34a;
--danger: #f87171;
--border: rgba(148, 163, 184, 0.2);
--radius: 12px;
--shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Vazirmatn', ui-sans-serif, system-ui, sans-serif;
--color-brand-50: #ecfdf5;
--color-brand-100: #d1fae5;
--color-brand-500: #22c55e;
--color-brand-600: #16a34a;
--color-brand-700: #15803d;
--color-surface: #ffffff;
--color-surface-2: #f8fafc;
--color-surface-3: #f1f5f9;
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-muted: #64748b;
}
*,
*::before,
*::after {
box-sizing: border-box;
@layer theme {
.dark {
--color-surface: #0f1419;
--color-surface-2: #1a2332;
--color-surface-3: #243044;
--color-border: #334155;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
}
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
@layer base {
html {
@apply antialiased;
}
body {
font-family: 'Vazirmatn', system-ui, sans-serif;
background: radial-gradient(ellipse at top, #1e3a2f 0%, var(--bg) 55%);
color: var(--text);
line-height: 1.6;
}
body {
@apply bg-surface-2 text-text font-sans;
}
.app-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.card {
width: 100%;
max-width: 420px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
box-shadow: var(--shadow);
}
.logo {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.25rem;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--muted);
margin: 0 0 1.75rem;
font-size: 0.95rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--muted);
}
.field input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font: inherit;
direction: ltr;
text-align: left;
}
.field input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.btn {
width: 100%;
padding: 0.85rem 1rem;
border: none;
border-radius: 8px;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: var(--accent);
color: #052e16;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
color: var(--muted);
margin-top: 0.75rem;
}
.error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: var(--danger);
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.timer {
text-align: center;
color: var(--muted);
font-size: 0.875rem;
margin-top: 1rem;
}
.home-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.user-card {
background: var(--surface-2);
border-radius: 8px;
padding: 1rem;
}
.user-card dt {
color: var(--muted);
font-size: 0.8rem;
}
.user-card dd {
margin: 0.25rem 0 0.75rem;
font-weight: 500;
}
.user-card dd:last-child {
margin-bottom: 0;
#app {
@apply min-h-dvh;
}
}
-40
View File
@@ -1,40 +0,0 @@
<template>
<main class="card">
<header class="home-header">
<div>
<h1 class="logo">مون‌تایم</h1>
<p class="subtitle" style="margin: 0">خوش آمدید</p>
</div>
<button type="button" class="btn btn-ghost" style="width: auto" @click="logout">
خروج
</button>
</header>
<dl v-if="auth.user" class="user-card">
<dt>موبایل</dt>
<dd dir="ltr">{{ auth.user.mobile }}</dd>
<dt>نام</dt>
<dd>{{ auth.user.fullName ?? '—' }}</dd>
<dt>نقش</dt>
<dd>{{ auth.user.role }}</dd>
</dl>
</main>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const router = useRouter();
onMounted(async () => {
await auth.loadUser();
});
function logout() {
auth.logout();
router.push({ name: 'login' });
}
</script>
-59
View File
@@ -1,59 +0,0 @@
<template>
<main class="card">
<h1 class="logo">مون‌تایم</h1>
<p class="subtitle">ورود با شماره موبایل</p>
<p v-if="error" class="error">{{ error }}</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label for="mobile">شماره موبایل</label>
<input
id="mobile"
v-model="mobile"
type="tel"
inputmode="numeric"
placeholder="09121234567"
autocomplete="tel"
required
/>
</div>
<button class="btn btn-primary" type="submit" :disabled="loading">
{{ loading ? 'در حال ارسال…' : 'دریافت کد تأیید' }}
</button>
</form>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
const router = useRouter();
const mobile = ref('');
const loading = ref(false);
const error = ref('');
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await requestOtp(mobile.value.trim());
await router.push({
name: 'verify',
query: {
mobile: mobile.value.trim(),
expires: String(res.expiresInSeconds),
...(res.otpCode ? { devCode: res.otpCode } : {}),
},
});
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'خطا در ارسال درخواست. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
</script>
-127
View File
@@ -1,127 +0,0 @@
<template>
<main class="card">
<h1 class="logo">کد تأیید</h1>
<p class="subtitle">کد ارسال‌شده به {{ mobile }} را وارد کنید</p>
<p v-if="devCode" class="error" style="border-color: var(--accent); color: var(--accent)">
حالت توسعه — کد: {{ devCode }}
</p>
<p v-if="error" class="error">{{ error }}</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label for="code">کد ۶ رقمی</label>
<input
id="code"
v-model="code"
type="text"
inputmode="numeric"
maxlength="8"
placeholder="123456"
autocomplete="one-time-code"
required
/>
</div>
<button class="btn btn-primary" type="submit" :disabled="loading">
{{ loading ? 'در حال بررسی…' : 'ورود' }}
</button>
</form>
<p v-if="secondsLeft > 0" class="timer">
ارسال مجدد تا {{ secondsLeft }} ثانیه
</p>
<button
v-else
type="button"
class="btn btn-ghost"
:disabled="resendLoading"
@click="resend"
>
{{ resendLoading ? 'در حال ارسال…' : 'ارسال مجدد کد' }}
</button>
<button type="button" class="btn btn-ghost" @click="router.push({ name: 'login' })">
تغییر شماره موبایل
</button>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { requestOtp, verifyOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
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 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(() => {
if (!mobile.value) {
router.replace({ name: 'login' });
return;
}
timer = setInterval(() => {
if (secondsLeft.value > 0) {
secondsLeft.value -= 1;
}
}, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await verifyOtp(mobile.value, code.value.trim());
auth.setSession(res.accessToken, res.user);
await router.replace({ name: 'home' });
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
async function resend() {
error.value = '';
resendLoading.value = true;
try {
const res = await requestOtp(mobile.value);
secondsLeft.value = res.expiresInSeconds;
if (res.otpCode) {
await router.replace({
query: {
...route.query,
devCode: res.otpCode,
expires: String(res.expiresInSeconds),
},
});
}
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'ارسال مجدد ناموفق بود.';
} finally {
resendLoading.value = false;
}
}
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">کسب‌وکارها</h1>
<div class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted">
به‌زودی — CRUD کسب‌وکار و واگذاری مدیر
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import AdminLayout from '@/layouts/AdminLayout.vue';
</script>
+20
View File
@@ -0,0 +1,20 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">داشبورد مدیریت</h1>
<p class="mt-2 text-sm text-muted">فقط سوپر ادمین — adm.montime.ir</p>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">کسب‌وکارها</p>
<p class="mt-1 text-2xl font-bold text-text">—</p>
</div>
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">کاربران</p>
<p class="mt-1 text-2xl font-bold text-text">—</p>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import AdminLayout from '@/layouts/AdminLayout.vue';
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">کاربران</h1>
<div class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted">
به‌زودی
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import AdminLayout from '@/layouts/AdminLayout.vue';
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<WebAppLayout>
<h1 class="mb-4 text-xl font-bold text-text">رزروهای من</h1>
<div class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted">
به‌زودی
</div>
</WebAppLayout>
</template>
<script setup lang="ts">
import WebAppLayout from '@/layouts/WebAppLayout.vue';
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<WebAppLayout>
<section class="space-y-4">
<h1 class="text-xl font-bold text-text">کشف خدمات محلی</h1>
<p class="text-sm text-muted">
به‌زودی: دسته‌بندی‌ها، جستجو و لیست کسب‌وکارها
</p>
<div
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
فاز بعدی — صفحهٔ خانه
</div>
</section>
</WebAppLayout>
</template>
<script setup lang="ts">
import WebAppLayout from '@/layouts/WebAppLayout.vue';
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<WebAppLayout>
<h1 class="mb-4 text-xl font-bold text-text">مدیریت کسب‌وکار</h1>
<p class="mb-4 text-sm text-muted">خدمات، ویژگی‌ها و رزروهای ورودی</p>
<div class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted">
به‌زودی — پنل صاحب کسب‌وکار
</div>
</WebAppLayout>
</template>
<script setup lang="ts">
import WebAppLayout from '@/layouts/WebAppLayout.vue';
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<WebAppLayout>
<h1 class="mb-4 text-xl font-bold text-text">پروفایل</h1>
<dl v-if="auth.user" class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
<div>
<dt class="text-xs text-muted">موبایل</dt>
<dd class="font-medium" dir="ltr">{{ auth.user.mobile }}</dd>
</div>
<div>
<dt class="text-xs text-muted">نام</dt>
<dd class="font-medium">{{ auth.user.fullName ?? '—' }}</dd>
</div>
<div>
<dt class="text-xs text-muted">نقش</dt>
<dd class="font-medium">{{ roleLabel }}</dd>
</div>
</dl>
</WebAppLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const roleLabel = computed(() => {
switch (auth.user?.role) {
case 'admin':
return 'مدیر سیستم';
case 'business_owner':
return 'صاحب کسب‌وکار';
default:
return 'مشتری';
}
});
</script>
+70
View File
@@ -0,0 +1,70 @@
<template>
<AuthLayout :title="brandTitle" :subtitle="brandSubtitle">
<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>
<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
/>
<AppButton type="submit" :loading="loading">دریافت کد تأیید</AppButton>
</form>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
import { getAppMode } from '@/composables/useAppMode';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
const router = useRouter();
const route = useRoute();
const isAdmin = computed(() => getAppMode() === 'admin' || route.meta.adminAuth === true);
const brandTitle = computed(() => (isAdmin.value ? 'پنل مدیریت' : 'مون‌تایم'));
const brandSubtitle = computed(() =>
isAdmin.value ? 'ورود سوپر ادمین' : 'ورود با شماره موبایل',
);
const mobile = ref('');
const loading = ref(false);
const error = ref('');
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await requestOtp(mobile.value.trim());
await router.push({
name: isAdmin.value ? 'admin-verify' : 'verify',
query: {
mobile: mobile.value.trim(),
expires: String(res.expiresInSeconds),
...(res.otpCode ? { devCode: res.otpCode } : {}),
},
});
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'خطا در ارسال درخواست. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
</script>
+139
View File
@@ -0,0 +1,139 @@
<template>
<AuthLayout title="کد تأیید" :subtitle="`کد ارسال‌شده به ${mobile}`">
<p
v-if="devCode"
class="mb-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
>
حالت توسعه — کد: {{ devCode }}
</p>
<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>
<form class="space-y-4" @submit.prevent="onSubmit">
<AppInput
id="code"
v-model="code"
label="کد ۶ رقمی"
type="text"
inputmode="numeric"
maxlength="8"
placeholder="123456"
autocomplete="one-time-code"
ltr
required
/>
<AppButton type="submit" :loading="loading">ورود</AppButton>
</form>
<p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted">
ارسال مجدد تا {{ secondsLeft }} ثانیه
</p>
<AppButton
v-else
variant="ghost"
class="mt-2"
:loading="resendLoading"
@click="resend"
>
ارسال مجدد کد
</AppButton>
<AppButton
variant="ghost"
class="mt-2"
@click="router.push({ name: isAdmin ? 'admin-login' : 'login' })"
>
تغییر شماره موبایل
</AppButton>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { requestOtp, verifyOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
import { getAppMode } from '@/composables/useAppMode';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => getAppMode() === 'admin' || route.meta.adminAuth === true);
const mobile = computed(() => String(route.query.mobile ?? ''));
const devCode = computed(() => route.query.devCode as string | undefined);
const code = ref(devCode.value ?? '');
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(() => {
if (!mobile.value) {
router.replace({ name: isAdmin.value ? 'admin-login' : 'login' });
return;
}
timer = setInterval(() => {
if (secondsLeft.value > 0) secondsLeft.value -= 1;
}, 1000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await verifyOtp(mobile.value, code.value.trim());
auth.setSession(res.accessToken, res.user);
if (isAdmin.value) {
if (!auth.isAdmin) {
auth.logout();
error.value = 'دسترسی ادمین ندارید.';
return;
}
await router.replace({ name: 'admin-home' });
return;
}
await router.replace({ name: 'app-home' });
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
async function resend() {
error.value = '';
resendLoading.value = true;
try {
const res = await requestOtp(mobile.value);
secondsLeft.value = res.expiresInSeconds;
if (res.otpCode) {
await router.replace({
query: { ...route.query, devCode: res.otpCode, expires: String(res.expiresInSeconds) },
});
}
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'ارسال مجدد ناموفق بود.';
} finally {
resendLoading.value = false;
}
}
</script>
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_MODE?: 'web' | 'admin';
}
interface ImportMeta {