diff --git a/index.html b/index.html index 5d72d04..edbfc46 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,13 @@ + منتایم + + + + - - M + + + diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..1f41dc5 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-32.png b/public/icons/icon-32.png new file mode 100644 index 0000000..bc79a08 Binary files /dev/null and b/public/icons/icon-32.png differ diff --git a/public/icons/icon-36.png b/public/icons/icon-36.png new file mode 100644 index 0000000..04734ba Binary files /dev/null and b/public/icons/icon-36.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..9a397dd Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..d0a7616 --- /dev/null +++ b/public/site.webmanifest @@ -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" + } + ] +} diff --git a/src/api/client.ts b/src/api/client.ts index 64ad0fb..e1bb712 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,3 +1,5 @@ +import { logApiServerTiming } from './server-timing'; + const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; export class ApiError extends Error { @@ -36,6 +38,8 @@ export async function api(path: string, options: ApiOptions = {}): Promise headers, }); + logApiServerTiming(path, res); + if (!res.ok) { const body = (await res.json().catch(() => null)) as | { message?: string | string[] } diff --git a/src/api/metrics.ts b/src/api/metrics.ts new file mode 100644 index 0000000..ef3daff --- /dev/null +++ b/src/api/metrics.ts @@ -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; + } + }; +} diff --git a/src/api/server-timing.ts b/src/api/server-timing.ts new file mode 100644 index 0000000..a849609 --- /dev/null +++ b/src/api/server-timing.ts @@ -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}`); +} diff --git a/src/components/auth/AuthModeSwitch.vue b/src/components/auth/AuthModeSwitch.vue index 714f8aa..d5441fb 100644 --- a/src/components/auth/AuthModeSwitch.vue +++ b/src/components/auth/AuthModeSwitch.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/brand/AppLogo.vue b/src/components/brand/AppLogo.vue new file mode 100644 index 0000000..4f41b8d --- /dev/null +++ b/src/components/brand/AppLogo.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/AppInput.vue b/src/components/ui/AppInput.vue index d2c71f6..809bc24 100644 --- a/src/components/ui/AppInput.vue +++ b/src/components/ui/AppInput.vue @@ -6,7 +6,7 @@ diff --git a/src/layouts/AuthLayout.vue b/src/layouts/AuthLayout.vue index c291864..1bcae4e 100644 --- a/src/layouts/AuthLayout.vue +++ b/src/layouts/AuthLayout.vue @@ -2,8 +2,14 @@
-

{{ title }}

-

{{ subtitle }}

+
+ +

{{ subtitle }}

+
+
@@ -14,11 +20,13 @@ diff --git a/src/layouts/WebAppLayout.vue b/src/layouts/WebAppLayout.vue index b7d5c87..ae02fc4 100644 --- a/src/layouts/WebAppLayout.vue +++ b/src/layouts/WebAppLayout.vue @@ -3,8 +3,8 @@
-
-

منتایم

+
+

{{ auth.user.mobile }}

@@ -54,6 +54,7 @@ import { computed } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import AppShell from '@/components/shell/AppShell.vue'; import AppIcon from '@/components/ui/AppIcon.vue'; +import AppLogo from '@/components/brand/AppLogo.vue'; import ThemeToggle from '@/components/ui/ThemeToggle.vue'; import type { NavItem } from '@/types/icons'; import { useAuthStore } from '@/stores/auth'; diff --git a/src/router/index.ts b/src/router/index.ts index 71edce4..66c6e91 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -60,7 +60,7 @@ const router = createRouter({ router.beforeEach(async (to) => { const auth = useAuthStore(); - if (auth.token) { + if (auth.token && !auth.user) { await auth.loadUser(); } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 32891b3..a39a509 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -26,10 +26,13 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem(TOKEN_KEY); } - async function loadUser() { + async function loadUser(force = false) { if (!token.value) { return; } + if (user.value && !force) { + return; + } try { user.value = await fetchMe(token.value); } catch { diff --git a/src/styles/main.css b/src/styles/main.css index bbaa64b..9fcad6f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -9,6 +9,7 @@ --color-border: #e2e8f0; --color-text: #0f172a; --color-muted: #64748b; + --color-placeholder: rgb(100 116 139 / 0.35); } .dark { @@ -18,6 +19,7 @@ --color-border: #334155; --color-text: #f1f5f9; --color-muted: #94a3b8; + --color-placeholder: rgb(148 163 184 / 0.35); } @layer base { diff --git a/src/views/app/AppHomeView.vue b/src/views/app/AppHomeView.vue index fea1784..2662765 100644 --- a/src/views/app/AppHomeView.vue +++ b/src/views/app/AppHomeView.vue @@ -130,8 +130,7 @@ watch(selectedCategoryId, () => { loadBusinesses(); }); -onMounted(async () => { - await loadCategories(); - await loadBusinesses(); +onMounted(() => { + void Promise.all([loadCategories(), loadBusinesses()]); }); diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index c5f5661..750f0b9 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -1,46 +1,60 @@ @@ -54,6 +68,7 @@ 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 AppLogo from '@/components/brand/AppLogo.vue'; import AuthLayout from '@/layouts/AuthLayout.vue'; import { useAuthStore } from '@/stores/auth'; diff --git a/src/views/auth/VerifyOtpView.vue b/src/views/auth/VerifyOtpView.vue index 1b18772..8d441d6 100644 --- a/src/views/auth/VerifyOtpView.vue +++ b/src/views/auth/VerifyOtpView.vue @@ -14,19 +14,14 @@

- - ورود +

در حال تأیید...

@@ -43,12 +38,12 @@