diff --git a/apps/adm/index.html b/apps/adm/index.html index 6116fb9..ee098c2 100644 --- a/apps/adm/index.html +++ b/apps/adm/index.html @@ -4,7 +4,9 @@ - Montech Admin + + + پنل مدیریت منتک
diff --git a/apps/adm/package.json b/apps/adm/package.json index b24370f..036c74b 100644 --- a/apps/adm/package.json +++ b/apps/adm/package.json @@ -10,8 +10,12 @@ "lint": "echo \"adm lint placeholder\"" }, "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@fontsource/vazirmatn": "^5.3.0", "@montech/shared": "*", + "ant-design-vue": "^4.2.6", "axios": "^1.8.4", + "dayjs": "^1.11.23", "pinia": "^3.0.1", "vue": "^3.5.13", "vue-router": "^4.5.0" @@ -19,6 +23,7 @@ "devDependencies": { "@vitejs/plugin-vue": "^5.2.3", "typescript": "^5.8.2", + "unplugin-vue-components": "^0.28.0", "vite": "^6.2.2", "vue-tsc": "^2.2.8" } diff --git a/apps/adm/public/logo.svg b/apps/adm/public/logo.svg new file mode 100644 index 0000000..46b4dc4 --- /dev/null +++ b/apps/adm/public/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/adm/src/App.vue b/apps/adm/src/App.vue index 543cfdb..ef4d311 100644 --- a/apps/adm/src/App.vue +++ b/apps/adm/src/App.vue @@ -1,7 +1,29 @@ diff --git a/apps/adm/src/components.d.ts b/apps/adm/src/components.d.ts new file mode 100644 index 0000000..872de81 --- /dev/null +++ b/apps/adm/src/components.d.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AAlert: typeof import('ant-design-vue/es')['Alert'] + AApp: typeof import('ant-design-vue/es')['App'] + ABadge: typeof import('ant-design-vue/es')['Badge'] + AButton: typeof import('ant-design-vue/es')['Button'] + ACard: typeof import('ant-design-vue/es')['Card'] + ACardMeta: typeof import('ant-design-vue/es')['CardMeta'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] + ACol: typeof import('ant-design-vue/es')['Col'] + ACollapse: typeof import('ant-design-vue/es')['Collapse'] + ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel'] + AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider'] + ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] + ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem'] + ADropdown: typeof import('ant-design-vue/es')['Dropdown'] + AForm: typeof import('ant-design-vue/es')['Form'] + AFormItem: typeof import('ant-design-vue/es')['FormItem'] + AImage: typeof import('ant-design-vue/es')['Image'] + AInput: typeof import('ant-design-vue/es')['Input'] + AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] + AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] + ALayout: typeof import('ant-design-vue/es')['Layout'] + ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] + ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader'] + ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider'] + AMenu: typeof import('ant-design-vue/es')['Menu'] + AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] + AModal: typeof import('ant-design-vue/es')['Modal'] + APageHeader: typeof import('ant-design-vue/es')['PageHeader'] + APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] + AResult: typeof import('ant-design-vue/es')['Result'] + ARow: typeof import('ant-design-vue/es')['Row'] + ASegmented: typeof import('ant-design-vue/es')['Segmented'] + ASelect: typeof import('ant-design-vue/es')['Select'] + ASkeleton: typeof import('ant-design-vue/es')['Skeleton'] + ASpace: typeof import('ant-design-vue/es')['Space'] + ASpin: typeof import('ant-design-vue/es')['Spin'] + AStatistic: typeof import('ant-design-vue/es')['Statistic'] + ASwitch: typeof import('ant-design-vue/es')['Switch'] + ATable: typeof import('ant-design-vue/es')['Table'] + ATag: typeof import('ant-design-vue/es')['Tag'] + ATextarea: typeof import('ant-design-vue/es')['Textarea'] + ATooltip: typeof import('ant-design-vue/es')['Tooltip'] + ATypographyText: typeof import('ant-design-vue/es')['TypographyText'] + AUpload: typeof import('ant-design-vue/es')['Upload'] + AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } +} diff --git a/apps/adm/src/components/ImageField.vue b/apps/adm/src/components/ImageField.vue index 9574a28..ff1e186 100644 --- a/apps/adm/src/components/ImageField.vue +++ b/apps/adm/src/components/ImageField.vue @@ -1,63 +1,58 @@ + + diff --git a/apps/adm/src/components/SchemaFields.vue b/apps/adm/src/components/SchemaFields.vue new file mode 100644 index 0000000..8535d5a --- /dev/null +++ b/apps/adm/src/components/SchemaFields.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/apps/adm/src/composables/useLeadsCount.ts b/apps/adm/src/composables/useLeadsCount.ts new file mode 100644 index 0000000..78e405c --- /dev/null +++ b/apps/adm/src/composables/useLeadsCount.ts @@ -0,0 +1,16 @@ +import { ref } from "vue"; +import { api } from "../lib/api"; + +const newCount = ref(0); + +export function useLeadsCount() { + async function refresh() { + try { + const { data } = await api.get<{ newCount: number }>("/admin/leads", { params: { pageSize: 1 } }); + newCount.value = data.newCount ?? 0; + } catch { + /* the badge is non-critical */ + } + } + return { newCount, refresh }; +} diff --git a/apps/adm/src/fonts.css b/apps/adm/src/fonts.css deleted file mode 100644 index 405b3c7..0000000 --- a/apps/adm/src/fonts.css +++ /dev/null @@ -1,65 +0,0 @@ -/* IRANSans from landing assets (shared path in monorepo) */ -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff") format("woff"); - font-weight: 200; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Light.woff") format("woff"); - font-weight: 300; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum).woff") format("woff"); - font-weight: 400; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff"); - font-weight: 500; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff"); - font-weight: 600; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff") format("woff"); - font-weight: 700; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff"); - font-weight: 800; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "IRANSans"; - src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"), - url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff"); - font-weight: 900; - font-style: normal; - font-display: swap; -} diff --git a/apps/adm/src/layouts/AdminLayout.vue b/apps/adm/src/layouts/AdminLayout.vue index 202dd73..a244727 100644 --- a/apps/adm/src/layouts/AdminLayout.vue +++ b/apps/adm/src/layouts/AdminLayout.vue @@ -1,67 +1,163 @@ diff --git a/apps/adm/src/lib/api.ts b/apps/adm/src/lib/api.ts index 30df757..81b63ec 100644 --- a/apps/adm/src/lib/api.ts +++ b/apps/adm/src/lib/api.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { isAxiosError } from "axios"; const TOKEN_KEY = "montech_adm_token"; @@ -12,6 +12,18 @@ api.interceptors.request.use((config) => { return config; }); +api.interceptors.response.use( + (res) => res, + (error) => { + if (isAxiosError(error) && error.response?.status === 401 && !error.config?.url?.startsWith("/auth/login")) { + setToken(null); + const redirect = encodeURIComponent(window.location.pathname.replace(/^\/adm/, "") || "/"); + window.location.assign(`/adm/login?redirect=${redirect}`); + } + return Promise.reject(error); + }, +); + export function setToken(token: string | null) { if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY); @@ -20,3 +32,10 @@ export function setToken(token: string | null) { export function getToken() { return localStorage.getItem(TOKEN_KEY); } + +export function apiErrorMessage(error: unknown, fallback = "خطا در ارتباط با سرور.") { + if (!isAxiosError(error)) return fallback; + const detail = (error.response?.data as { message?: string | string[] } | undefined)?.message; + if (Array.isArray(detail)) return detail.join("، "); + return detail || fallback; +} diff --git a/apps/adm/src/main.ts b/apps/adm/src/main.ts index 8dd6185..e92d814 100644 --- a/apps/adm/src/main.ts +++ b/apps/adm/src/main.ts @@ -1,5 +1,9 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; +import "@fontsource/vazirmatn/400.css"; +import "@fontsource/vazirmatn/500.css"; +import "@fontsource/vazirmatn/700.css"; +import "ant-design-vue/dist/reset.css"; import App from "./App.vue"; import router from "./router"; import "./styles.css"; diff --git a/apps/adm/src/router/index.ts b/apps/adm/src/router/index.ts index 813243d..b2fae79 100644 --- a/apps/adm/src/router/index.ts +++ b/apps/adm/src/router/index.ts @@ -10,6 +10,7 @@ const router = createRouter({ component: () => import("../layouts/AdminLayout.vue"), children: [ { path: "", name: "dashboard", component: () => import("../views/DashboardView.vue") }, + { path: "leads", name: "leads", component: () => import("../views/LeadsView.vue") }, { path: "settings", name: "settings", component: () => import("../views/SettingsView.vue") }, { path: "sections", name: "sections", component: () => import("../views/SectionsView.vue") }, { path: "sections/:key", name: "section-edit", component: () => import("../views/SectionEditView.vue") }, @@ -21,6 +22,7 @@ const router = createRouter({ { path: "media", name: "media", component: () => import("../views/MediaView.vue") }, ], }, + { path: "/:pathMatch(.*)*", redirect: "/" }, ], }); diff --git a/apps/adm/src/sections/schema.ts b/apps/adm/src/sections/schema.ts new file mode 100644 index 0000000..68f581e --- /dev/null +++ b/apps/adm/src/sections/schema.ts @@ -0,0 +1,260 @@ +import { + METRIC_ICONS, + SOLUTION_ACCENTS, + SOLUTION_ICONS, + type SectionKey, +} from "@montech/shared"; + +export interface Option { + value: string; + label: string; +} + +interface BaseField { + key: string; + label: string; + hint?: string; + /** Grid span out of 24 on desktop. */ + span?: number; +} + +export type Field = + | (BaseField & { kind: "text" | "textarea" | "image"; ltr?: boolean; required?: boolean }) + | (BaseField & { kind: "number"; min?: number; max?: number }) + | (BaseField & { kind: "select"; options: Option[] }) + | (BaseField & { kind: "strings"; itemLabel: string; item?: "text" | "image"; fixed?: number; max?: number }) + | (BaseField & { kind: "list"; itemLabel: string; fields: Field[]; newItem: () => Record; max?: number }) + | (BaseField & { kind: "group"; fields: Field[] }); + +export interface SectionSchema { + description: string; + fields: Field[]; +} + +const linkFields: Field[] = [ + { kind: "text", key: "label", label: "متن", span: 12, required: true }, + { kind: "text", key: "href", label: "لینک", span: 12, ltr: true, hint: "مثلاً #solutions یا /blog" }, +]; + +const newLink = () => ({ label: "", href: "#" }); + +const opt = (values: readonly string[], labels: Record): Option[] => + values.map((v) => ({ value: v, label: labels[v] ?? v })); + +export const SECTION_SCHEMAS: Record = { + header: { + description: "لوگو، منوی بالای صفحه و دکمه مشاوره.", + fields: [ + { kind: "text", key: "brandTitle", label: "نام برند", span: 12, required: true }, + { kind: "text", key: "brandSubtitle", label: "زیرعنوان برند", span: 12, ltr: true }, + { kind: "text", key: "ctaLabel", label: "متن دکمه مشاوره", span: 12 }, + { + kind: "list", + key: "nav", + label: "آیتم‌های منو", + hint: "به ترتیب خواندن از راست به چپ.", + itemLabel: "آیتم", + fields: linkFields, + newItem: newLink, + max: 8, + }, + ], + }, + hero: { + description: "تیتر اصلی، جستجوی هوشمند، ماکاپ لپ‌تاپ/موبایل و پنجره پیشنهاد هوشمند.", + fields: [ + { kind: "text", key: "eyebrow", label: "برچسب بالای تیتر", span: 12 }, + { kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12, required: true }, + { kind: "text", key: "titleHighlight", label: "خط رنگی تیتر", span: 12, required: true }, + { kind: "textarea", key: "subtitle", label: "توضیح زیر تیتر" }, + { kind: "text", key: "searchPlaceholder", label: "متن راهنمای جستجو", span: 12 }, + { kind: "text", key: "searchDefaultQuery", label: "پرس‌وجوی پیش‌فرض (جستجوی خالی)", span: 12 }, + { kind: "text", key: "suggestionsLabel", label: "عنوان پیشنهادها", span: 12 }, + { + kind: "list", + key: "suggestions", + label: "پیشنهادهای پرطرفدار", + itemLabel: "پیشنهاد", + max: 6, + fields: [ + { kind: "text", key: "label", label: "متن دکمه", span: 8, required: true }, + { kind: "text", key: "query", label: "پرس‌وجوی ارسالی", span: 16 }, + ], + newItem: () => ({ label: "", query: "" }), + }, + { + kind: "group", + key: "mockup", + label: "ماکاپ لپ‌تاپ و موبایل", + fields: [ + { kind: "text", key: "domain", label: "دامنه نوار آدرس", span: 12, ltr: true }, + { kind: "text", key: "version", label: "نسخه", span: 12 }, + { kind: "text", key: "salesValue", label: "مبلغ فروش", span: 12 }, + { kind: "text", key: "salesGrowth", label: "رشد فروش", span: 12 }, + { kind: "text", key: "customersValue", label: "تعداد مشتریان", span: 12 }, + { kind: "text", key: "satisfaction", label: "شاخص رضایت", span: 12 }, + { kind: "text", key: "chartDefaultTooltip", label: "برچسب پیش‌فرض نمودار", span: 12 }, + { kind: "number", key: "goldBarCount", label: "موجودی شمش (موبایل)", span: 12, min: 0, max: 9999 }, + { kind: "strings", key: "chartTooltips", label: "برچسب نقاط نمودار", itemLabel: "نقطه", fixed: 3 }, + ], + }, + { + kind: "group", + key: "ai", + label: "پنجره پیشنهاد هوشمند", + fields: [ + { kind: "text", key: "title", label: "عنوان پنجره", span: 12 }, + { kind: "text", key: "queryLabel", label: "برچسب پرس‌وجو", span: 12 }, + { kind: "text", key: "fallbackQuery", label: "پرس‌وجوی جایگزین", span: 12 }, + { kind: "text", key: "packageLabel", label: "برچسب بسته", span: 12 }, + { kind: "text", key: "packageTitle", label: "عنوان بسته", span: 12 }, + { kind: "text", key: "matchBadge", label: "نشان تطابق", span: 12 }, + { kind: "strings", key: "items", label: "موارد بسته", itemLabel: "مورد", max: 6 }, + { kind: "text", key: "primaryCta", label: "دکمه ثبت مشاوره", span: 12 }, + { kind: "text", key: "closeLabel", label: "دکمه بستن", span: 12 }, + ], + }, + ], + }, + metrics: { + description: "نوار سه‌تایی آمار زیر هیرو.", + fields: [ + { + kind: "list", + key: "items", + label: "آمار", + itemLabel: "آمار", + max: 3, + fields: [ + { kind: "select", key: "icon", label: "آیکون", span: 6, options: opt(METRIC_ICONS, { gear: "چرخ‌دنده", users: "کاربران", box: "جعبه" }) }, + { kind: "text", key: "value", label: "مقدار", span: 6, required: true }, + { kind: "text", key: "label", label: "برچسب", span: 6 }, + { kind: "select", key: "emphasis", label: "نمایش", span: 6, options: [{ value: "number", label: "عدد بزرگ" }, { value: "text", label: "متن پررنگ" }] }, + ], + newItem: () => ({ icon: "users", value: "", label: "", emphasis: "number" }), + }, + ], + }, + features: { + description: "بخش «از نسخه پایه تا راهکار اختصاصی» با کارت‌های سه‌بعدی.", + fields: [ + { kind: "text", key: "eyebrow", label: "برچسب", span: 12 }, + { kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 }, + { kind: "text", key: "titleLine2", label: "خط دوم تیتر (رنگی)", span: 12 }, + { kind: "textarea", key: "body", label: "توضیح" }, + { kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 }, + { kind: "text", key: "ctaHref", label: "لینک دکمه", span: 12, ltr: true }, + { kind: "text", key: "baseCardLabel", label: "برچسب کارت پایه", span: 12 }, + { kind: "text", key: "customCardTitle", label: "عنوان کارت اختصاصی", span: 12 }, + { kind: "text", key: "customCardBadge", label: "نشان کارت اختصاصی", span: 12 }, + { kind: "text", key: "salesLabel", label: "برچسب فروش", span: 12 }, + { kind: "text", key: "salesValue", label: "مبلغ فروش", span: 12 }, + { kind: "text", key: "linkNote", label: "یادداشت اتصال", span: 12 }, + { kind: "text", key: "aiLabel", label: "برچسب هوش مصنوعی", span: 8 }, + { kind: "text", key: "aiStatus", label: "وضعیت", span: 8 }, + { kind: "text", key: "aiNote", label: "توضیح", span: 8 }, + ], + }, + solutions: { + description: "کارت‌های محصولات؛ کلیک روی هر کارت فرم مشاوره را با زمینه انتخاب‌شده باز می‌کند.", + fields: [ + { kind: "text", key: "eyebrow", label: "برچسب", span: 8 }, + { kind: "text", key: "title", label: "عنوان", span: 8 }, + { kind: "text", key: "subtitle", label: "زیرعنوان", span: 8 }, + { + kind: "list", + key: "items", + label: "کارت‌ها", + itemLabel: "کارت", + max: 10, + fields: [ + { kind: "text", key: "title", label: "عنوان", span: 12, required: true }, + { kind: "text", key: "subtitle", label: "زیرعنوان", span: 12 }, + { kind: "text", key: "consultType", label: "زمینه در فرم مشاوره", span: 8, hint: "باید با یکی از گزینه‌های فرم مشاوره یکسان باشد." }, + { + kind: "select", + key: "accent", + label: "رنگ", + span: 8, + options: opt(SOLUTION_ACCENTS, { brand: "آبی برند", sky: "آسمانی", indigo: "نیلی", cyan: "فیروزه‌ای", amber: "طلایی" }), + }, + { + kind: "select", + key: "icon", + label: "آیکون", + span: 8, + options: opt(SOLUTION_ICONS, { cube: "مکعب", bars: "نمودار", avatars: "کاربران", cart: "سبد خرید", gold: "شمش طلا" }), + }, + ], + newItem: () => ({ title: "", subtitle: "", consultType: "سایر", accent: "brand", icon: "cube" }), + }, + ], + }, + testimonials: { + description: "بخش اعتماد کسب‌وکارها و کارت نظر مشتری.", + fields: [ + { kind: "text", key: "eyebrow", label: "برچسب", span: 12 }, + { kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 }, + { kind: "text", key: "titleLine2", label: "خط دوم تیتر (رنگی)", span: 12 }, + { kind: "textarea", key: "body", label: "توضیح" }, + { kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 }, + { kind: "text", key: "badge", label: "نشان کارت", span: 12 }, + { kind: "textarea", key: "quote", label: "متن نظر" }, + { kind: "text", key: "name", label: "نام مشتری", span: 12 }, + { kind: "text", key: "role", label: "سمت", span: 12 }, + { kind: "image", key: "avatarUrl", label: "تصویر مشتری" }, + { kind: "strings", key: "decorAvatars", label: "آواتارهای تزئینی", itemLabel: "تصویر", item: "image", fixed: 2 }, + ], + }, + cta: { + description: "بنر آبی دعوت به مشاوره رایگان.", + fields: [ + { kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 }, + { kind: "text", key: "titleLine2", label: "خط دوم تیتر", span: 12 }, + { kind: "textarea", key: "body", label: "توضیح" }, + { kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 }, + ], + }, + footer: { + description: "ستون‌های فوتر. تلفن، ایمیل، آدرس و شبکه‌های اجتماعی از «تنظیمات سایت» خوانده می‌شوند.", + fields: [ + { kind: "text", key: "contactTitle", label: "عنوان ستون تماس", span: 8 }, + { kind: "text", key: "productsTitle", label: "عنوان ستون محصولات", span: 8 }, + { kind: "text", key: "quickLinksTitle", label: "عنوان دسترسی سریع", span: 8 }, + { kind: "list", key: "products", label: "لینک‌های محصولات", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 10 }, + { kind: "list", key: "quickLinks", label: "لینک‌های دسترسی سریع", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 10 }, + { kind: "text", key: "brandTitle", label: "نام برند", span: 12 }, + { kind: "text", key: "brandSubtitle", label: "زیرعنوان برند", span: 12, ltr: true }, + { kind: "strings", key: "descriptionLines", label: "خطوط توضیح برند", itemLabel: "خط", max: 3 }, + { kind: "text", key: "copyright", label: "متن کپی‌رایت" }, + { kind: "list", key: "legalLinks", label: "لینک‌های حقوقی", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 4 }, + ], + }, + consult: { + description: "پنجره درخواست مشاوره رایگان؛ درخواست‌ها در بخش «درخواست‌های مشاوره» ثبت می‌شوند.", + fields: [ + { kind: "text", key: "title", label: "عنوان پنجره" }, + { kind: "text", key: "nameLabel", label: "برچسب نام", span: 12 }, + { kind: "text", key: "namePlaceholder", label: "راهنمای نام", span: 12 }, + { kind: "text", key: "phoneLabel", label: "برچسب تلفن", span: 12 }, + { kind: "text", key: "phonePlaceholder", label: "راهنمای تلفن", span: 12, ltr: true }, + { kind: "text", key: "typeLabel", label: "برچسب زمینه کسب‌وکار", span: 12 }, + { + kind: "list", + key: "types", + label: "گزینه‌های زمینه کسب‌وکار", + hint: "«مقدار» در درخواست ثبت می‌شود و باید با «زمینه در فرم مشاوره» کارت‌های راهکار یکسان باشد.", + itemLabel: "گزینه", + max: 12, + fields: [ + { kind: "text", key: "value", label: "مقدار", span: 12, required: true }, + { kind: "text", key: "label", label: "متن نمایشی", span: 12, required: true }, + ], + newItem: () => ({ value: "", label: "" }), + }, + { kind: "textarea", key: "successMessage", label: "پیام موفقیت" }, + { kind: "textarea", key: "errorMessage", label: "پیام خطا" }, + { kind: "text", key: "submitLabel", label: "متن دکمه ارسال", span: 12 }, + ], + }, +}; diff --git a/apps/adm/src/styles.css b/apps/adm/src/styles.css index 324ce8e..eec0be1 100644 --- a/apps/adm/src/styles.css +++ b/apps/adm/src/styles.css @@ -1,70 +1,46 @@ -@import "./fonts.css"; - -:root { - --bg: #0f1115; - --panel: #171a21; - --border: #2a2f3a; - --text: #e8e9ed; - --muted: #9aa0ab; - --accent: #c8ff2e; - --danger: #ff6b6b; - --radius: 10px; - font-family: "IRANSans", Tahoma, sans-serif; +html, +body, +#app { + min-height: 100%; } -* { box-sizing: border-box; } body { margin: 0; - background: var(--bg); - color: var(--text); - font-family: "IRANSans", Tahoma, sans-serif; + background: #f5f7fb; + font-family: Vazirmatn, Tahoma, sans-serif; } -a { color: inherit; text-decoration: none; } -button, input, textarea, select { - font: inherit; + +.page-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 20px; } -.btn { - border: 0; + +.page-header h1 { + margin: 0; + font-size: 20px; + font-weight: 700; +} + +.page-header p { + margin: 4px 0 0; + color: rgba(0, 0, 0, 0.45); +} + +.ltr, +.ltr input { + direction: ltr; + text-align: left; +} + +.thumb { + width: 48px; + height: 48px; + object-fit: cover; border-radius: 8px; - padding: 0.55rem 1rem; - background: var(--accent); - color: #111; - font-weight: 600; - cursor: pointer; -} -.btn.ghost { - background: transparent; - color: var(--text); - border: 1px solid var(--border); -} -.btn.danger { background: var(--danger); color: #fff; } -.input, .textarea { - width: 100%; - background: #0c0e12; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - padding: 0.6rem 0.75rem; -} -.textarea { min-height: 140px; resize: vertical; } -.card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem; -} -.label { display: block; margin-bottom: 0.35rem; color: var(--muted); font-size: 0.85rem; } -.field { margin-bottom: 0.9rem; } -.table { width: 100%; border-collapse: collapse; } -.table th, .table td { - text-align: right; - padding: 0.65rem 0.4rem; - border-bottom: 1px solid var(--border); -} -.muted { color: var(--muted); } -.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } -.grid-2 { - display: grid; - gap: 1rem; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + border: 1px solid #f0f0f0; + background: #fafafa; } diff --git a/apps/adm/src/views/BlogEditView.vue b/apps/adm/src/views/BlogEditView.vue index 4b0ecf5..cae6fdc 100644 --- a/apps/adm/src/views/BlogEditView.vue +++ b/apps/adm/src/views/BlogEditView.vue @@ -1,63 +1,86 @@