feat: initial panel (Vue + Tailwind)

This commit is contained in:
2026-05-18 21:30:37 +03:30
parent 3f9d91d4bf
commit 3df62f9c6e
30 changed files with 115 additions and 279 deletions
-2
View File
@@ -1,2 +0,0 @@
VITE_API_URL=https://api.montime.ir
VITE_APP_MODE=admin
-2
View File
@@ -1,3 +1 @@
VITE_API_URL=http://localhost:3000 VITE_API_URL=http://localhost:3000
# web = montime.ir | admin = adm.montime.ir (also auto-detected from hostname)
VITE_APP_MODE=web
-1
View File
@@ -1,6 +1,5 @@
node_modules node_modules
dist dist
dist-admin
.env .env
.env.local .env.local
*.log *.log
@@ -0,0 +1 @@
import{_ as r}from"./AdminLayout.vue_vue_type_script_setup_true_lang-X12EOjkr.js";import{i as s,b as o,O as a,a as t,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const x=s({__name:"AdminBusinessesView",setup(n){return(m,e)=>(d(),o(r,null,{default:a(()=>[...e[0]||(e[0]=[t("h1",{class:"text-xl font-bold text-text"},"کسب‌وکارها",-1),t("div",{class:"mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"}," به‌زودی — CRUD کسب‌وکار و واگذاری مدیر ",-1)])]),_:1}))}});export{x as default};
@@ -0,0 +1 @@
import{_ as s}from"./AdminLayout.vue_vue_type_script_setup_true_lang-X12EOjkr.js";import{i as r,b as o,O as a,a as t,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const i=r({__name:"AdminHomeView",setup(x){return(l,e)=>(d(),o(s,null,{default:a(()=>[...e[0]||(e[0]=[t("h1",{class:"text-xl font-bold text-text"},"داشبورد مدیریت",-1),t("p",{class:"mt-2 text-sm text-muted"},"فقط سوپر ادمین — adm.montime.ir",-1),t("div",{class:"mt-6 grid gap-3 sm:grid-cols-2"},[t("div",{class:"rounded-2xl border border-border bg-surface-2 p-4"},[t("p",{class:"text-xs text-muted"},"کسب‌وکارها"),t("p",{class:"mt-1 text-2xl font-bold text-text"},"—")]),t("div",{class:"rounded-2xl border border-border bg-surface-2 p-4"},[t("p",{class:"text-xs text-muted"},"کاربران"),t("p",{class:"mt-1 text-2xl font-bold text-text"},"—")])],-1)])]),_:1}))}});export{i as default};
@@ -0,0 +1 @@
import{i as b,I as x,b as _,O as o,a as e,g as a,y as f,e as g,x as h,F as y,K as k,J as v,B,u as n,q as C,H as L,D as r,f as A}from"./index--aiJRDg4.js";import{A as R,_ as S}from"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const N={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"},V={class:"flex items-center gap-2"},w={class:"flex-1 space-y-4 px-4 py-4"},z={class:"sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2"},D={class:"grid grid-cols-3 gap-1"},F={class:"text-lg"},E=b({__name:"AdminLayout",setup($){const l=v(),c=k(),i=x(),d=[{to:"/admin",name:"admin-home",label:"داشبورد",icon:"📊"},{to:"/admin/businesses",name:"admin-businesses",label:"کسب‌وکار",icon:"🏪"},{to:"/admin/users",name:"admin-users",label:"کاربران",icon:"👥"}];function u(){i.logout(),c.push({name:"admin-login"})}return(m,s)=>{const p=B("RouterLink");return n(),_(R,null,{default:o(()=>[e("header",N,[s[0]||(s[0]=e("div",null,[e("p",{class:"font-bold text-text"},"پنل مدیریت"),e("p",{class:"text-xs text-amber-600 dark:text-amber-400"},"adm.montime.ir")],-1)),e("div",V,[a(S),e("button",{type:"button",class:"rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3",onClick:u}," خروج ")])]),e("main",w,[f(m.$slots,"default")]),e("nav",z,[e("div",D,[(n(),g(y,null,h(d,t=>a(p,{key:t.to,to:t.to,class:C(["flex flex-col items-center rounded-xl py-2 text-xs",L(l).name===t.name?"bg-amber-500/15 text-amber-700 dark:text-amber-400":"text-muted hover:bg-surface-3"])},{default:o(()=>[e("span",F,r(t.icon),1),A(" "+r(t.label),1)]),_:2},1032,["to","class"])),64))])])]),_:3})}}});export{E as _};
@@ -0,0 +1 @@
import{_ as r}from"./AdminLayout.vue_vue_type_script_setup_true_lang-X12EOjkr.js";import{i as s,b as o,O as a,a as t,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const c=s({__name:"AdminUsersView",setup(n){return(m,e)=>(d(),o(r,null,{default:a(()=>[...e[0]||(e[0]=[t("h1",{class:"text-xl font-bold text-text"},"کاربران",-1),t("div",{class:"mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"}," به‌زودی ",-1)])]),_:1}))}});export{c as default};
@@ -0,0 +1 @@
import{_ as r}from"./WebAppLayout.vue_vue_type_script_setup_true_lang-D1g6DvZW.js";import{i as o,b as s,O as a,a as t,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const x=o({__name:"AppBookingsView",setup(n){return(m,e)=>(d(),s(r,null,{default:a(()=>[...e[0]||(e[0]=[t("h1",{class:"mb-4 text-xl font-bold text-text"},"رزروهای من",-1),t("div",{class:"rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"}," به‌زودی ",-1)])]),_:1}))}});export{x as default};
@@ -0,0 +1 @@
import{_ as s}from"./WebAppLayout.vue_vue_type_script_setup_true_lang-D1g6DvZW.js";import{i as r,b as o,O as a,a as e,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const x=r({__name:"AppHomeView",setup(n){return(c,t)=>(d(),o(s,null,{default:a(()=>[...t[0]||(t[0]=[e("section",{class:"space-y-4"},[e("h1",{class:"text-xl font-bold text-text"},"کشف خدمات محلی"),e("p",{class:"text-sm text-muted"}," به‌زودی: دسته‌بندی‌ها، جستجو و لیست کسب‌وکارها "),e("div",{class:"rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"}," فاز بعدی — صفحهٔ خانه ")],-1)])]),_:1}))}});export{x as default};
@@ -0,0 +1 @@
import{_ as r}from"./WebAppLayout.vue_vue_type_script_setup_true_lang-D1g6DvZW.js";import{i as s,b as o,O as a,a as e,u as d}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const c=s({__name:"AppOwnerView",setup(m){return(n,t)=>(d(),o(r,null,{default:a(()=>[...t[0]||(t[0]=[e("h1",{class:"mb-4 text-xl font-bold text-text"},"مدیریت کسب‌وکار",-1),e("p",{class:"mb-4 text-sm text-muted"},"خدمات، ویژگی‌ها و رزروهای ورودی",-1),e("div",{class:"rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"}," به‌زودی — پنل صاحب کسب‌وکار ",-1)])]),_:1}))}});export{c as default};
@@ -0,0 +1 @@
import{_ as n}from"./WebAppLayout.vue_vue_type_script_setup_true_lang-D1g6DvZW.js";import{i as u,I as i,b as m,O as c,a as t,H as r,e as x,D as d,d as f,c as p,u as l}from"./index--aiJRDg4.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const _={key:0,class:"space-y-3 rounded-2xl border border-border bg-surface-2 p-4"},b={class:"font-medium",dir:"ltr"},k={class:"font-medium"},v={class:"font-medium"},N=u({__name:"AppProfileView",setup(w){const s=i(),a=p(()=>{var o;switch((o=s.user)==null?void 0:o.role){case"admin":return"مدیر سیستم";case"business_owner":return"صاحب کسب‌وکار";default:return"مشتری"}});return(o,e)=>(l(),m(n,null,{default:c(()=>[e[3]||(e[3]=t("h1",{class:"mb-4 text-xl font-bold text-text"},"پروفایل",-1)),r(s).user?(l(),x("dl",_,[t("div",null,[e[0]||(e[0]=t("dt",{class:"text-xs text-muted"},"موبایل",-1)),t("dd",b,d(r(s).user.mobile),1)]),t("div",null,[e[1]||(e[1]=t("dt",{class:"text-xs text-muted"},"نام",-1)),t("dd",k,d(r(s).user.fullName??"—"),1)]),t("div",null,[e[2]||(e[2]=t("dt",{class:"text-xs text-muted"},"نقش",-1)),t("dd",v,d(a.value),1)])])):f("",!0)]),_:1}))}});export{N as default};
@@ -0,0 +1 @@
import{i as r,e as l,y as d,q as u,c as f,u as s,D as o,d as i,a,o as b,b as m,O as p,g as x}from"./index--aiJRDg4.js";import{A as h,_ as y}from"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const _=["type","disabled"],V=r({__name:"AppButton",props:{variant:{default:"primary"},type:{default:"button"},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(e){const t=e,n=f(()=>{switch(t.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"}});return(c,C)=>(s(),l("button",{type:e.type,class:u(["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",n.value]),disabled:e.disabled||e.loading},[d(c.$slots,"default")],10,_))}}),g={class:"space-y-1.5"},v=["for"],$=["id"],j=r({inheritAttrs:!1,__name:"AppInput",props:{label:{},id:{},ltr:{type:Boolean,default:!1}},setup(e){return(t,n)=>(s(),l("div",g,[e.label?(s(),l("label",{key:0,for:e.id,class:"block text-sm font-medium text-muted"},o(e.label),9,v)):i("",!0),a("input",b({id:e.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",e.ltr?"text-left":""]},t.$attrs),null,16,$)]))}}),B={class:"flex items-center justify-between px-4 py-3"},k={class:"text-lg font-bold text-text"},w={key:0,class:"text-sm text-muted"},A={class:"flex flex-1 flex-col px-4 pb-8"},D=r({__name:"AuthLayout",props:{title:{},subtitle:{}},setup(e){return(t,n)=>(s(),m(h,null,{default:p(()=>[a("header",B,[a("div",null,[a("p",k,o(e.title),1),e.subtitle?(s(),l("p",w,o(e.subtitle),1)):i("",!0)]),x(y)]),a("main",A,[d(t.$slots,"default")])]),_:3}))}});export{D as _,j as a,V as b};
+1
View File
@@ -0,0 +1 @@
import{i as _,b as y,c as l,O as u,K as x,J as g,u as n,e as V,D as w,d as C,a as S,P as k,g as d,f as A,w as i,z as h,A as B,j as N}from"./index--aiJRDg4.js";import{_ as q,a as $,b as D}from"./AuthLayout.vue_vue_type_script_setup_true_lang-Bbht7KM7.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const E={key:0,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"},j=_({__name:"LoginView",setup(M){const m=x(),c=g(),s=l(()=>N()==="admin"||c.meta.adminAuth===!0),p=l(()=>s.value?"پنل مدیریت":"مون‌تایم"),f=l(()=>s.value?"ورود سوپر ادمین":"ورود با شماره موبایل"),t=i(""),r=i(!1),a=i("");async function v(){a.value="",r.value=!0;try{const e=await h(t.value.trim());await m.push({name:s.value?"admin-verify":"verify",query:{mobile:t.value.trim(),expires:String(e.expiresInSeconds),...e.otpCode?{devCode:e.otpCode}:{}}})}catch(e){a.value=e instanceof B?e.message:"خطا در ارسال درخواست. دوباره تلاش کنید."}finally{r.value=!1}}return(e,o)=>(n(),y(q,{title:p.value,subtitle:f.value},{default:u(()=>[a.value?(n(),V("p",E,w(a.value),1)):C("",!0),S("form",{class:"mt-auto space-y-4",onSubmit:k(v,["prevent"])},[d($,{id:"mobile",modelValue:t.value,"onUpdate:modelValue":o[0]||(o[0]=b=>t.value=b),label:"شماره موبایل",type:"tel",inputmode:"numeric",placeholder:"09121234567",autocomplete:"tel",ltr:"",required:""},null,8,["modelValue"]),d(D,{type:"submit",loading:r.value},{default:u(()=>[...o[1]||(o[1]=[A("دریافت کد تأیید",-1)])]),_:1},8,["loading"])],32)]),_:1},8,["title","subtitle"]))}});export{j as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{i as I,I as N,w as r,r as $,t as B,b as S,O as i,J as O,K as L,c as g,e as x,D as y,d as V,a as M,g as _,f as k,P as D,H as E,z as R,A as h,u as l,L as T,j as U}from"./index--aiJRDg4.js";import{_ as j,a as z,b as C}from"./AuthLayout.vue_vue_type_script_setup_true_lang-Bbht7KM7.js";import"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const H={key:0,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"},J={key:1,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"},K={key:2,class:"mt-4 text-center text-sm text-muted"},W=I({__name:"VerifyOtpView",setup(P){const s=O(),o=L(),d=N(),m=g(()=>U()==="admin"||s.meta.adminAuth===!0),u=g(()=>String(s.query.mobile??"")),c=g(()=>s.query.devCode),p=r(c.value??""),v=r(!1),f=r(!1),a=r(""),n=r(Number(s.query.expires)||120);let b=null;$(()=>{if(!u.value){o.replace({name:m.value?"admin-login":"login"});return}b=setInterval(()=>{n.value>0&&(n.value-=1)},1e3)}),B(()=>{b&&clearInterval(b)});async function q(){a.value="",v.value=!0;try{const e=await T(u.value,p.value.trim());if(d.setSession(e.accessToken,e.user),m.value){if(!d.isAdmin){d.logout(),a.value="دسترسی ادمین ندارید.";return}await o.replace({name:"admin-home"});return}await o.replace({name:"app-home"})}catch(e){a.value=e instanceof h?e.message:"کد نامعتبر است. دوباره تلاش کنید."}finally{v.value=!1}}async function A(){a.value="",f.value=!0;try{const e=await R(u.value);n.value=e.expiresInSeconds,e.otpCode&&await o.replace({query:{...s.query,devCode:e.otpCode,expires:String(e.expiresInSeconds)}})}catch(e){a.value=e instanceof h?e.message:"ارسال مجدد ناموفق بود."}finally{f.value=!1}}return(e,t)=>(l(),S(j,{title:"کد تأیید",subtitle:`کد ارسال‌شده به ${u.value}`},{default:i(()=>[c.value?(l(),x("p",H," حالت توسعه — کد: "+y(c.value),1)):V("",!0),a.value?(l(),x("p",J,y(a.value),1)):V("",!0),M("form",{class:"space-y-4",onSubmit:D(q,["prevent"])},[_(z,{id:"code",modelValue:p.value,"onUpdate:modelValue":t[0]||(t[0]=w=>p.value=w),label:"کد ۶ رقمی",type:"text",inputmode:"numeric",maxlength:"8",placeholder:"123456",autocomplete:"one-time-code",ltr:"",required:""},null,8,["modelValue"]),_(C,{type:"submit",loading:v.value},{default:i(()=>[...t[2]||(t[2]=[k("ورود",-1)])]),_:1},8,["loading"])],32),n.value>0?(l(),x("p",K," ارسال مجدد تا "+y(n.value)+" ثانیه ",1)):(l(),S(C,{key:3,variant:"ghost",class:"mt-2",loading:f.value,onClick:A},{default:i(()=>[...t[3]||(t[3]=[k(" ارسال مجدد کد ",-1)])]),_:1},8,["loading"])),_(C,{variant:"ghost",class:"mt-2",onClick:t[1]||(t[1]=w=>E(o).push({name:m.value?"admin-login":"login"}))},{default:i(()=>[...t[4]||(t[4]=[k(" تغییر شماره موبایل ",-1)])]),_:1})]),_:1},8,["subtitle"]))}});export{W as default};
@@ -0,0 +1 @@
import{i as f,I as g,b as p,O as u,a as e,H as n,e as i,D as r,d as h,g as k,y,q as d,x as v,F as B,c as C,K as w,J as L,B as A,u as a}from"./index--aiJRDg4.js";import{A as R,_ as S}from"./ThemeToggle.vue_vue_type_script_setup_true_lang-MhHgjOGX.js";const N={class:"sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur"},V={key:0,class:"text-xs text-muted"},z={class:"flex items-center gap-2"},D={class:"flex-1 px-4 py-4"},F={class:"sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2 backdrop-blur"},I={class:"text-lg","aria-hidden":"true"},q=f({__name:"WebAppLayout",setup(O){const o=g(),b=L(),m=w(),l=C(()=>{const s=[{to:"/app",name:"app-home",label:"خانه",icon:"🏠"},{to:"/app/bookings",name:"app-bookings",label:"رزروها",icon:"📅"},{to:"/app/profile",name:"app-profile",label:"پروفایل",icon:"👤"}];return o.isBusinessOwner&&s.splice(2,0,{to:"/app/owner",name:"app-owner",label:"کسب‌وکار",icon:"🏪"}),s});function x(){o.logout(),m.push({name:"login"})}return(s,c)=>{const _=A("RouterLink");return a(),p(R,null,{default:u(()=>[e("header",N,[e("div",null,[c[0]||(c[0]=e("p",{class:"font-bold text-text"},"مون‌تایم",-1)),n(o).user?(a(),i("p",V,r(n(o).user.mobile),1)):h("",!0)]),e("div",z,[k(S),e("button",{type:"button",class:"rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3 hover:text-text",onClick:x}," خروج ")])]),e("main",D,[y(s.$slots,"default")]),e("nav",F,[e("div",{class:d(["grid gap-1",l.value.length===4?"grid-cols-4":"grid-cols-3"])},[(a(!0),i(B,null,v(l.value,t=>(a(),p(_,{key:t.to,to:t.to,class:d(["flex flex-col items-center rounded-xl px-2 py-2 text-xs transition",n(b).name===t.name?"bg-brand-500/15 text-brand-700 dark:text-brand-500":"text-muted hover:bg-surface-3 hover:text-text"])},{default:u(()=>[e("span",I,r(t.icon),1),e("span",null,r(t.label),1)]),_:2},1032,["to","class"]))),128))],2)])]),_:3})}}});export{q as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>مون‌تایم</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index--aiJRDg4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-bUNY5WyE.css">
</head>
<body>
<div id="app"></div>␍
</body>
</html>
+1 -4
View File
@@ -5,12 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:admin": "vite --port 5174 --mode admin",
"build": "vite build", "build": "vite build",
"build:admin": "vite build --mode admin",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"preview": "vite preview", "preview": "vite preview",
"preview:admin": "vite preview --port 5174 --mode admin" "typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
-20
View File
@@ -1,20 +0,0 @@
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' };
}
-67
View File
@@ -1,67 +0,0 @@
<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>
+41 -93
View File
@@ -1,103 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { getAppMode } from '@/composables/useAppMode';
import { useAuthStore } from '@/stores/auth'; 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({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: mode === 'admin' ? adminRoutes : webRoutes, routes: [
{ path: '/', redirect: '/login' },
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true },
},
{
path: '/verify',
name: 'verify',
component: () => import('@/views/auth/VerifyOtpView.vue'),
meta: { guest: true },
},
{
path: '/app',
name: 'app-home',
component: () => import('@/views/app/AppHomeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/bookings',
name: 'app-bookings',
component: () => import('@/views/app/AppBookingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/profile',
name: 'app-profile',
component: () => import('@/views/app/AppProfileView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/owner',
name: 'app-owner',
component: () => import('@/views/app/AppOwnerView.vue'),
meta: { requiresAuth: true, requiresBusinessOwner: true },
},
{ path: '/home', redirect: '/app' },
],
}); });
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
const appMode = getAppMode();
if (auth.token && !auth.user) { if (auth.token && !auth.user) {
await auth.loadUser(); await auth.loadUser();
} }
if (to.meta.requiresAuth && !auth.isLoggedIn) { if (to.meta.requiresAuth && !auth.isLoggedIn) {
return appMode === 'admin' ? { name: 'admin-login' } : { name: 'login' }; return { name: 'login' };
}
if (to.meta.requiresAdmin && !auth.isAdmin) {
return appMode === 'admin' ? { name: 'admin-login' } : { name: 'login' };
} }
if (to.meta.requiresBusinessOwner && !auth.isBusinessOwner && !auth.isAdmin) { if (to.meta.requiresBusinessOwner && !auth.isBusinessOwner && !auth.isAdmin) {
@@ -105,16 +61,8 @@ router.beforeEach(async (to) => {
} }
if (to.meta.guest && auth.isLoggedIn) { if (to.meta.guest && auth.isLoggedIn) {
if (to.meta.adminAuth) {
return auth.isAdmin ? { name: 'admin-home' } : undefined;
}
return { name: 'app-home' }; return { name: 'app-home' };
} }
if (appMode === 'admin' && auth.isLoggedIn && !auth.isAdmin && !to.meta.guest) {
auth.logout();
return { name: 'admin-login' };
}
}); });
export default router; export default router;
-12
View File
@@ -1,12 +0,0 @@
<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
@@ -1,20 +0,0 @@
<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
@@ -1,12 +0,0 @@
<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>
+4 -13
View File
@@ -1,5 +1,5 @@
<template> <template>
<AuthLayout :title="brandTitle" :subtitle="brandSubtitle"> <AuthLayout title="مون‌تایم" subtitle="ورود با شماره موبایل">
<p <p
v-if="error" 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" 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"
@@ -25,24 +25,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth'; import { requestOtp } from '@/api/auth';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import { getAppMode } from '@/composables/useAppMode';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import AuthLayout from '@/layouts/AuthLayout.vue'; import AuthLayout from '@/layouts/AuthLayout.vue';
const router = useRouter(); 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 mobile = ref('');
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
@@ -53,7 +44,7 @@ async function onSubmit() {
try { try {
const res = await requestOtp(mobile.value.trim()); const res = await requestOtp(mobile.value.trim());
await router.push({ await router.push({
name: isAdmin.value ? 'admin-verify' : 'verify', name: 'verify',
query: { query: {
mobile: mobile.value.trim(), mobile: mobile.value.trim(),
expires: String(res.expiresInSeconds), expires: String(res.expiresInSeconds),
+3 -26
View File
@@ -32,21 +32,11 @@
<p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted"> <p v-if="secondsLeft > 0" class="mt-4 text-center text-sm text-muted">
ارسال مجدد تا {{ secondsLeft }} ثانیه ارسال مجدد تا {{ secondsLeft }} ثانیه
</p> </p>
<AppButton <AppButton v-else variant="ghost" class="mt-2" :loading="resendLoading" @click="resend">
v-else
variant="ghost"
class="mt-2"
:loading="resendLoading"
@click="resend"
>
ارسال مجدد کد ارسال مجدد کد
</AppButton> </AppButton>
<AppButton <AppButton variant="ghost" class="mt-2" @click="router.push({ name: 'login' })">
variant="ghost"
class="mt-2"
@click="router.push({ name: isAdmin ? 'admin-login' : 'login' })"
>
تغییر شماره موبایل تغییر شماره موبایل
</AppButton> </AppButton>
</AuthLayout> </AuthLayout>
@@ -57,7 +47,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { requestOtp, verifyOtp } from '@/api/auth'; import { requestOtp, verifyOtp } from '@/api/auth';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import { getAppMode } from '@/composables/useAppMode';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import AuthLayout from '@/layouts/AuthLayout.vue'; import AuthLayout from '@/layouts/AuthLayout.vue';
@@ -66,7 +55,6 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const isAdmin = computed(() => getAppMode() === 'admin' || route.meta.adminAuth === true);
const mobile = computed(() => String(route.query.mobile ?? '')); const mobile = computed(() => String(route.query.mobile ?? ''));
const devCode = computed(() => route.query.devCode as string | undefined); const devCode = computed(() => route.query.devCode as string | undefined);
@@ -80,7 +68,7 @@ let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => { onMounted(() => {
if (!mobile.value) { if (!mobile.value) {
router.replace({ name: isAdmin.value ? 'admin-login' : 'login' }); router.replace({ name: 'login' });
return; return;
} }
timer = setInterval(() => { timer = setInterval(() => {
@@ -98,17 +86,6 @@ async function onSubmit() {
try { try {
const res = await verifyOtp(mobile.value, code.value.trim()); const res = await verifyOtp(mobile.value, code.value.trim());
auth.setSession(res.accessToken, res.user); 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' }); await router.replace({ name: 'app-home' });
} catch (e) { } catch (e) {
error.value = error.value =
-1
View File
@@ -2,7 +2,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL: string; readonly VITE_API_URL: string;
readonly VITE_APP_MODE?: 'web' | 'admin';
} }
interface ImportMeta { interface ImportMeta {
+3 -6
View File
@@ -2,10 +2,7 @@ import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
export default defineConfig(({ mode }) => ({ export default defineConfig({
build: {
outDir: mode === 'admin' ? 'dist-admin' : 'dist',
},
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
@@ -13,6 +10,6 @@ export default defineConfig(({ mode }) => ({
}, },
}, },
server: { server: {
port: mode === 'admin' ? 5174 : 5173, port: 5173,
}, },
})); });