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
-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>