Add password auth, business browsing, dev proxy, and production env.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 13:14:52 +03:30
co-authored by Cursor
parent 3df62f9c6e
commit e07527c1db
23 changed files with 736 additions and 37 deletions
+3 -1
View File
@@ -1 +1,3 @@
VITE_API_URL=http://localhost:3000
# Dev: /api (Vite proxy) | Production build: .env.production
# Full local API: http://localhost:3000
VITE_API_URL=/api
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=https://api.montime.ir
VITE_APP_MODE=web
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>مون‌تایم</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0d9488"/>
<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>

After

Width:  |  Height:  |  Size: 268 B

+25 -2
View File
@@ -1,3 +1,4 @@
import { normalizeIranMobile } from '@/utils/mobile';
import { api } from './client';
export type User = {
@@ -6,6 +7,7 @@ export type User = {
fullName: string | null;
role: string;
isActive: boolean;
hasPassword?: boolean;
};
export type RequestOtpResponse = {
@@ -21,17 +23,38 @@ export type VerifyOtpResponse = {
export function requestOtp(mobile: string) {
return api<RequestOtpResponse>('/auth/request-otp', {
method: 'POST',
body: JSON.stringify({ mobile }),
body: JSON.stringify({ mobile: normalizeIranMobile(mobile) }),
});
}
export function verifyOtp(mobile: string, code: string) {
return api<VerifyOtpResponse>('/auth/verify-otp', {
method: 'POST',
body: JSON.stringify({ mobile, code }),
body: JSON.stringify({ mobile: normalizeIranMobile(mobile), code }),
});
}
export function fetchMe(token: string) {
return api<User>('/auth/me', { token });
}
export function loginWithPassword(mobile: string, password: string) {
return api<VerifyOtpResponse>('/auth/login-password', {
method: 'POST',
body: JSON.stringify({
mobile: normalizeIranMobile(mobile),
password,
}),
});
}
export function setPassword(payload: {
password: string;
confirmPassword: string;
currentPassword?: string;
}) {
return api<User>('/auth/set-password', {
method: 'POST',
body: JSON.stringify(payload),
});
}
+6
View File
@@ -0,0 +1,6 @@
import { api } from './client';
import type { BusinessCategory } from './types';
export function fetchBusinessCategories() {
return api<BusinessCategory[]>('/business-categories');
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from './client';
import type { BusinessService } from './types';
export function fetchBusinessServices(businessId: string) {
const search = new URLSearchParams({
businessId,
isActive: 'true',
});
return api<BusinessService[]>(`/business-services?${search}`);
}
+21
View File
@@ -0,0 +1,21 @@
import { api } from './client';
import type { Business } from './types';
export function fetchBusinesses(params?: {
categoryId?: string;
isActive?: boolean;
}) {
const search = new URLSearchParams();
if (params?.categoryId) {
search.set('categoryId', params.categoryId);
}
if (params?.isActive !== undefined) {
search.set('isActive', String(params.isActive));
}
const query = search.toString();
return api<Business[]>(`/businesses${query ? `?${query}` : ''}`);
}
export function fetchBusiness(id: string) {
return api<Business>(`/businesses/${id}`);
}
+46
View File
@@ -0,0 +1,46 @@
export type BusinessCategory = {
id: string;
slug: string;
title: string;
isActive: boolean;
};
export type BusinessImage = {
id: string;
url: string;
isCover: boolean;
sortOrder: number;
};
export type Business = {
id: string;
title: string;
slug: string;
description: string | null;
contactPhone: string | null;
address: string | null;
latitude: number | null;
longitude: number | null;
isActive: boolean;
categoryId: string;
category?: BusinessCategory;
images?: BusinessImage[];
};
export type ServiceTemplate = {
id: string;
title: string;
slug: string;
description: string | null;
defaultDurationMinutes: number;
};
export type BusinessService = {
id: string;
businessId: string;
serviceTemplateId: string;
price: string;
durationMinutes: number | null;
isActive: boolean;
serviceTemplate?: ServiceTemplate;
};
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="mb-4 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"
:class="modelValue === 'otp' ? 'bg-surface text-text shadow-sm' : 'text-muted'"
@click="emit('update:modelValue', 'otp')"
>
کد یکبارمصرف
</button>
<button
type="button"
class="flex-1 rounded-lg py-2 text-sm font-medium transition"
:class="modelValue === 'password' ? 'bg-surface text-text shadow-sm' : 'text-muted'"
@click="emit('update:modelValue', 'password')"
>
رمز عبور
</button>
</div>
</template>
<script setup lang="ts">
export type AuthLoginMode = 'otp' | 'password';
defineProps<{ modelValue: AuthLoginMode }>();
const emit = defineEmits<{ 'update:modelValue': [value: AuthLoginMode] }>();
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<RouterLink
:to="{ name: 'app-business-detail', params: { id: business.id } }"
class="block overflow-hidden rounded-2xl border border-border bg-surface-2 transition hover:border-brand-500/40 hover:shadow-md"
>
<div class="relative aspect-[16/10] bg-surface-3">
<img
v-if="cover"
:src="cover"
:alt="business.title"
class="h-full w-full object-cover"
loading="lazy"
/>
<div v-else class="flex h-full items-center justify-center text-3xl text-muted/40">🏪</div>
</div>
<div class="p-4">
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold text-text">{{ business.title }}</h2>
<span
v-if="business.category"
class="shrink-0 rounded-lg bg-brand-500/10 px-2 py-0.5 text-xs text-brand-700 dark:text-brand-500"
>
{{ business.category.title }}
</span>
</div>
<p v-if="business.description" class="mt-2 line-clamp-2 text-sm text-muted">
{{ business.description }}
</p>
<p v-if="business.address" class="mt-2 text-xs text-muted">
📍 {{ business.address }}
</p>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Business } from '@/api/types';
import { businessCoverUrl } from '@/utils/media';
const props = defineProps<{
business: Business;
}>();
const cover = computed(() => businessCoverUrl(props.business.images));
</script>
@@ -0,0 +1,43 @@
<template>
<div class="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
<button
type="button"
class="shrink-0 rounded-full px-4 py-2 text-sm font-medium transition"
:class="
!selectedId
? 'bg-brand-600 text-white'
: 'bg-surface-3 text-muted hover:text-text'
"
@click="$emit('select', null)"
>
همه
</button>
<button
v-for="cat in categories"
:key="cat.id"
type="button"
class="shrink-0 rounded-full px-4 py-2 text-sm font-medium transition"
:class="
selectedId === cat.id
? 'bg-brand-600 text-white'
: 'bg-surface-3 text-muted hover:text-text'
"
@click="$emit('select', cat.id)"
>
{{ cat.title }}
</button>
</div>
</template>
<script setup lang="ts">
import type { BusinessCategory } from '@/api/types';
defineProps<{
categories: BusinessCategory[];
selectedId: string | null;
}>();
defineEmits<{
select: [categoryId: string | null];
}>();
</script>
+3
View File
@@ -5,6 +5,7 @@
</label>
<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="ltr ? 'text-left' : ''"
v-bind="$attrs"
@@ -15,6 +16,8 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false });
const model = defineModel<string>({ default: '' });
withDefaults(
defineProps<{
label?: string;
+6
View File
@@ -23,6 +23,12 @@ const router = createRouter({
component: () => import('@/views/app/AppHomeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/businesses/:id',
name: 'app-business-detail',
component: () => import('@/views/app/AppBusinessDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/bookings',
name: 'app-bookings',
+5
View File
@@ -37,6 +37,10 @@ export const useAuthStore = defineStore('auth', () => {
}
}
function setUser(profile: User) {
user.value = profile;
}
return {
token,
user,
@@ -47,5 +51,6 @@ export const useAuthStore = defineStore('auth', () => {
setSession,
logout,
loadUser,
setUser,
};
});
+21
View File
@@ -0,0 +1,21 @@
/** Formats API numeric price string (e.g. "150000.00") for display. */
export function formatPriceToman(price: string): string {
const value = Number.parseFloat(price);
if (!Number.isFinite(value)) {
return price;
}
return new Intl.NumberFormat('fa-IR').format(Math.round(value));
}
export function formatDuration(minutes: number | null, fallback?: number): string {
const m = minutes ?? fallback;
if (!m) {
return '—';
}
if (m < 60) {
return `${m} دقیقه`;
}
const h = Math.floor(m / 60);
const rest = m % 60;
return rest ? `${h} ساعت و ${rest} دقیقه` : `${h} ساعت`;
}
+16
View File
@@ -0,0 +1,16 @@
const assetsBase = (import.meta.env.VITE_ASSETS_URL ?? '').replace(/\/$/, '');
export function uploadUrl(path: string | null | undefined): string {
if (!path) return '';
if (path.startsWith('http')) return path;
const rel = path.startsWith('/uploads/') ? path : `/uploads/${path}`;
return assetsBase ? `${assetsBase}${rel}` : rel;
}
export function businessCoverUrl(
images?: { url: string; isCover?: boolean }[] | null,
): string | null {
if (!images?.length) return null;
const cover = images.find((i) => i.isCover) ?? images[0];
return uploadUrl(cover?.url) || null;
}
+37
View File
@@ -0,0 +1,37 @@
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
const INVISIBLE_CHARS = /[\u200c\u200d\u202a-\u202e\ufeff]/g;
function toAsciiDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const persian = PERSIAN_DIGITS.indexOf(ch);
if (persian >= 0) return String(persian);
const arabic = ARABIC_DIGITS.indexOf(ch);
if (arabic >= 0) return String(arabic);
return ch;
});
}
/** Normalize Iranian mobile to 09xxxxxxxxx (11 digits). */
export function normalizeIranMobile(raw: string): string {
let mobile = toAsciiDigits(String(raw ?? '').trim());
mobile = mobile.replace(INVISIBLE_CHARS, '');
let digits = mobile.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0098')) {
digits = digits.slice(2);
}
if (digits.startsWith('98') && digits.length >= 12) {
digits = `0${digits.slice(2)}`;
} else if (digits.startsWith('9') && digits.length === 10) {
digits = `0${digits}`;
}
return digits;
}
export function isValidIranMobile(mobile: string): boolean {
return /^09\d{9}$/.test(mobile);
}
+129
View File
@@ -0,0 +1,129 @@
<template>
<WebAppLayout>
<div v-if="loading" class="py-12 text-center text-sm text-muted">در حال بارگذاری…</div>
<p
v-else-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>
<section v-else-if="business" class="space-y-6">
<button
type="button"
class="text-sm text-brand-600 dark:text-brand-500"
@click="router.back()"
>
← بازگشت
</button>
<div>
<span
v-if="business.category"
class="inline-block rounded-lg bg-brand-500/10 px-2 py-0.5 text-xs text-brand-700 dark:text-brand-500"
>
{{ business.category.title }}
</span>
<h1 class="mt-2 text-xl font-bold text-text">{{ business.title }}</h1>
<p v-if="business.description" class="mt-2 text-sm text-muted">
{{ business.description }}
</p>
</div>
<dl class="space-y-2 rounded-2xl border border-border bg-surface-2 p-4 text-sm">
<div v-if="business.contactPhone">
<dt class="text-muted">تلفن</dt>
<dd class="font-medium" dir="ltr">{{ business.contactPhone }}</dd>
</div>
<div v-if="business.address">
<dt class="text-muted">آدرس</dt>
<dd>{{ business.address }}</dd>
</div>
</dl>
<div>
<h2 class="mb-3 font-semibold text-text">خدمات</h2>
<div v-if="loadingServices" class="text-sm text-muted">در حال بارگذاری…</div>
<div
v-else-if="!services.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
خدمتی ثبت نشده است.
</div>
<ul v-else class="space-y-3">
<li
v-for="svc in services"
:key="svc.id"
class="rounded-2xl border border-border bg-surface-2 p-4"
>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 text-sm text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-2 flex flex-wrap gap-3 text-sm text-muted">
<span>{{ formatPriceToman(svc.price) }} تومان</span>
<span>·</span>
<span>{{
formatDuration(
svc.durationMinutes,
svc.serviceTemplate?.defaultDurationMinutes,
)
}}</span>
</div>
<AppButton class="mt-3" variant="primary" @click="onBook(svc)">
رزرو (به‌زودی)
</AppButton>
</li>
</ul>
</div>
</section>
</WebAppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { fetchBusinessServices } from '@/api/business-services';
import { fetchBusiness } from '@/api/businesses';
import { ApiError } from '@/api/client';
import type { Business, BusinessService } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { formatDuration, formatPriceToman } from '@/utils/format';
const route = useRoute();
const router = useRouter();
const business = ref<Business | null>(null);
const services = ref<BusinessService[]>([]);
const loading = ref(true);
const loadingServices = ref(true);
const error = ref('');
async function load() {
const id = String(route.params.id);
loading.value = true;
loadingServices.value = true;
error.value = '';
try {
business.value = await fetchBusiness(id);
services.value = await fetchBusinessServices(id);
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری اطلاعات ناموفق بود.';
} finally {
loading.value = false;
loadingServices.value = false;
}
}
function onBook(_svc: BusinessService) {
// فاز بعدی: فرم رزرو
}
onMounted(load);
</script>
+118 -6
View File
@@ -1,19 +1,131 @@
<template>
<WebAppLayout>
<section class="space-y-4">
<h1 class="text-xl font-bold text-text">کشف خدمات محلی</h1>
<p class="text-sm text-muted">
به‌زودی: دسته‌بندی‌ها، جستجو و لیست کسب‌وکارها
<section class="space-y-5">
<div>
<h1 class="text-xl font-bold text-text">کشف خدمات</h1>
<p class="mt-1 text-sm text-muted">کسب‌وکارهای محلی نزدیک شما</p>
</div>
<div>
<label class="sr-only" for="search">جستجو</label>
<input
id="search"
v-model="searchQuery"
type="search"
placeholder="جستجو در نام یا آدرس…"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
/>
</div>
<div v-if="loadingCategories" class="text-center text-sm text-muted">
در حال بارگذاری دسته‌ها…
</div>
<CategoryFilter
v-else-if="categories.length"
:categories="activeCategories"
:selected-id="selectedCategoryId"
@select="onCategorySelect"
/>
<div v-if="loadingBusinesses" class="py-8 text-center text-sm text-muted">
در حال بارگذاری…
</div>
<p
v-else-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>
<div
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
v-else-if="!filteredBusinesses.length"
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
>
فاز بعدی — صفحهٔ خانه
کسب‌وکاری یافت نشد.
</div>
<ul v-else class="space-y-3">
<li v-for="b in filteredBusinesses" :key="b.id">
<BusinessCard :business="b" />
</li>
</ul>
</section>
</WebAppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchBusinessCategories } from '@/api/business-categories';
import { fetchBusinesses } from '@/api/businesses';
import { ApiError } from '@/api/client';
import type { Business, BusinessCategory } from '@/api/types';
import BusinessCard from '@/components/business/BusinessCard.vue';
import CategoryFilter from '@/components/business/CategoryFilter.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
const categories = ref<BusinessCategory[]>([]);
const businesses = ref<Business[]>([]);
const selectedCategoryId = ref<string | null>(null);
const searchQuery = ref('');
const loadingCategories = ref(true);
const loadingBusinesses = ref(true);
const error = ref('');
const activeCategories = computed(() =>
categories.value.filter((c) => c.isActive),
);
const filteredBusinesses = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) {
return businesses.value;
}
return businesses.value.filter((b) => {
const haystack = [b.title, b.description, b.address]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
});
async function loadCategories() {
loadingCategories.value = true;
try {
const all = await fetchBusinessCategories();
categories.value = all;
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری دسته‌ها ناموفق بود.';
} finally {
loadingCategories.value = false;
}
}
async function loadBusinesses() {
loadingBusinesses.value = true;
error.value = '';
try {
businesses.value = await fetchBusinesses({
categoryId: selectedCategoryId.value ?? undefined,
isActive: true,
});
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری کسب‌وکارها ناموفق بود.';
} finally {
loadingBusinesses.value = false;
}
}
function onCategorySelect(id: string | null) {
selectedCategoryId.value = id;
}
watch(selectedCategoryId, () => {
loadBusinesses();
});
onMounted(async () => {
await loadCategories();
await loadBusinesses();
});
</script>
+99 -15
View File
@@ -1,29 +1,80 @@
<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>
<p
v-if="message"
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"
>
{{ message }}
</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"
>
{{ error }}
</p>
<div v-if="auth.user" class="space-y-4">
<dl 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">{{ roleLabel }}</dd>
</div>
</dl>
<section class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
<h2 class="text-sm font-semibold text-text">
{{ auth.user.hasPassword ? 'تغییر رمز عبور' : 'تنظیم رمز عبور' }}
</h2>
<p class="text-xs text-muted">
پس از تنظیم رمز، ورود با رمز عبور یا کد یکبارمصرف هر دو ممکن است.
</p>
<AppInput
v-if="auth.user.hasPassword"
id="currentPassword"
v-model="currentPassword"
label="رمز فعلی"
type="password"
ltr
/>
<AppInput id="newPassword" v-model="newPassword" label="رمز جدید" type="password" ltr />
<AppInput
id="confirmPassword"
v-model="confirmPassword"
label="تکرار رمز جدید"
type="password"
ltr
/>
<AppButton :loading="saving" @click="onSavePassword">
{{ auth.user.hasPassword ? 'به‌روزرسانی رمز' : 'ذخیره رمز' }}
</AppButton>
</section>
</div>
</WebAppLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { setPassword } from '@/api/auth';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const saving = ref(false);
const error = ref('');
const message = ref('');
const roleLabel = computed(() => {
switch (auth.user?.role) {
@@ -35,4 +86,37 @@ const roleLabel = computed(() => {
return 'مشتری';
}
});
async function onSavePassword() {
if (!auth.user) return;
error.value = '';
message.value = '';
if (newPassword.value.length < 8) {
error.value = 'رمز باید حداقل ۸ کاراکتر باشد.';
return;
}
if (newPassword.value !== confirmPassword.value) {
error.value = 'تکرار رمز با رمز جدید یکسان نیست.';
return;
}
saving.value = true;
try {
const updated = await setPassword({
password: newPassword.value,
confirmPassword: confirmPassword.value,
...(auth.user.hasPassword ? { currentPassword: currentPassword.value } : {}),
});
auth.setUser(updated);
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
message.value = 'رمز عبور ذخیره شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره رمز';
} finally {
saving.value = false;
}
}
</script>
+53 -13
View File
@@ -1,5 +1,5 @@
<template>
<AuthLayout title="مون‌تایم" subtitle="ورود با شماره موبایل">
<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"
@@ -7,6 +7,8 @@
{{ error }}
</p>
<AuthModeSwitch v-model="mode" />
<form class="mt-auto space-y-4" @submit.prevent="onSubmit">
<AppInput
id="mobile"
@@ -19,22 +21,47 @@
ltr
required
/>
<AppButton type="submit" :loading="loading">دریافت کد تأیید</AppButton>
<AppInput
v-if="mode === 'password'"
id="password"
v-model="password"
label="رمز عبور"
type="password"
autocomplete="current-password"
ltr
required
/>
<AppButton type="submit" :loading="loading">
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
</AppButton>
</form>
<p v-if="mode === 'password'" class="mt-3 text-center text-xs text-muted">
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
</p>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth';
import { loginWithPassword, requestOtp } from '@/api/auth';
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
import { ApiError } from '@/api/client';
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 AuthLayout from '@/layouts/AuthLayout.vue';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const auth = useAuthStore();
const mode = ref<AuthLoginMode>('otp');
const mobile = ref('');
const password = ref('');
const loading = ref(false);
const error = ref('');
@@ -42,18 +69,31 @@ 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 } : {}),
},
});
const normalized = normalizeIranMobile(mobile.value);
if (!isValidIranMobile(normalized)) {
error.value = 'شماره موبایل معتبر نیست. مثال: 09121234567';
return;
}
if (mode.value === 'otp') {
const res = await requestOtp(normalized);
await router.push({
name: 'verify',
query: {
mobile: normalized,
expires: String(res.expiresInSeconds),
...(res.otpCode ? { devCode: res.otpCode } : {}),
},
});
return;
}
const res = await loginWithPassword(normalized, password.value);
auth.setSession(res.accessToken, res.user);
await router.replace({ name: 'app-home' });
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'خطا در ارسال درخواست. دوباره تلاش کنید.';
e instanceof ApiError ? e.message : 'خطا در ورود. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
+15
View File
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
const API_TARGET = 'https://api.montime.ir';
export default defineConfig({
plugins: [vue()],
resolve: {
@@ -11,5 +13,18 @@ export default defineConfig({
},
server: {
port: 5173,
proxy: {
'/api': {
target: API_TARGET,
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/uploads': {
target: API_TARGET,
changeOrigin: true,
secure: true,
},
},
},
});