From e07527c1db7ca60bba66973c51e3bb497f11139d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 23 May 2026 13:14:52 +0330 Subject: [PATCH] Add password auth, business browsing, dev proxy, and production env. Co-authored-by: Cursor --- .env.example | 4 +- .env.production | 2 + index.html | 1 + public/favicon.svg | 4 + src/api/auth.ts | 27 ++++- src/api/business-categories.ts | 6 + src/api/business-services.ts | 10 ++ src/api/businesses.ts | 21 ++++ src/api/types.ts | 46 ++++++++ src/components/auth/AuthModeSwitch.vue | 27 +++++ src/components/business/BusinessCard.vue | 46 ++++++++ src/components/business/CategoryFilter.vue | 43 +++++++ src/components/ui/AppInput.vue | 3 + src/router/index.ts | 6 + src/stores/auth.ts | 5 + src/utils/format.ts | 21 ++++ src/utils/media.ts | 16 +++ src/utils/mobile.ts | 37 ++++++ src/views/app/AppBusinessDetailView.vue | 129 +++++++++++++++++++++ src/views/app/AppHomeView.vue | 124 +++++++++++++++++++- src/views/app/AppProfileView.vue | 114 +++++++++++++++--- src/views/auth/LoginView.vue | 66 ++++++++--- vite.config.ts | 15 +++ 23 files changed, 736 insertions(+), 37 deletions(-) create mode 100644 .env.production create mode 100644 public/favicon.svg create mode 100644 src/api/business-categories.ts create mode 100644 src/api/business-services.ts create mode 100644 src/api/businesses.ts create mode 100644 src/api/types.ts create mode 100644 src/components/auth/AuthModeSwitch.vue create mode 100644 src/components/business/BusinessCard.vue create mode 100644 src/components/business/CategoryFilter.vue create mode 100644 src/utils/format.ts create mode 100644 src/utils/media.ts create mode 100644 src/utils/mobile.ts create mode 100644 src/views/app/AppBusinessDetailView.vue diff --git a/.env.example b/.env.example index 5317fce..c51665f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..89903f1 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_API_URL=https://api.montime.ir +VITE_APP_MODE=web diff --git a/index.html b/index.html index 8516d40..b1d11ad 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ مون‌تایم + + + M + diff --git a/src/api/auth.ts b/src/api/auth.ts index 3df4d09..7c091d3 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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('/auth/request-otp', { method: 'POST', - body: JSON.stringify({ mobile }), + body: JSON.stringify({ mobile: normalizeIranMobile(mobile) }), }); } export function verifyOtp(mobile: string, code: string) { return api('/auth/verify-otp', { method: 'POST', - body: JSON.stringify({ mobile, code }), + body: JSON.stringify({ mobile: normalizeIranMobile(mobile), code }), }); } export function fetchMe(token: string) { return api('/auth/me', { token }); } + +export function loginWithPassword(mobile: string, password: string) { + return api('/auth/login-password', { + method: 'POST', + body: JSON.stringify({ + mobile: normalizeIranMobile(mobile), + password, + }), + }); +} + +export function setPassword(payload: { + password: string; + confirmPassword: string; + currentPassword?: string; +}) { + return api('/auth/set-password', { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/src/api/business-categories.ts b/src/api/business-categories.ts new file mode 100644 index 0000000..b09299b --- /dev/null +++ b/src/api/business-categories.ts @@ -0,0 +1,6 @@ +import { api } from './client'; +import type { BusinessCategory } from './types'; + +export function fetchBusinessCategories() { + return api('/business-categories'); +} diff --git a/src/api/business-services.ts b/src/api/business-services.ts new file mode 100644 index 0000000..a614f13 --- /dev/null +++ b/src/api/business-services.ts @@ -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(`/business-services?${search}`); +} diff --git a/src/api/businesses.ts b/src/api/businesses.ts new file mode 100644 index 0000000..d19fcf5 --- /dev/null +++ b/src/api/businesses.ts @@ -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(`/businesses${query ? `?${query}` : ''}`); +} + +export function fetchBusiness(id: string) { + return api(`/businesses/${id}`); +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..8d4c0b5 --- /dev/null +++ b/src/api/types.ts @@ -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; +}; diff --git a/src/components/auth/AuthModeSwitch.vue b/src/components/auth/AuthModeSwitch.vue new file mode 100644 index 0000000..714f8aa --- /dev/null +++ b/src/components/auth/AuthModeSwitch.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/business/BusinessCard.vue b/src/components/business/BusinessCard.vue new file mode 100644 index 0000000..88e2872 --- /dev/null +++ b/src/components/business/BusinessCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/business/CategoryFilter.vue b/src/components/business/CategoryFilter.vue new file mode 100644 index 0000000..47ba45e --- /dev/null +++ b/src/components/business/CategoryFilter.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/ui/AppInput.vue b/src/components/ui/AppInput.vue index 2e0074b..d2c71f6 100644 --- a/src/components/ui/AppInput.vue +++ b/src/components/ui/AppInput.vue @@ -5,6 +5,7 @@ defineOptions({ inheritAttrs: false }); +const model = defineModel({ default: '' }); + withDefaults( defineProps<{ label?: string; diff --git a/src/router/index.ts b/src/router/index.ts index bcd40a4..72720a1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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', diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 75b5cab..32891b3 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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, }; }); diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..fc89a8e --- /dev/null +++ b/src/utils/format.ts @@ -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} ساعت`; +} diff --git a/src/utils/media.ts b/src/utils/media.ts new file mode 100644 index 0000000..cd73769 --- /dev/null +++ b/src/utils/media.ts @@ -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; +} diff --git a/src/utils/mobile.ts b/src/utils/mobile.ts new file mode 100644 index 0000000..a4fc20a --- /dev/null +++ b/src/utils/mobile.ts @@ -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); +} diff --git a/src/views/app/AppBusinessDetailView.vue b/src/views/app/AppBusinessDetailView.vue new file mode 100644 index 0000000..e3144d6 --- /dev/null +++ b/src/views/app/AppBusinessDetailView.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/views/app/AppHomeView.vue b/src/views/app/AppHomeView.vue index 90d9f44..5fe882a 100644 --- a/src/views/app/AppHomeView.vue +++ b/src/views/app/AppHomeView.vue @@ -1,19 +1,131 @@ diff --git a/src/views/app/AppProfileView.vue b/src/views/app/AppProfileView.vue index 15a3686..d71e424 100644 --- a/src/views/app/AppProfileView.vue +++ b/src/views/app/AppProfileView.vue @@ -1,29 +1,80 @@ diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index 8bf3c01..6f2e1ac 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -1,5 +1,5 @@