Replace the landing with the artifact design and rebuild the admin in Ant Design.

Adds consultation leads, section editors, and the one-time content reset for this release.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 20:36:06 +03:30
co-authored by Cursor
parent 5147c661f0
commit b0088c35d1
130 changed files with 8024 additions and 2801 deletions
+3 -1
View File
@@ -4,7 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<title>Montech Admin</title> <meta name="theme-color" content="#0062f5" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>پنل مدیریت منتک</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+5
View File
@@ -10,8 +10,12 @@
"lint": "echo \"adm lint placeholder\"" "lint": "echo \"adm lint placeholder\""
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@fontsource/vazirmatn": "^5.3.0",
"@montech/shared": "*", "@montech/shared": "*",
"ant-design-vue": "^4.2.6",
"axios": "^1.8.4", "axios": "^1.8.4",
"dayjs": "^1.11.23",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
@@ -19,6 +23,7 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.2.2", "vite": "^6.2.2",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"
} }
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="44" y2="44" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0062f5"/>
<stop offset="0.5" stop-color="#007dfc"/>
<stop offset="1" stop-color="#38bdf8"/>
</linearGradient>
</defs>
<rect width="44" height="44" rx="16" fill="url(#g)"/>
<g transform="translate(8.25 8.25) scale(0.859375)">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="22" r="2.2" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 642 B

+23 -1
View File
@@ -1,7 +1,29 @@
<template> <template>
<RouterView /> <a-config-provider :locale="faIR" direction="rtl" :theme="theme">
<a-app>
<RouterView />
</a-app>
</a-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import faIR from "ant-design-vue/es/locale/fa_IR";
import type { ThemeConfig } from "ant-design-vue/es/config-provider/context";
import "dayjs/locale/fa";
import dayjs from "dayjs";
dayjs.locale("fa");
const theme: ThemeConfig = {
token: {
colorPrimary: "#0062f5",
colorInfo: "#0062f5",
borderRadius: 10,
fontFamily: "Vazirmatn, Tahoma, sans-serif",
},
components: {
Layout: { colorBgHeader: "#ffffff", colorBgBody: "#f5f7fb" },
},
};
</script> </script>
+58
View File
@@ -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']
}
}
+38 -43
View File
@@ -1,63 +1,58 @@
<template> <template>
<div class="field"> <div class="image-field">
<label v-if="label" class="label">{{ label }}</label> <a-input :id="id" v-model:value="model" class="ltr" :placeholder="placeholder || '/api/uploads/...'" allow-clear />
<div class="row" style="gap: 0.75rem; align-items: flex-start"> <a-upload :show-upload-list="false" accept="image/*" :custom-request="upload">
<div style="flex: 1"> <a-button :loading="uploading">
<input v-model="model" class="input" dir="ltr" :placeholder="placeholder" /> <template #icon><UploadOutlined /></template>
<div class="row" style="margin-top: 0.5rem"> آپلود
<input type="file" accept="image/*" @change="onFile" /> </a-button>
<button type="button" class="btn ghost" :disabled="!file || uploading" @click="upload"> </a-upload>
{{ uploading ? "در حال آپلود..." : "آپلود تصویر" }} <a-image v-if="model" :src="model" :width="40" :height="40" class="preview" />
</button>
</div>
<p v-if="hint" class="muted" style="margin-top: 0.35rem">{{ hint }}</p>
<p v-if="err" class="muted" style="color: var(--danger); margin-top: 0.35rem">{{ err }}</p>
</div>
<img
v-if="model"
:src="model"
alt=""
style="width: 72px; height: 72px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); background: #0c0e12"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
import { UploadOutlined } from "@ant-design/icons-vue";
import { api, apiErrorMessage } from "../lib/api";
const model = defineModel<string>({ default: "" }); const model = defineModel<string>({ default: "" });
defineProps<{ defineProps<{ placeholder?: string; id?: string }>();
label?: string;
placeholder?: string;
hint?: string;
}>();
const file = ref<File | null>(null);
const uploading = ref(false); const uploading = ref(false);
const err = ref("");
function onFile(e: Event) { async function upload(options: UploadRequestOption) {
const input = e.target as HTMLInputElement;
file.value = input.files?.[0] || null;
err.value = "";
}
async function upload() {
if (!file.value) return;
uploading.value = true; uploading.value = true;
err.value = "";
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file.value); fd.append("file", options.file as Blob);
const { data } = await api.post("/admin/media/upload", fd); const { data } = await api.post<{ url: string }>("/admin/media/upload", fd);
model.value = data.url; model.value = data.url;
file.value = null; options.onSuccess?.(data);
} catch { message.success("تصویر آپلود شد.");
err.value = "آپلود ناموفق بود."; } catch (e) {
options.onError?.(e as Error);
message.error(apiErrorMessage(e, "آپلود ناموفق بود."));
} finally { } finally {
uploading.value = false; uploading.value = false;
} }
} }
</script> </script>
<style scoped>
.image-field {
display: flex;
align-items: center;
gap: 8px;
}
.image-field :deep(.ant-input-affix-wrapper) {
flex: 1;
}
.preview :deep(img),
.image-field :deep(.ant-image-img) {
object-fit: cover;
border-radius: 8px;
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<template>
<a-row :gutter="16">
<a-col v-for="field in fields" :key="field.key" :xs="24" :md="isBlock(field) ? 24 : field.span ?? 24">
<a-form-item
v-if="field.kind === 'text' || field.kind === 'textarea' || field.kind === 'image' || field.kind === 'number' || field.kind === 'select'"
:label="field.label"
:html-for="idOf(field.key)"
:extra="field.hint"
:required="'required' in field && field.required"
:validate-status="isMissing(field) ? 'error' : undefined"
:help="isMissing(field) ? 'این فیلد الزامی است.' : undefined"
>
<a-input v-if="field.kind === 'text'" :id="idOf(field.key)" v-model:value="model[field.key]" :class="{ ltr: field.ltr }" />
<a-textarea v-else-if="field.kind === 'textarea'" :id="idOf(field.key)" v-model:value="model[field.key]" :auto-size="{ minRows: 2, maxRows: 6 }" />
<ImageField v-else-if="field.kind === 'image'" :id="idOf(field.key)" v-model="model[field.key]" />
<a-input-number v-else-if="field.kind === 'number'" :id="idOf(field.key)" v-model:value="model[field.key]" :min="field.min" :max="field.max" style="width: 100%" />
<a-select v-else-if="field.kind === 'select'" :id="idOf(field.key)" v-model:value="model[field.key]" :options="field.options" />
</a-form-item>
<a-form-item v-else-if="field.kind === 'strings'" :label="field.label" :html-for="idOf(field.key, 0)" :extra="field.hint">
<div v-for="(_, i) in asArray(field.key)" :key="i" class="string-row">
<ImageField v-if="field.item === 'image'" :id="idOf(field.key, i)" v-model="asArray(field.key)[i]" />
<a-input
v-else
:id="idOf(field.key, i)"
v-model:value="asArray(field.key)[i]"
:aria-label="`${field.label} — ${field.itemLabel} ${toFa(i + 1)}`"
:placeholder="`${field.itemLabel} ${toFa(i + 1)}`"
/>
<a-button v-if="!field.fixed" danger type="text" aria-label="حذف" title="حذف" @click="asArray(field.key).splice(i, 1)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<a-button
v-if="!field.fixed && (!field.max || asArray(field.key).length < field.max)"
type="dashed"
block
@click="asArray(field.key).push('')"
>
<template #icon><PlusOutlined /></template>
افزودن {{ field.itemLabel }}
</a-button>
</a-form-item>
<div v-else-if="field.kind === 'list'" class="block">
<div class="block-head">
<strong>{{ field.label }}</strong>
<a-typography-text v-if="field.hint" type="secondary">{{ field.hint }}</a-typography-text>
</div>
<a-card v-for="(item, i) in asList(field.key)" :key="i" size="small" class="item-card">
<template #title>{{ field.itemLabel }} {{ toFa(i + 1) }}</template>
<template #extra>
<a-space :size="4">
<a-button size="small" type="text" :disabled="i === 0" title="بالا" aria-label="انتقال به بالا" @click="move(field.key, i, -1)">
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button
size="small"
type="text"
:disabled="i === asList(field.key).length - 1"
title="پایین"
aria-label="انتقال به پایین"
@click="move(field.key, i, 1)"
>
<template #icon><ArrowDownOutlined /></template>
</a-button>
<a-popconfirm title="حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="asList(field.key).splice(i, 1)">
<a-button size="small" type="text" danger title="حذف" :aria-label="`حذف ${field.itemLabel} ${toFa(i + 1)}`">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</a-space>
</template>
<SchemaFields :fields="field.fields" :model="item" :show-errors="showErrors" :id-prefix="idOf(field.key, i)" />
</a-card>
<a-button v-if="!field.max || asList(field.key).length < field.max" type="dashed" block @click="asList(field.key).push(field.newItem())">
<template #icon><PlusOutlined /></template>
افزودن {{ field.itemLabel }}
</a-button>
</div>
<a-card v-else-if="field.kind === 'group'" size="small" :title="field.label" class="block">
<SchemaFields :fields="field.fields" :model="asObject(field.key)" :show-errors="showErrors" :id-prefix="idOf(field.key)" />
</a-card>
</a-col>
</a-row>
</template>
<script setup lang="ts">
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import type { Field } from "../sections/schema";
import ImageField from "./ImageField.vue";
type Model = Record<string, any>;
const props = withDefaults(defineProps<{ fields: Field[]; model: Model; showErrors?: boolean; idPrefix?: string }>(), {
showErrors: false,
idPrefix: "sf",
});
const toFa = (n: number) => n.toLocaleString("fa-IR");
function idOf(key: string, index?: number) {
return index === undefined ? `${props.idPrefix}-${key}` : `${props.idPrefix}-${key}-${index}`;
}
function isBlock(field: Field) {
return field.kind === "list" || field.kind === "group" || field.kind === "strings";
}
function isMissing(field: Field) {
if (!props.showErrors || !("required" in field) || !field.required) return false;
const v = props.model[field.key];
return typeof v !== "string" || !v.trim();
}
function asArray(key: string): string[] {
if (!Array.isArray(props.model[key])) props.model[key] = [];
return props.model[key];
}
function asList(key: string): Model[] {
if (!Array.isArray(props.model[key])) props.model[key] = [];
return props.model[key];
}
function asObject(key: string): Model {
const v = props.model[key];
if (!v || typeof v !== "object" || Array.isArray(v)) props.model[key] = {};
return props.model[key];
}
function move(key: string, i: number, delta: number) {
const list = asList(key);
const j = i + delta;
if (j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j], list[i]];
}
</script>
<style scoped>
.block {
margin-bottom: 20px;
}
.block-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
}
.item-card {
margin-bottom: 10px;
background: #fafbfd;
}
.string-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.string-row > :first-child {
flex: 1;
}
</style>
+16
View File
@@ -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 };
}
-65
View File
@@ -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;
}
+138 -42
View File
@@ -1,67 +1,163 @@
<template> <template>
<div class="shell"> <a-layout class="shell">
<aside class="side"> <a-layout-sider
<div class="brand">Montech <span>/adm</span></div> v-model:collapsed="collapsed"
<nav> :width="232"
<RouterLink to="/">داشبورد</RouterLink> breakpoint="lg"
<RouterLink to="/settings">تنظیمات سایت</RouterLink> collapsible
<RouterLink to="/sections">محتوای لندینگ</RouterLink> :collapsed-width="64"
<RouterLink to="/blog">بلاگ</RouterLink> theme="light"
<RouterLink to="/gallery">گالری</RouterLink> class="side"
<RouterLink to="/seo">سئو صفحات</RouterLink> >
<RouterLink to="/media">رسانه</RouterLink> <RouterLink to="/" class="brand">
</nav> <img src="/logo.svg" alt="" width="32" height="32" />
<button class="btn ghost" @click="onLogout">خروج</button> <span v-if="!collapsed">منتک <small>پنل مدیریت</small></span>
</aside> </RouterLink>
<main class="main"> <a-menu :selected-keys="selectedKeys" mode="inline" :items="menuItems" @click="onMenu" />
<RouterView /> </a-layout-sider>
</main>
</div> <a-layout>
<a-layout-header class="header">
<a-space :size="12">
<a-badge :count="leadsBadge" :overflow-count="99">
<a-button type="text" @click="router.push('/leads')">
<template #icon><InboxOutlined /></template>
درخواست‌ها
</a-button>
</a-badge>
<a-button href="/" target="_blank" type="text">
<template #icon><GlobalOutlined /></template>
مشاهده سایت
</a-button>
</a-space>
<a-dropdown placement="bottomLeft">
<a-button type="text">
<template #icon><UserOutlined /></template>
{{ auth.admin?.name || "ادمین" }}
</a-button>
<template #overlay>
<a-menu @click="onLogout">
<a-menu-item key="logout">
<LogoutOutlined />
خروج
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content class="main">
<RouterView />
</a-layout-content>
</a-layout>
</a-layout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { computed, h, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import type { MenuProps } from "ant-design-vue";
import {
AppstoreOutlined,
DashboardOutlined,
FileTextOutlined,
GlobalOutlined,
InboxOutlined,
LogoutOutlined,
PictureOutlined,
SearchOutlined,
SettingOutlined,
UserOutlined,
CloudUploadOutlined,
} from "@ant-design/icons-vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { useLeadsCount } from "../composables/useLeadsCount";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const collapsed = ref(false);
const { newCount, refresh } = useLeadsCount();
onMounted(() => { const NAV = [
auth.fetchMe().catch(() => auth.logout()); { key: "/", label: "داشبورد", icon: DashboardOutlined },
{ key: "/leads", label: "درخواست‌های مشاوره", icon: InboxOutlined },
{ key: "/sections", label: "محتوای لندینگ", icon: AppstoreOutlined },
{ key: "/settings", label: "تنظیمات سایت", icon: SettingOutlined },
{ key: "/blog", label: "بلاگ", icon: FileTextOutlined },
{ key: "/gallery", label: "گالری", icon: PictureOutlined },
{ key: "/seo", label: "سئو صفحات", icon: SearchOutlined },
{ key: "/media", label: "رسانه", icon: CloudUploadOutlined },
];
const menuItems = computed<MenuProps["items"]>(() =>
NAV.map((n) => ({ key: n.key, label: n.label, icon: () => h(n.icon) })),
);
const selectedKeys = computed(() => {
const match = NAV.filter((n) => n.key !== "/" && route.path.startsWith(n.key));
return [match[0]?.key ?? "/"];
}); });
const leadsBadge = computed(() => newCount.value);
const onMenu: MenuProps["onClick"] = ({ key }) => {
router.push(String(key));
};
function onLogout() { function onLogout() {
auth.logout(); auth.logout();
router.push({ name: "login" }); router.push({ name: "login" });
} }
onMounted(() => {
auth.fetchMe().catch(() => undefined);
refresh();
});
watch(() => route.path, refresh);
</script> </script>
<style scoped> <style scoped>
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; } .shell {
min-height: 100vh;
}
.side { .side {
border-left: 1px solid var(--border); border-inline-start: 1px solid #f0f0f0;
background: #12151b; }
padding: 1.25rem; .brand {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 1rem; gap: 10px;
height: 64px;
padding: 0 16px;
color: #0f172a;
font-weight: 800;
font-size: 17px;
border-bottom: 1px solid #f0f0f0;
} }
.brand { font-weight: 700; font-size: 1.1rem; } .brand small {
.brand span { color: var(--accent); } display: block;
nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; } font-size: 11px;
nav a { font-weight: 500;
padding: 0.55rem 0.7rem; color: rgba(0, 0, 0, 0.45);
border-radius: 8px;
color: var(--muted);
} }
nav a.router-link-active { .header {
background: #1d2430; display: flex;
color: var(--text); align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
line-height: normal;
} }
.main { padding: 1.5rem; } .main {
@media (max-width: 860px) { padding: 24px;
.shell { grid-template-columns: 1fr; } max-width: 1200px;
.side { border-left: 0; border-bottom: 1px solid var(--border); } width: 100%;
margin: 0 auto;
}
@media (max-width: 640px) {
.main {
padding: 16px;
}
} }
</style> </style>
+20 -1
View File
@@ -1,4 +1,4 @@
import axios from "axios"; import axios, { isAxiosError } from "axios";
const TOKEN_KEY = "montech_adm_token"; const TOKEN_KEY = "montech_adm_token";
@@ -12,6 +12,18 @@ api.interceptors.request.use((config) => {
return 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) { export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token); if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY); else localStorage.removeItem(TOKEN_KEY);
@@ -20,3 +32,10 @@ export function setToken(token: string | null) {
export function getToken() { export function getToken() {
return localStorage.getItem(TOKEN_KEY); 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;
}
+4
View File
@@ -1,5 +1,9 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia"; 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 App from "./App.vue";
import router from "./router"; import router from "./router";
import "./styles.css"; import "./styles.css";
+2
View File
@@ -10,6 +10,7 @@ const router = createRouter({
component: () => import("../layouts/AdminLayout.vue"), component: () => import("../layouts/AdminLayout.vue"),
children: [ children: [
{ path: "", name: "dashboard", component: () => import("../views/DashboardView.vue") }, { 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: "settings", name: "settings", component: () => import("../views/SettingsView.vue") },
{ path: "sections", name: "sections", component: () => import("../views/SectionsView.vue") }, { path: "sections", name: "sections", component: () => import("../views/SectionsView.vue") },
{ path: "sections/:key", name: "section-edit", component: () => import("../views/SectionEditView.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: "media", name: "media", component: () => import("../views/MediaView.vue") },
], ],
}, },
{ path: "/:pathMatch(.*)*", redirect: "/" },
], ],
}); });
+260
View File
@@ -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<string, unknown>; 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<string, string>): Option[] =>
values.map((v) => ({ value: v, label: labels[v] ?? v }));
export const SECTION_SCHEMAS: Record<SectionKey, SectionSchema> = {
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 },
],
},
};
+38 -62
View File
@@ -1,70 +1,46 @@
@import "./fonts.css"; html,
body,
:root { #app {
--bg: #0f1115; min-height: 100%;
--panel: #171a21;
--border: #2a2f3a;
--text: #e8e9ed;
--muted: #9aa0ab;
--accent: #c8ff2e;
--danger: #ff6b6b;
--radius: 10px;
font-family: "IRANSans", Tahoma, sans-serif;
} }
* { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: #f5f7fb;
color: var(--text); font-family: Vazirmatn, Tahoma, sans-serif;
font-family: "IRANSans", Tahoma, sans-serif;
} }
a { color: inherit; text-decoration: none; }
button, input, textarea, select { .page-header {
font: inherit; 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; border-radius: 8px;
padding: 0.55rem 1rem; border: 1px solid #f0f0f0;
background: var(--accent); background: #fafafa;
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));
} }
+97 -54
View File
@@ -1,63 +1,86 @@
<template> <template>
<div> <div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem"> <div class="page-header">
<h1>{{ isNew ? "پست جدید" : "ویرایش پست" }}</h1> <div>
<RouterLink class="btn ghost" to="/blog">بازگشت</RouterLink> <h1>{{ isNew ? "پست جدید" : "ویرایش پست" }}</h1>
</div>
<a-space>
<a-button v-if="!isNew && form.published" :href="`/blog/${form.slug}`" target="_blank">مشاهده</a-button>
<a-button @click="router.push('/blog')">بازگشت</a-button>
</a-space>
</div> </div>
<form class="card" @submit.prevent="save">
<div class="grid-2"> <a-spin :spinning="loading">
<div class="field"> <a-form layout="vertical" :model="form" @finish="save">
<label class="label">عنوان</label> <a-row :gutter="16">
<input v-model="form.title" class="input" required /> <a-col :xs="24" :lg="16">
</div> <a-card title="محتوا">
<div class="field"> <a-form-item label="عنوان" name="title" :rules="[{ required: true, message: 'عنوان الزامی است.' }]">
<label class="label">اسلاگ</label> <a-input v-model:value="form.title" @blur="suggestSlug" />
<input v-model="form.slug" class="input" dir="ltr" required /> </a-form-item>
</div> <a-form-item
</div> label="اسلاگ"
<div class="field"> name="slug"
<label class="label">خلاصه</label> :rules="[
<textarea v-model="form.excerpt" class="textarea" /> { required: true, message: 'اسلاگ الزامی است.' },
</div> { pattern: SLUG_RE, message: 'فقط حروف انگلیسی کوچک، عدد و خط تیره.' },
<div class="field"> ]"
<label class="label">متن پست</label> >
<textarea v-model="form.content" class="textarea" style="min-height: 240px" placeholder="متن را عادی بنویسید. برای پاراگراف جدید Enter بزنید." /> <a-input v-model:value="form.slug" class="ltr" addon-before="/blog/" />
</div> </a-form-item>
<ImageField v-model="form.coverUrl" label="تصویر کاور" /> <a-form-item label="خلاصه" name="excerpt">
<div class="grid-2"> <a-textarea v-model:value="form.excerpt" :auto-size="{ minRows: 2, maxRows: 4 }" show-count :maxlength="300" />
<div class="field"> </a-form-item>
<label class="label">عنوان سئو</label> <a-form-item label="متن پست" name="content" extra="برای پاراگراف جدید یک خط خالی بگذارید.">
<input v-model="form.seoTitle" class="input" /> <a-textarea v-model:value="form.content" :auto-size="{ minRows: 10 }" />
</div> </a-form-item>
<div class="field"> <a-form-item label="تصویر کاور">
<label class="label">توضیح سئو</label> <ImageField v-model="form.coverUrl" />
<input v-model="form.seoDesc" class="input" /> </a-form-item>
</div> </a-card>
<div class="field"> </a-col>
<label class="label">آدرس Canonical</label> <a-col :xs="24" :lg="8">
<input v-model="form.canonical" class="input" dir="ltr" /> <a-card title="انتشار">
</div> <a-form-item name="published">
<div class="field"> <a-switch v-model:checked="form.published" checked-children="منتشر" un-checked-children="پیش‌نویس" />
<label class="label"><input v-model="form.published" type="checkbox" /> منتشر شود</label> </a-form-item>
</div> <a-button type="primary" html-type="submit" block :loading="saving">ذخیره</a-button>
</div> </a-card>
<ImageField v-model="form.ogImage" label="تصویر اشتراک‌گذاری (اختیاری)" /> <a-card title="سئو" style="margin-top: 16px">
<p v-if="msg" class="muted">{{ msg }}</p> <a-form-item label="عنوان سئو" name="seoTitle">
<button class="btn">ذخیره</button> <a-input v-model:value="form.seoTitle" show-count :maxlength="70" />
</form> </a-form-item>
<a-form-item label="توضیح سئو" name="seoDesc">
<a-textarea v-model:value="form.seoDesc" show-count :maxlength="170" :auto-size="{ minRows: 3 }" />
</a-form-item>
<a-form-item label="Canonical" name="canonical">
<a-input v-model:value="form.canonical" class="ltr" placeholder="https://montech.ir/blog/..." />
</a-form-item>
<a-form-item label="تصویر اشتراک‌گذاری">
<ImageField v-model="form.ogImage" />
</a-form-item>
</a-card>
</a-col>
</a-row>
</a-form>
</a-spin>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import { api, apiErrorMessage } from "../lib/api";
import ImageField from "../components/ImageField.vue"; import ImageField from "../components/ImageField.vue";
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const isNew = computed(() => route.name === "blog-new"); const isNew = computed(() => route.name === "blog-new");
const msg = ref(""); const loading = ref(false);
const saving = ref(false);
const form = reactive({ const form = reactive({
title: "", title: "",
slug: "", slug: "",
@@ -73,23 +96,43 @@ const form = reactive({
onMounted(async () => { onMounted(async () => {
if (isNew.value) return; if (isNew.value) return;
const { data } = await api.get(`/admin/blog/${route.params.id}`); loading.value = true;
Object.assign(form, data); try {
const { data } = await api.get(`/admin/blog/${route.params.id}`);
for (const k of Object.keys(form) as Array<keyof typeof form>) {
const v = data[k];
if (v !== undefined && v !== null) (form as Record<string, unknown>)[k] = v;
}
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
}); });
function suggestSlug() {
if (form.slug || !/[a-z0-9]/i.test(form.title)) return;
form.slug = form.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function save() { async function save() {
msg.value = ""; saving.value = true;
try { try {
if (isNew.value) { if (isNew.value) {
const { data } = await api.post("/admin/blog", form); const { data } = await api.post<{ id: string }>("/admin/blog", form);
msg.value = "ایجاد شد."; message.success("پست ایجاد شد.");
router.replace(`/blog/${data.id}`); router.replace(`/blog/${data.id}`);
} else { } else {
await api.put(`/admin/blog/${route.params.id}`, form); await api.put(`/admin/blog/${route.params.id}`, form);
msg.value = "ذخیره شد."; message.success("ذخیره شد.");
} }
} catch { } catch (e) {
msg.value = "خطا در ذخیره."; message.error(apiErrorMessage(e, "خطا در ذخیره."));
} finally {
saving.value = false;
} }
} }
</script> </script>
+72 -26
View File
@@ -1,42 +1,88 @@
<template> <template>
<div> <div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem"> <div class="page-header">
<h1>بلاگ</h1> <div>
<RouterLink class="btn" to="/blog/new">پست جدید</RouterLink> <h1>بلاگ</h1>
<p>پست‌های منتشرشده در /blog نمایش داده می‌شوند.</p>
</div>
<a-button type="primary" @click="router.push('/blog/new')">
<template #icon><PlusOutlined /></template>
پست جدید
</a-button>
</div> </div>
<table class="table">
<thead> <a-card :body-style="{ padding: 0 }">
<tr><th>عنوان</th><th>اسلاگ</th><th>وضعیت</th><th></th></tr> <a-table :columns="columns" :data-source="posts" :loading="loading" row-key="id" :pagination="{ pageSize: 20 }" :scroll="{ x: 640 }">
</thead> <template #bodyCell="{ column, record }">
<tbody> <template v-if="column.key === 'title'">
<tr v-for="p in posts" :key="p.id"> <RouterLink :to="`/blog/${record.id}`">{{ record.title }}</RouterLink>
<td>{{ p.title }}</td> </template>
<td class="muted" dir="ltr">{{ p.slug }}</td> <template v-else-if="column.key === 'slug'">
<td>{{ p.published ? "منتشر" : "پیش‌نویس" }}</td> <span class="ltr">{{ record.slug }}</span>
<td class="row"> </template>
<RouterLink :to="`/blog/${p.id}`">ویرایش</RouterLink> <template v-else-if="column.key === 'published'">
<button class="btn danger" @click="remove(p.id)">حذف</button> <a-tag :color="record.published ? 'green' : 'default'">{{ record.published ? "منتشر" : "پیش‌نویس" }}</a-tag>
</td> </template>
</tr> <template v-else-if="column.key === 'actions'">
</tbody> <a-space>
</table> <a-button size="small" @click="router.push(`/blog/${record.id}`)">ویرایش</a-button>
<a-popconfirm title="این پست حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
<a-button size="small" danger>حذف</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../lib/api"; import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import type { TableColumnsType } from "ant-design-vue";
import { PlusOutlined } from "@ant-design/icons-vue";
import { api, apiErrorMessage } from "../lib/api";
interface PostRow {
id: string;
title: string;
slug: string;
published: boolean;
}
const router = useRouter();
const posts = ref<PostRow[]>([]);
const loading = ref(true);
const columns: TableColumnsType<PostRow> = [
{ title: "عنوان", key: "title", dataIndex: "title" },
{ title: "اسلاگ", key: "slug", dataIndex: "slug" },
{ title: "وضعیت", key: "published", dataIndex: "published", width: 120 },
{ title: "", key: "actions", width: 160 },
];
const posts = ref<any[]>([]);
async function load() { async function load() {
const { data } = await api.get("/admin/blog"); loading.value = true;
posts.value = data; try {
const { data } = await api.get<PostRow[]>("/admin/blog");
posts.value = data;
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
} }
onMounted(load); onMounted(load);
async function remove(id: string) { async function remove(id: string) {
if (!confirm("حذف شود؟")) return; try {
await api.delete(`/admin/blog/${id}`); await api.delete(`/admin/blog/${id}`);
await load(); message.success("حذف شد.");
await load();
} catch (e) {
message.error(apiErrorMessage(e));
}
} }
</script> </script>
+70 -7
View File
@@ -1,17 +1,80 @@
<template> <template>
<div> <div>
<h1>داشبورد</h1> <div class="page-header">
<p class="muted">سلام {{ auth.admin?.name || "ادمین" }} — مدیریت محتوای montech.ir</p> <div>
<div class="grid-2" style="margin-top: 1.25rem"> <h1>داشبورد</h1>
<RouterLink class="card" to="/sections">ویرایش بخش‌های لندینگ</RouterLink> <p>سلام {{ auth.admin?.name || "ادمین" }} — مدیریت محتوای montech.ir</p>
<RouterLink class="card" to="/blog">مدیریت بلاگ</RouterLink> </div>
<RouterLink class="card" to="/gallery">گالری</RouterLink>
<RouterLink class="card" to="/seo">سئو صفحات</RouterLink>
</div> </div>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :lg="6">
<a-card hoverable @click="router.push('/leads')">
<a-statistic title="درخواست‌های جدید" :value="stats.newLeads" :value-style="{ color: '#0062f5' }">
<template #prefix><InboxOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card hoverable @click="router.push('/leads')">
<a-statistic title="کل درخواست‌ها" :value="stats.totalLeads">
<template #prefix><TeamOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card hoverable @click="router.push('/blog')">
<a-statistic title="پست‌های بلاگ" :value="stats.posts">
<template #prefix><FileTextOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card hoverable @click="router.push('/sections')">
<a-statistic title="بخش‌های سفارشی‌شده" :value="stats.customSections" :suffix="`/ ${stats.totalSections}`">
<template #prefix><AppstoreOutlined /></template>
</a-statistic>
</a-card>
</a-col>
</a-row>
<a-card title="دسترسی سریع" style="margin-top: 16px">
<a-space wrap>
<a-button type="primary" @click="router.push('/sections/hero')">ویرایش هیرو</a-button>
<a-button @click="router.push('/sections/solutions')">راهکارها</a-button>
<a-button @click="router.push('/settings')">اطلاعات تماس</a-button>
<a-button @click="router.push('/blog/new')">پست جدید</a-button>
<a-button @click="router.push('/seo')">سئو صفحات</a-button>
</a-space>
</a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
import { AppstoreOutlined, FileTextOutlined, InboxOutlined, TeamOutlined } from "@ant-design/icons-vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { api } from "../lib/api";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter();
const stats = reactive({ newLeads: 0, totalLeads: 0, posts: 0, customSections: 0, totalSections: 0 });
onMounted(async () => {
const [leads, posts, sections] = await Promise.allSettled([
api.get<{ total: number; newCount: number }>("/admin/leads", { params: { pageSize: 1 } }),
api.get<unknown[]>("/admin/blog"),
api.get<Array<{ isDefault?: boolean }>>("/admin/sections"),
]);
if (leads.status === "fulfilled") {
stats.newLeads = leads.value.data.newCount ?? 0;
stats.totalLeads = leads.value.data.total ?? 0;
}
if (posts.status === "fulfilled") stats.posts = posts.value.data.length;
if (sections.status === "fulfilled") {
stats.totalSections = sections.value.data.length;
stats.customSections = sections.value.data.filter((s) => !s.isDefault).length;
}
});
</script> </script>
+106 -48
View File
@@ -1,70 +1,128 @@
<template> <template>
<div> <div>
<h1>گالری</h1> <div class="page-header">
<form class="card" style="margin-bottom: 1rem" @submit.prevent="create"> <div>
<div class="grid-2"> <h1>گالری</h1>
<div class="field"> <p>تصاویر صفحه /gallery به ترتیب عدد «ترتیب» نمایش داده می‌شوند.</p>
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required />
</div>
<div class="field">
<label class="label">دسته</label>
<input v-model="form.category" class="input" />
</div>
<div class="field">
<label class="label">ترتیب</label>
<input v-model.number="form.sortOrder" type="number" class="input" />
</div>
</div> </div>
<ImageField v-model="form.imageUrl" label="تصویر" /> </div>
<button class="btn" style="margin-top: 0.75rem" :disabled="!form.imageUrl">افزودن</button>
</form> <a-card title="افزودن تصویر" style="margin-bottom: 16px">
<table class="table"> <a-form ref="formRef" layout="vertical" :model="form" @finish="create">
<thead> <a-row :gutter="16">
<tr><th>پیش‌نمایش</th><th>عنوان</th><th>دسته</th><th>ترتیب</th><th></th></tr> <a-col :xs="24" :md="10">
</thead> <a-form-item label="عنوان" name="title" :rules="[{ required: true, message: 'عنوان الزامی است.' }]">
<tbody> <a-input v-model:value="form.title" />
<tr v-for="g in items" :key="g.id"> </a-form-item>
<td> </a-col>
<img v-if="g.imageUrl" :src="g.imageUrl" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px" /> <a-col :xs="24" :md="8">
</td> <a-form-item label="دسته" name="category">
<td>{{ g.title }}</td> <a-input v-model:value="form.category" />
<td class="muted">{{ g.category || "—" }}</td> </a-form-item>
<td>{{ g.sortOrder }}</td> </a-col>
<td><button class="btn danger" @click="remove(g.id)">حذف</button></td> <a-col :xs="24" :md="6">
</tr> <a-form-item label="ترتیب" name="sortOrder">
</tbody> <a-input-number v-model:value="form.sortOrder" style="width: 100%" />
</table> </a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="تصویر" name="imageUrl" :rules="[{ required: true, message: 'تصویر را آپلود کنید.' }]">
<ImageField v-model="form.imageUrl" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">
<template #icon><PlusOutlined /></template>
افزودن
</a-button>
</a-form>
</a-card>
<a-card :body-style="{ padding: 0 }">
<a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 560 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image v-if="record.imageUrl" :src="record.imageUrl" :width="48" :height="48" class="thumb" />
</template>
<template v-else-if="column.key === 'category'">
<span>{{ record.category || "—" }}</span>
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
<a-button size="small" danger>حذف</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import type { FormInstance, TableColumnsType } from "ant-design-vue";
import { PlusOutlined } from "@ant-design/icons-vue";
import { api, apiErrorMessage } from "../lib/api";
import ImageField from "../components/ImageField.vue"; import ImageField from "../components/ImageField.vue";
const items = ref<any[]>([]); interface GalleryRow {
id: string;
title: string;
imageUrl: string;
category: string | null;
sortOrder: number;
}
const items = ref<GalleryRow[]>([]);
const loading = ref(true);
const saving = ref(false);
const formRef = ref<FormInstance>();
const form = reactive({ title: "", imageUrl: "", category: "", sortOrder: 0, published: true }); const form = reactive({ title: "", imageUrl: "", category: "", sortOrder: 0, published: true });
const columns: TableColumnsType<GalleryRow> = [
{ title: "پیش‌نمایش", key: "image", width: 96 },
{ title: "عنوان", key: "title", dataIndex: "title" },
{ title: "دسته", key: "category", dataIndex: "category" },
{ title: "ترتیب", key: "sortOrder", dataIndex: "sortOrder", width: 90 },
{ title: "", key: "actions", width: 90 },
];
async function load() { async function load() {
const { data } = await api.get("/admin/gallery"); loading.value = true;
items.value = data; try {
const { data } = await api.get<GalleryRow[]>("/admin/gallery");
items.value = data;
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
} }
onMounted(load); onMounted(load);
async function create() { async function create() {
if (!form.imageUrl) return; saving.value = true;
await api.post("/admin/gallery", form); try {
form.title = ""; await api.post("/admin/gallery", form);
form.imageUrl = ""; message.success("تصویر اضافه شد.");
form.category = ""; formRef.value?.resetFields();
form.sortOrder = 0; Object.assign(form, { title: "", imageUrl: "", category: "", sortOrder: 0 });
await load(); await load();
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
saving.value = false;
}
} }
async function remove(id: string) { async function remove(id: string) {
if (!confirm("حذف شود؟")) return; try {
await api.delete(`/admin/gallery/${id}`); await api.delete(`/admin/gallery/${id}`);
await load(); message.success("حذف شد.");
await load();
} catch (e) {
message.error(apiErrorMessage(e));
}
} }
</script> </script>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div>
<div class="page-header">
<div>
<h1>درخواست‌های مشاوره</h1>
<p>درخواست‌های ثبت‌شده از فرم مشاوره، کارت‌های راهکار و پیشنهاد هوشمند لندینگ.</p>
</div>
<a-space wrap>
<a-segmented v-model:value="statusFilter" :options="filterOptions" @change="onFilter" />
<a-button :loading="loading" title="بارگذاری مجدد" aria-label="بارگذاری مجدد" @click="load">
<template #icon><ReloadOutlined /></template>
</a-button>
</a-space>
</div>
<a-card :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="items"
:loading="loading"
row-key="id"
:pagination="pagination"
:scroll="{ x: 900 }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<template v-else-if="column.key === 'phone'">
<a :href="`tel:${record.phone}`" class="ltr">{{ record.phone }}</a>
</template>
<template v-else-if="column.key === 'source'">
<a-tag :color="SOURCE_META[record.source as LeadSource]?.color">{{ SOURCE_META[record.source as LeadSource]?.label ?? record.source }}</a-tag>
<a-tooltip v-if="record.aiQuery" :title="record.aiQuery">
<InfoCircleOutlined style="color: #999" />
</a-tooltip>
</template>
<template v-else-if="column.key === 'status'">
<a-select
:value="record.status"
:options="statusOptions"
size="small"
style="width: 130px"
@change="(v) => update(record as Lead, { status: v as LeadStatus })"
/>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ new Date(record.createdAt).toLocaleString("fa-IR", { dateStyle: "medium", timeStyle: "short" }) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="openNote(record as Lead)">
<template #icon><EditOutlined /></template>
{{ record.note ? "یادداشت" : "افزودن یادداشت" }}
</a-button>
<a-popconfirm title="این درخواست حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
<a-button size="small" danger title="حذف" aria-label="حذف درخواست">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #expandedRowRender="{ record }">
<a-descriptions size="small" :column="1">
<a-descriptions-item v-if="record.aiQuery" label="پرس‌وجوی هوشمند">{{ record.aiQuery }}</a-descriptions-item>
<a-descriptions-item label="یادداشت">{{ record.note || "—" }}</a-descriptions-item>
</a-descriptions>
</template>
</a-table>
</a-card>
<a-modal v-model:open="noteOpen" title="یادداشت پیگیری" ok-text="ذخیره" cancel-text="انصراف" :confirm-loading="noteSaving" @ok="saveNote">
<a-textarea v-model:value="noteText" :auto-size="{ minRows: 4 }" :maxlength="2000" show-count />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { message } from "ant-design-vue";
import type { TableColumnsType, TablePaginationConfig } from "ant-design-vue";
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, ReloadOutlined } from "@ant-design/icons-vue";
import { LEAD_STATUSES, type Lead, type LeadSource, type LeadStatus } from "@montech/shared";
import { api, apiErrorMessage } from "../lib/api";
import { useLeadsCount } from "../composables/useLeadsCount";
const STATUS_LABELS: Record<LeadStatus, string> = { new: "جدید", contacted: "تماس گرفته شد", closed: "بسته شد" };
const SOURCE_META: Record<LeadSource, { label: string; color: string }> = {
consult: { label: "فرم مشاوره", color: "blue" },
solution: { label: "کارت راهکار", color: "cyan" },
ai: { label: "پیشنهاد هوشمند", color: "purple" },
};
const statusOptions = LEAD_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }));
const filterOptions = [{ value: "all", label: "همه" }, ...statusOptions];
const { refresh: refreshBadge } = useLeadsCount();
const items = ref<Lead[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const statusFilter = ref<"all" | LeadStatus>("new");
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ["20", "50", "100"],
}));
const columns: TableColumnsType<Lead> = [
{ title: "نام", key: "name", dataIndex: "name" },
{ title: "تلفن", key: "phone", dataIndex: "phone", width: 150 },
{ title: "زمینه کسب‌وکار", key: "businessType", dataIndex: "businessType" },
{ title: "منبع", key: "source", dataIndex: "source", width: 150 },
{ title: "وضعیت", key: "status", dataIndex: "status", width: 150 },
{ title: "تاریخ", key: "createdAt", dataIndex: "createdAt", width: 170 },
{ title: "", key: "actions", width: 190 },
];
async function load() {
loading.value = true;
try {
const { data } = await api.get<{ items: Lead[]; total: number }>("/admin/leads", {
params: {
status: statusFilter.value === "all" ? undefined : statusFilter.value,
page: page.value,
pageSize: pageSize.value,
},
});
items.value = data.items;
total.value = data.total;
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
}
function onFilter() {
page.value = 1;
load();
}
function onTableChange(p: TablePaginationConfig) {
page.value = p.current ?? 1;
pageSize.value = p.pageSize ?? 20;
load();
}
async function update(record: Lead, patch: Partial<Pick<Lead, "status" | "note">>) {
try {
const { data } = await api.patch<Lead>(`/admin/leads/${record.id}`, patch);
Object.assign(record, data);
message.success("به‌روزرسانی شد.");
refreshBadge();
if (patch.status && statusFilter.value !== "all" && patch.status !== statusFilter.value) load();
} catch (e) {
message.error(apiErrorMessage(e));
}
}
const noteOpen = ref(false);
const noteText = ref("");
const noteSaving = ref(false);
let noteTarget: Lead | null = null;
function openNote(record: Lead) {
noteTarget = record;
noteText.value = record.note ?? "";
noteOpen.value = true;
}
async function saveNote() {
if (!noteTarget) return;
noteSaving.value = true;
await update(noteTarget, { note: noteText.value });
noteSaving.value = false;
noteOpen.value = false;
}
async function remove(id: string) {
try {
await api.delete(`/admin/leads/${id}`);
message.success("حذف شد.");
refreshBadge();
await load();
} catch (e) {
message.error(apiErrorMessage(e));
}
}
onMounted(load);
</script>
+46 -23
View File
@@ -1,32 +1,40 @@
<template> <template>
<div class="wrap"> <div class="wrap">
<form class="card form" @submit.prevent="submit"> <a-card class="login-card" :bordered="false">
<h1>ورود ادمین Montech</h1> <div class="head">
<p class="muted">موبایل و رمز حساب ادمین را وارد کنید.</p> <img src="/logo.svg" alt="" width="48" height="48" />
<div class="field"> <h1>ورود به پنل منتک</h1>
<label class="label">موبایل</label> <p>موبایل و رمز حساب ادمین را وارد کنید.</p>
<input v-model="mobile" class="input" dir="ltr" autocomplete="username" />
</div> </div>
<div class="field"> <a-form layout="vertical" :model="form" @finish="submit">
<label class="label">رمز</label> <a-form-item label="موبایل" name="mobile" :rules="[{ required: true, message: 'موبایل را وارد کنید.' }]">
<input v-model="password" type="password" class="input" dir="ltr" autocomplete="current-password" /> <a-input v-model:value="form.mobile" class="ltr" autocomplete="username" size="large">
</div> <template #prefix><MobileOutlined /></template>
<p v-if="error" class="err">{{ error }}</p> </a-input>
<button class="btn" :disabled="loading">{{ loading ? "..." : "ورود" }}</button> </a-form-item>
</form> <a-form-item label="رمز عبور" name="password" :rules="[{ required: true, message: 'رمز را وارد کنید.' }]">
<a-input-password v-model:value="form.password" class="ltr" autocomplete="current-password" size="large">
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-alert v-if="error" type="error" :message="error" show-icon style="margin-bottom: 16px" />
<a-button type="primary" html-type="submit" size="large" block :loading="loading">ورود</a-button>
</a-form>
</a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { LockOutlined, MobileOutlined } from "@ant-design/icons-vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { apiErrorMessage } from "../lib/api";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const mobile = ref("09121234567"); const form = reactive({ mobile: "09121234567", password: "" });
const password = ref("");
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
@@ -34,11 +42,11 @@ async function submit() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
await auth.login(mobile.value, password.value); await auth.login(form.mobile, form.password);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/"; const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
router.replace(redirect); router.replace(redirect);
} catch { } catch (e) {
error.value = "ورود ناموفق بود."; error.value = apiErrorMessage(e, "ورود ناموفق بود.");
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -50,9 +58,24 @@ async function submit() {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 16px;
background: radial-gradient(ellipse at top, #e6f2ff 0%, #f5f7fb 60%);
}
.login-card {
width: min(420px, 100%);
box-shadow: 0 20px 50px -20px rgba(0, 98, 245, 0.25);
}
.head {
text-align: center;
margin-bottom: 24px;
}
.head h1 {
margin: 12px 0 4px;
font-size: 20px;
font-weight: 800;
}
.head p {
margin: 0;
color: rgba(0, 0, 0, 0.45);
} }
.form { width: min(420px, 100%); }
h1 { margin: 0 0 0.4rem; font-size: 1.35rem; }
.err { color: var(--danger); }
</style> </style>
+88 -41
View File
@@ -1,59 +1,106 @@
<template> <template>
<div> <div>
<h1>رسانه</h1> <div class="page-header">
<form class="card" style="margin-bottom: 1rem" @submit.prevent="upload"> <div>
<input type="file" @change="onFile" /> <h1>رسانه</h1>
<button class="btn" :disabled="!file">آپلود</button> <p>فایل‌های آپلودشده؛ آدرس را کپی کنید و در هر فیلد تصویر استفاده کنید.</p>
<p v-if="msg" class="muted">{{ msg }}</p> </div>
</form> </div>
<table class="table">
<thead> <a-card style="margin-bottom: 16px">
<tr><th>فایل</th><th>URL</th><th>حجم</th><th></th></tr> <a-upload-dragger :show-upload-list="false" :custom-request="upload" :multiple="true">
</thead> <p class="ant-upload-drag-icon"><InboxOutlined /></p>
<tbody> <p class="ant-upload-text">فایل را اینجا رها کنید یا کلیک کنید</p>
<tr v-for="m in items" :key="m.id"> </a-upload-dragger>
<td>{{ m.filename }}</td> </a-card>
<td class="muted" dir="ltr">{{ m.url }}</td>
<td>{{ Math.round(m.size / 1024) }} KB</td> <a-card :body-style="{ padding: 0 }">
<td><button class="btn danger" @click="remove(m.id)">حذف</button></td> <a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="{ pageSize: 20 }" :scroll="{ x: 640 }">
</tr> <template #bodyCell="{ column, record }">
</tbody> <template v-if="column.key === 'preview'">
</table> <a-image v-if="isImage(record as MediaRow)" :src="record.url" :width="48" :height="48" class="thumb" />
<FileOutlined v-else style="font-size: 24px; color: #999" />
</template>
<template v-else-if="column.key === 'url'">
<a-typography-text :copyable="{ text: record.url }" class="ltr">{{ record.url }}</a-typography-text>
</template>
<template v-else-if="column.key === 'size'">{{ Math.round(record.size / 1024) }} KB</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm title="این فایل حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
<a-button size="small" danger>حذف</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import type { TableColumnsType } from "ant-design-vue";
import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
import { FileOutlined, InboxOutlined } from "@ant-design/icons-vue";
import { api, apiErrorMessage } from "../lib/api";
const items = ref<any[]>([]); interface MediaRow {
const file = ref<File | null>(null); id: string;
const msg = ref(""); filename: string;
url: string;
size: number;
mimeType?: string;
}
const items = ref<MediaRow[]>([]);
const loading = ref(true);
const columns: TableColumnsType<MediaRow> = [
{ title: "", key: "preview", width: 80 },
{ title: "فایل", key: "filename", dataIndex: "filename" },
{ title: "آدرس", key: "url" },
{ title: "حجم", key: "size", width: 100 },
{ title: "", key: "actions", width: 90 },
];
function isImage(m: MediaRow) {
return m.mimeType ? m.mimeType.startsWith("image/") : /\.(png|jpe?g|webp|gif|svg|avif)$/i.test(m.url);
}
async function load() { async function load() {
const { data } = await api.get("/admin/media"); loading.value = true;
items.value = data; try {
const { data } = await api.get<MediaRow[]>("/admin/media");
items.value = data;
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
} }
onMounted(load); onMounted(load);
function onFile(e: Event) { async function upload(options: UploadRequestOption) {
const input = e.target as HTMLInputElement; try {
file.value = input.files?.[0] || null; const fd = new FormData();
} fd.append("file", options.file as Blob);
const { data } = await api.post("/admin/media/upload", fd);
async function upload() { options.onSuccess?.(data);
if (!file.value) return; message.success("آپلود شد.");
const fd = new FormData(); await load();
fd.append("file", file.value); } catch (e) {
const { data } = await api.post("/admin/media/upload", fd); options.onError?.(e as Error);
msg.value = `آپلود شد: ${data.url}`; message.error(apiErrorMessage(e, "آپلود ناموفق بود."));
file.value = null; }
await load();
} }
async function remove(id: string) { async function remove(id: string) {
if (!confirm("حذف شود؟")) return; try {
await api.delete(`/admin/media/${id}`); await api.delete(`/admin/media/${id}`);
await load(); message.success("حذف شد.");
await load();
} catch (e) {
message.error(apiErrorMessage(e));
}
} }
</script> </script>
+103 -262
View File
@@ -1,290 +1,131 @@
<template> <template>
<div> <div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem"> <a-page-header :title="title" :sub-title="schema?.description" style="padding: 0 0 16px" @back="router.push('/sections')">
<h1>{{ titleFa }}</h1> <template #extra>
<RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink> <a-tag v-if="isDefault" color="default">محتوای پیش‌فرض</a-tag>
</div> <a-popconfirm
title="همه فیلدهای این بخش به متن اصلی طراحی برگردد؟"
<form v-if="ready" class="card" @submit.prevent="save"> ok-text="بازگردانی"
<!-- HERO --> cancel-text="انصراف"
<template v-if="key === 'hero'"> @confirm="resetToDefaults"
<div class="field"><label class="label">عنوان لودر ابتدای سایت</label><input v-model="form.loaderTitle" class="input" placeholder="هوش‌افزار منتک" /></div> >
<div class="field"><label class="label">برچسب بالای صفحه</label><input v-model="form.eyebrow" class="input" /></div> <a-button>
<div class="field"><label class="label">محل / سال تأسیس</label><input v-model="form.location" class="input" /></div> <template #icon><UndoOutlined /></template>
<div class="field"> بازگردانی پیش‌فرض
<label class="label">خطوط عنوان اصلی</label> </a-button>
<div v-for="(_line, i) in form.lines" :key="i" class="row" style="gap: 0.5rem; margin-bottom: 0.5rem"> </a-popconfirm>
<input v-model="form.lines[i]" class="input" :placeholder="`خط ${i + 1}`" /> <a-button type="primary" :loading="saving" :disabled="!form" @click="save">
<button type="button" class="btn danger" @click="form.lines.splice(i, 1)">حذف</button> <template #icon><SaveOutlined /></template>
</div> ذخیره
<button type="button" class="btn ghost" @click="form.lines.push('')">+ افزودن خط</button> </a-button>
</div>
<div class="field">
<label class="label">عبارت‌های رنگی (هر خط یک عبارت — رنگ volt و فونت تاکید)</label>
<textarea v-model="highlightWordsText" class="textarea" style="min-height: 90px" placeholder="هوش‌افزار منتک" />
<p class="muted" style="margin-top: 0.35rem">هر عبارتی که داخل خطوط عنوان باشد، با رنگ متفاوت نمایش داده می‌شود.</p>
</div>
<div class="field"><label class="label">توضیح کوتاه</label><textarea v-model="form.body" class="textarea" /></div>
<div class="field"><label class="label">متن دکمه</label><input v-model="form.cta" class="input" /></div>
<div class="field"><label class="label">مختصات / موقعیت</label><input v-model="form.coords" class="input" /></div>
<div class="field"><label class="label">برچسب پروژه‌ها</label><input v-model="form.projectsLabel" class="input" /></div>
</template> </template>
</a-page-header>
<!-- MARQUEE --> <a-result v-if="!schema" status="404" title="این بخش وجود ندارد." />
<template v-else-if="key === 'marquee'"> <a-spin v-else :spinning="loading">
<div class="field"> <a-card>
<label class="label">موارد نوار متحرک (هر خط یک مورد)</label> <a-form v-if="form" layout="vertical" @submit.prevent="save">
<textarea v-model="marqueeText" class="textarea" style="min-height: 200px" /> <SchemaFields :fields="schema.fields" :model="form" :show-errors="showErrors" />
</div> </a-form>
</template> </a-card>
</a-spin>
<!-- MANIFESTO -->
<template v-else-if="key === 'manifesto'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field">
<label class="label">جملهٔ اصلی (کلمات را با فاصله جدا کنید؛ برای برجسته کردن کلمه را داخل *بگذارید*)</label>
<textarea v-model="manifestoWordsText" class="textarea" />
<p class="muted" style="margin-top: 0.35rem">مثال: ما نرم‌افزاری می‌سازیم که *تفاوت* ایجاد کند. MONTECH</p>
</div>
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
</template>
<!-- SERVICES -->
<template v-else-if="key === 'services'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>خدمت {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
<div class="field"><label class="label">عنوان</label><input v-model="item.title" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="item.desc" class="textarea" /></div>
<div class="field"><label class="label">برچسب‌ها (با ویرگول)</label><input :value="(item.tags || []).join('، ')" class="input" @input="item.tags = splitTags(($event.target as HTMLInputElement).value)" /></div>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', desc: '', tags: [] })">+ افزودن خدمت</button>
</template>
<!-- WORK -->
<template v-else-if="key === 'work'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>نمونه کار {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
<div class="field"><label class="label">نام پروژه</label><input v-model="item.title" class="input" /></div>
<div class="field"><label class="label">دسته‌بندی</label><input v-model="item.cat" class="input" /></div>
<div class="field"><label class="label">سال</label><input v-model="item.year" class="input" /></div>
<ImageField v-model="item.imageUrl" label="تصویر پروژه" placeholder="/api/uploads/..." />
</div>
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', cat: '', year: '', imageUrl: '' })">+ افزودن پروژه</button>
</template>
<!-- PROCESS -->
<template v-else-if="key === 'process'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(step, i) in form.steps" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>مرحله {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.steps.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="step.n" class="input" /></div>
<div class="field"><label class="label">عنوان</label><input v-model="step.title" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="step.desc" class="textarea" /></div>
</div>
<button type="button" class="btn ghost" @click="form.steps.push({ n: String(form.steps.length + 1).padStart(2, '0'), title: '', desc: '' })">+ افزودن مرحله</button>
</template>
<!-- STATS -->
<template v-else-if="key === 'stats'">
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>آمار {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="grid-2">
<div class="field"><label class="label">عدد</label><input v-model.number="item.value" type="number" class="input" /></div>
<div class="field"><label class="label">پسوند (+ یا %)</label><input v-model="item.suffix" class="input" /></div>
</div>
<div class="field"><label class="label">برچسب</label><input v-model="item.label" class="input" /></div>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ value: 0, suffix: '+', label: '' })">+ افزودن آمار</button>
</template>
<!-- TEAM -->
<template v-else-if="key === 'team'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field"><label class="label">تیتر بزرگ</label><input v-model="form.headline" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
<div v-for="(m, i) in form.members" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>عضو {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.members.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">نام</label><input v-model="m.name" class="input" /></div>
<div class="field"><label class="label">سمت</label><input v-model="m.role" class="input" /></div>
<ImageField v-model="m.imageUrl" label="تصویر عضو" placeholder="/api/uploads/..." />
</div>
<button type="button" class="btn ghost" @click="form.members.push({ name: '', role: '', imageUrl: '' })">+ افزودن عضو</button>
</template>
<!-- FOOTER -->
<template v-else-if="key === 'footer'">
<p class="muted" style="margin-bottom: 1rem">
ایمیل، تلفن، آدرس و شبکه‌های اجتماعی از بخش «تنظیمات سایت» خوانده می‌شوند. اینجا فقط متن‌های نمایشی فوتر را ویرایش کنید.
</p>
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field"><label class="label">نوار متحرک پایین (هر خط یک مورد)</label><textarea v-model="footerMarqueeText" class="textarea" /></div>
<div class="field"><label class="label">متن دعوت به همکاری</label><input v-model="form.cta" class="input" /></div>
</template>
<template v-else>
<p class="muted">این بخش هنوز فرم اختصاصی ندارد. با پشتیبانی تماس بگیرید.</p>
</template>
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p>
<button class="btn" style="margin-top: 0.75rem">ذخیره</button>
</form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import ImageField from "../components/ImageField.vue"; import { SaveOutlined, UndoOutlined } from "@ant-design/icons-vue";
import { DEFAULT_SECTIONS, SECTION_KEYS, SECTION_TITLES_FA, resolveSection, type SectionKey } from "@montech/shared";
import { SECTION_SCHEMAS, type Field } from "../sections/schema";
import SchemaFields from "../components/SchemaFields.vue";
import { api, apiErrorMessage } from "../lib/api";
const TITLES: Record<string, string> = { type Model = Record<string, any>;
hero: "صفحه اول (هیرو)",
marquee: "نوار متحرک",
manifesto: "معرفی / مانیفست",
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const route = useRoute(); const route = useRoute();
const key = String(route.params.key); const router = useRouter();
const titleFa = computed(() => TITLES[key] || key); const rawKey = String(route.params.key);
const ready = ref(false); const key = (SECTION_KEYS as readonly string[]).includes(rawKey) ? (rawKey as SectionKey) : null;
const msg = ref(""); const schema = key ? SECTION_SCHEMAS[key] : null;
const form = reactive<any>({}); const title = computed(() => (key ? SECTION_TITLES_FA[key] : rawKey));
const marqueeText = ref(""); const form = ref<Model | null>(null);
const manifestoWordsText = ref(""); const isDefault = ref(false);
const footerMarqueeText = ref(""); const loading = ref(true);
const highlightWordsText = ref(""); const saving = ref(false);
const showErrors = ref(false);
function splitTags(v: string) { function clone<T>(v: T): T {
return v return JSON.parse(JSON.stringify(v));
.split(/[,،]/)
.map((s) => s.trim())
.filter(Boolean);
}
function wordsToText(words: Array<{ text: string; outline?: boolean; volt?: boolean }>) {
return (words || [])
.map((w) => (w.outline || w.volt ? `*${w.text}*` : w.text))
.join(" ");
}
function textToWords(text: string) {
return text
.trim()
.split(/\s+/)
.filter(Boolean)
.map((token) => {
const m = token.match(/^\*(.+)\*$/);
if (m) return { text: m[1], outline: false, volt: true };
return { text: token, outline: false, volt: false };
});
} }
onMounted(async () => { onMounted(async () => {
const { data } = await api.get(`/admin/sections/${key}`); if (!key) return;
Object.assign(form, structuredClone(data.data)); try {
const { data } = await api.get<{ data: Model; isDefault?: boolean }>(`/admin/sections/${key}`);
if (key === "hero") { form.value = clone(resolveSection(key, data.data as never)) as Model;
form.lines = Array.isArray(form.lines) ? [...form.lines] : [""]; isDefault.value = Boolean(data.isDefault);
if (!form.lines.length) form.lines.push(""); } catch (e) {
form.loaderTitle = form.loaderTitle || "هوش‌افزار منتک"; message.error(apiErrorMessage(e));
const words = Array.isArray(form.highlightWords) && form.highlightWords.length form.value = clone(DEFAULT_SECTIONS[key]) as unknown as Model;
? form.highlightWords } finally {
: form.highlightWord loading.value = false;
? [form.highlightWord]
: [];
highlightWordsText.value = words.join("\n");
} }
if (key === "marquee") marqueeText.value = (form.items || []).join("\n");
if (key === "manifesto") manifestoWordsText.value = wordsToText(form.words || []);
if (key === "services" || key === "work" || key === "stats") form.items = form.items || [];
if (key === "work") {
form.items = form.items.map((i: any) => ({ ...i, imageUrl: i.imageUrl || "" }));
}
if (key === "process") form.steps = form.steps || [];
if (key === "team") {
form.members = (form.members || []).map((m: any) => ({ ...m, imageUrl: m.imageUrl || "" }));
}
if (key === "footer") {
footerMarqueeText.value = (form.marquee || []).join("\n");
}
ready.value = true;
}); });
function buildPayload() { function hasMissing(fields: Field[], model: Model): boolean {
if (key === "marquee") return { items: marqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean) }; return fields.some((f) => {
if (key === "manifesto") { if ((f.kind === "text" || f.kind === "textarea") && f.required) {
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) }; const v = model[f.key];
return typeof v !== "string" || !v.trim();
}
if (f.kind === "group") return hasMissing(f.fields, model[f.key] ?? {});
if (f.kind === "list") return (model[f.key] ?? []).some((item: Model) => hasMissing(f.fields, item));
return false;
});
}
function stripEmptyStrings(fields: Field[], model: Model) {
for (const f of fields) {
if (f.kind === "strings" && !f.fixed && Array.isArray(model[f.key])) {
model[f.key] = model[f.key].map((s: string) => String(s ?? "").trim()).filter(Boolean);
} else if (f.kind === "group" && model[f.key]) {
stripEmptyStrings(f.fields, model[f.key]);
} else if (f.kind === "list" && Array.isArray(model[f.key])) {
model[f.key].forEach((item: Model) => stripEmptyStrings(f.fields, item));
}
} }
if (key === "hero") {
const words = highlightWordsText.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
return {
...form,
lines: (form.lines || []).map((s: string) => String(s || "").trim()).filter(Boolean),
highlightWords: words,
highlightWord: words[0] || form.highlightWord || "",
loaderTitle: form.loaderTitle || "هوش‌افزار منتک",
};
}
if (key === "footer") {
return {
tag: form.tag,
cta: form.cta,
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
email: form.email,
phone: form.phone,
address: form.address,
socials: form.socials,
};
}
return { ...form };
} }
async function save() { async function save() {
msg.value = ""; if (!key || !schema || !form.value) return;
if (hasMissing(schema.fields, form.value)) {
showErrors.value = true;
message.warning("فیلدهای الزامی را تکمیل کنید.");
return;
}
saving.value = true;
try { try {
await api.put(`/admin/sections/${key}`, { data: buildPayload() }); const payload = clone(form.value);
msg.value = "ذخیره شد."; stripEmptyStrings(schema.fields, payload);
} catch { await api.put(`/admin/sections/${key}`, { data: payload });
msg.value = "خطا در ذخیره."; form.value = payload;
isDefault.value = false;
showErrors.value = false;
message.success("ذخیره شد. تغییرات حداکثر تا ۳۰ ثانیه دیگر روی سایت دیده می‌شود.");
} catch (e) {
message.error(apiErrorMessage(e, "خطا در ذخیره."));
} finally {
saving.value = false;
} }
} }
</script>
<style scoped> function resetToDefaults() {
.item-box { if (!key) return;
border: 1px solid var(--border); form.value = clone(DEFAULT_SECTIONS[key]) as unknown as Model;
border-radius: 10px; showErrors.value = false;
padding: 0.9rem; message.info("متن پیش‌فرض جایگزین شد؛ برای اعمال، ذخیره کنید.");
margin-bottom: 0.9rem;
background: #12151b;
} }
</style> </script>
+62 -32
View File
@@ -1,44 +1,74 @@
<template> <template>
<div> <div>
<h1>محتوای لندینگ</h1> <div class="page-header">
<p class="muted">متن‌ها را مثل فرم معمولی ویرایش کنید — نیازی به کدنویسی نیست.</p> <div>
<table class="table"> <h1>محتوای لندینگ</h1>
<thead> <p>هر بخش صفحه اصلی را مثل یک فرم ساده ویرایش کنید.</p>
<tr><th>بخش</th><th>آخرین ویرایش</th><th></th></tr> </div>
</thead> <a-button href="/" target="_blank">
<tbody> <template #icon><GlobalOutlined /></template>
<tr v-for="s in sections" :key="s.id"> مشاهده لندینگ
<td>{{ label(s.key) }}</td> </a-button>
<td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td> </div>
<td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td>
</tr> <a-row :gutter="[16, 16]">
</tbody> <a-col v-for="s in rows" :key="s.key" :xs="24" :md="12" :xl="8">
</table> <a-card hoverable class="section-card" @click="router.push(`/sections/${s.key}`)">
<a-card-meta :title="SECTION_TITLES_FA[s.key]" :description="SECTION_SCHEMAS[s.key].description" />
<div class="meta">
<a-tag v-if="s.isDefault" color="default">محتوای پیش‌فرض</a-tag>
<a-tag v-else color="blue">سفارشی‌شده</a-tag>
<a-typography-text v-if="s.updatedAt && !s.isDefault" type="secondary">
{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}
</a-typography-text>
</div>
</a-card>
</a-col>
</a-row>
<a-skeleton v-if="loading" active />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../lib/api"; import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { GlobalOutlined } from "@ant-design/icons-vue";
import { SECTION_KEYS, SECTION_TITLES_FA, type SectionKey } from "@montech/shared";
import { SECTION_SCHEMAS } from "../sections/schema";
import { api, apiErrorMessage } from "../lib/api";
const TITLES: Record<string, string> = { interface SectionRow {
hero: "صفحه اول (هیرو)", key: SectionKey;
marquee: "نوار متحرک", updatedAt: string | null;
manifesto: "معرفی", isDefault: boolean;
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const sections = ref<any[]>([]);
function label(key: string) {
return TITLES[key] || key;
} }
const router = useRouter();
const rows = ref<SectionRow[]>([]);
const loading = ref(true);
onMounted(async () => { onMounted(async () => {
const { data } = await api.get("/admin/sections"); try {
sections.value = data; const { data } = await api.get<SectionRow[]>("/admin/sections");
const byKey = new Map(data.map((s) => [s.key, s]));
rows.value = SECTION_KEYS.map((key) => byKey.get(key) ?? { key, updatedAt: null, isDefault: true });
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
}); });
</script> </script>
<style scoped>
.section-card {
height: 100%;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
</style>
+89 -37
View File
@@ -1,52 +1,104 @@
<template> <template>
<div> <div>
<h1>سئو صفحات</h1> <div class="page-header">
<div v-for="page in pages" :key="page.id" class="card" style="margin-bottom: 1rem"> <div>
<h2 style="margin-top: 0">{{ page.title }} <span class="muted">({{ page.key }})</span></h2> <h1>سئو صفحات</h1>
<div class="grid-2"> <p>عنوان و توضیحی که موتورهای جستجو و شبکه‌های اجتماعی نمایش می‌دهند.</p>
<div class="field">
<label class="label">Title</label>
<input v-model="page.seo.title" class="input" />
</div>
<div class="field">
<label class="label">Canonical</label>
<input v-model="page.seo.canonical" class="input" dir="ltr" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label class="label">Description</label>
<textarea v-model="page.seo.description" class="textarea" />
</div>
<div class="field">
<label class="label">OG Image</label>
<input v-model="page.seo.ogImage" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label"><input v-model="page.seo.noIndex" type="checkbox" /> noindex</label>
</div>
</div> </div>
<button class="btn" @click="save(page)">ذخیره</button>
<span v-if="msg[page.key]" class="muted" style="margin-right: 0.75rem">{{ msg[page.key] }}</span>
</div> </div>
<a-spin :spinning="loading">
<a-collapse v-model:active-key="active" accordion>
<a-collapse-panel v-for="page in pages" :key="page.key">
<template #header>
<strong>{{ page.title }}</strong>
<a-typography-text type="secondary" class="ltr" style="margin-inline-start: 8px">{{ page.key }}</a-typography-text>
<a-tag v-if="page.seo.noIndex" color="orange" style="margin-inline-start: 8px">noindex</a-tag>
</template>
<a-form layout="vertical" :model="page.seo" @finish="save(page)">
<a-form-item label="عنوان (Title)" name="title" :rules="[{ required: true, message: 'الزامی است.' }]">
<a-input v-model:value="page.seo.title" show-count :maxlength="70" />
</a-form-item>
<a-form-item label="توضیح (Description)" name="description">
<a-textarea v-model:value="page.seo.description" show-count :maxlength="170" :auto-size="{ minRows: 2 }" />
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="Canonical" name="canonical">
<a-input v-model:value="page.seo.canonical" class="ltr" placeholder="https://montech.ir/..." />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تصویر اشتراک‌گذاری (OG)">
<ImageField v-model="page.seo.ogImage" />
</a-form-item>
</a-col>
</a-row>
<a-form-item name="noIndex">
<a-checkbox v-model:checked="page.seo.noIndex">عدم نمایش در موتورهای جستجو (noindex)</a-checkbox>
</a-form-item>
<a-button type="primary" html-type="submit" :loading="saving === page.key">ذخیره</a-button>
</a-form>
</a-collapse-panel>
</a-collapse>
</a-spin>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import { api, apiErrorMessage } from "../lib/api";
import ImageField from "../components/ImageField.vue";
const pages = ref<any[]>([]); interface SeoForm {
const msg = reactive<Record<string, string>>({}); title: string;
description: string;
canonical: string;
ogImage: string;
noIndex: boolean;
}
interface PageRow {
key: string;
title: string;
seo: SeoForm;
}
const pages = ref<PageRow[]>([]);
const active = ref<string>("home");
const loading = ref(true);
const saving = ref<string | null>(null);
onMounted(async () => { onMounted(async () => {
const { data } = await api.get("/admin/pages"); try {
pages.value = data.map((p: any) => ({ const { data } = await api.get<Array<{ key: string; title: string; seo: Partial<SeoForm> | null }>>("/admin/pages");
...p, pages.value = data.map((p) => ({
seo: p.seo || { title: p.title, description: "", canonical: "", ogImage: "", noIndex: false }, key: p.key,
})); title: p.title,
seo: {
title: p.seo?.title || p.title,
description: p.seo?.description || "",
canonical: p.seo?.canonical || "",
ogImage: p.seo?.ogImage || "",
noIndex: Boolean(p.seo?.noIndex),
},
}));
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
}); });
async function save(page: any) { async function save(page: PageRow) {
await api.put(`/admin/pages/${page.key}/seo`, page.seo); saving.value = page.key;
msg[page.key] = "ذخیره شد."; try {
await api.put(`/admin/pages/${page.key}/seo`, page.seo);
message.success("ذخیره شد.");
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
saving.value = null;
}
} }
</script> </script>
+132 -109
View File
@@ -1,137 +1,160 @@
<template> <template>
<div> <div>
<h1>تنظیمات سایت</h1> <div class="page-header">
<p class="muted">این مقادیر روی هدر، فوتر و کل سایت اعمال می‌شوند.</p> <div>
<form v-if="form" class="card" @submit.prevent="save"> <h1>تنظیمات سایت</h1>
<div class="grid-2"> <p>اطلاعات برند و راه‌های ارتباطی کل سایت.</p>
<div class="field">
<label class="label">نام برند</label>
<input v-model="form.brandName" class="input" />
</div>
<div class="field">
<label class="label">تگ‌لاین</label>
<input v-model="form.tagline" class="input" />
</div>
<div class="field">
<label class="label">ایمیل</label>
<input v-model="form.email" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">تلفن</label>
<input v-model="form.phone" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">آدرس</label>
<input v-model="form.address" class="input" />
</div>
<div class="field">
<label class="label">منطقه زمانی</label>
<select v-model="form.timezone" class="input" dir="ltr">
<option value="Asia/Tehran">Asia/Tehran (ایران)</option>
<option value="UTC">UTC</option>
</select>
</div>
</div> </div>
</div>
<ImageField <a-alert
v-model="form.logoUrl" type="info"
label="لوگو" show-icon
placeholder="/api/uploads/..." style="margin-bottom: 16px"
hint="لوگو در هدر سایت نمایش داده می‌شود. اگر خالی باشد نام برند نشان داده می‌شود." message="تلفن، ایمیل، آدرس و شبکه‌های اجتماعی مستقیماً در ستون «تماس با ما» و آیکون‌های فوتر لندینگ نمایش داده می‌شوند."
/> />
<h2 style="margin: 1.25rem 0 0.75rem; font-size: 1rem">شبکه‌های اجتماعی</h2> <a-spin :spinning="loading">
<div v-for="(s, i) in socials" :key="i" class="item-box"> <a-form layout="vertical" :model="form" @finish="save">
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem"> <a-card title="برند و تماس">
<strong>مورد {{ i + 1 }}</strong> <a-row :gutter="16">
<button type="button" class="btn danger" @click="socials.splice(i, 1)">حذف</button> <a-col :xs="24" :md="12">
</div> <a-form-item label="نام برند" name="brandName" :rules="[{ required: true, message: 'الزامی است.' }]">
<div class="grid-2"> <a-input v-model:value="form.brandName" />
<div class="field"> </a-form-item>
<label class="label">نام</label> </a-col>
<input v-model="s.label" class="input" /> <a-col :xs="24" :md="12">
<a-form-item label="تگ‌لاین" name="tagline">
<a-input v-model:value="form.tagline" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن" name="phone">
<a-input v-model:value="form.phone" placeholder="۰۹۱۲ ۳۴۵ ۶۷۸۹" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل" name="email" :rules="[{ type: 'email', message: 'ایمیل معتبر نیست.' }]">
<a-input v-model:value="form.email" class="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="آدرس" name="address">
<a-input v-model:value="form.address" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="منطقه زمانی" name="timezone">
<a-select v-model:value="form.timezone" :options="TIMEZONES" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="لوگو" extra="در صورت خالی بودن، نشان پیش‌فرض منتک نمایش داده می‌شود.">
<ImageField v-model="form.logoUrl" />
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-card title="شبکه‌های اجتماعی" style="margin-top: 16px">
<template #extra>
<a-typography-text type="secondary">آیکون بر اساس نام یا لینک (تلگرام، لینکدین، اینستاگرام) انتخاب می‌شود.</a-typography-text>
</template>
<div v-for="(s, i) in form.socials" :key="i" class="social-row">
<a-input v-model:value="s.label" placeholder="نام (مثلاً تلگرام)" />
<a-input v-model:value="s.url" class="ltr" placeholder="https://t.me/..." />
<a-button danger type="text" title="حذف" aria-label="حذف شبکه اجتماعی" @click="form.socials.splice(i, 1)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div> </div>
<div class="field"> <a-button type="dashed" block @click="form.socials.push({ label: '', url: '' })">
<label class="label">لینک</label> <template #icon><PlusOutlined /></template>
<input v-model="s.url" class="input" dir="ltr" placeholder="https://..." /> افزودن شبکه اجتماعی
</div> </a-button>
</div> </a-card>
</div>
<button type="button" class="btn ghost" @click="socials.push({ label: '', url: '' })">+ افزودن</button>
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p> <div class="actions">
<button class="btn" style="margin-top: 0.75rem">ذخیره</button> <a-button type="primary" html-type="submit" size="large" :loading="saving">ذخیره تنظیمات</a-button>
</form> </div>
</a-form>
</a-spin>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { api } from "../lib/api"; import { message } from "ant-design-vue";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import type { PublicSettings, SocialLink } from "@montech/shared";
import { api, apiErrorMessage } from "../lib/api";
import ImageField from "../components/ImageField.vue"; import ImageField from "../components/ImageField.vue";
const form = ref<{ const TIMEZONES = [
brandName: string; { value: "Asia/Tehran", label: "Asia/Tehran (ایران)" },
tagline: string; { value: "UTC", label: "UTC" },
email: string; ];
phone: string;
address: string;
timezone: string;
logoUrl: string;
} | null>(null);
const socials = reactive<Array<{ label: string; url: string }>>([]); const form = reactive({
const msg = ref(""); brandName: "",
tagline: "",
email: "",
phone: "",
address: "",
timezone: "Asia/Tehran",
logoUrl: "",
socials: [] as SocialLink[],
});
const loading = ref(true);
const saving = ref(false);
onMounted(async () => { onMounted(async () => {
const { data } = await api.get("/admin/settings"); try {
form.value = { const { data } = await api.get<PublicSettings>("/admin/settings");
brandName: data.brandName || "", Object.assign(form, {
tagline: data.tagline || "", brandName: data.brandName || "",
email: data.email || "", tagline: data.tagline || "",
phone: data.phone || "", email: data.email || "",
address: data.address || "", phone: data.phone || "",
timezone: data.timezone || "Asia/Tehran", address: data.address || "",
logoUrl: data.logoUrl || "", timezone: data.timezone || "Asia/Tehran",
}; logoUrl: data.logoUrl || "",
socials.splice(0, socials.length, ...(Array.isArray(data.socials) ? data.socials : [])); socials: Array.isArray(data.socials) ? data.socials.map((s) => ({ ...s })) : [],
if (!socials.length) socials.push({ label: "", url: "" }); });
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
loading.value = false;
}
}); });
async function save() { async function save() {
if (!form.value) return; saving.value = true;
msg.value = "";
try { try {
const payload = { const { data } = await api.put<PublicSettings>("/admin/settings", {
brandName: form.value.brandName, ...form,
tagline: form.value.tagline, logoUrl: form.logoUrl || null,
email: form.value.email, socials: form.socials.filter((s) => s.label.trim() || s.url.trim()),
phone: form.value.phone, });
address: form.value.address, form.logoUrl = data.logoUrl || "";
timezone: form.value.timezone, message.success("ذخیره شد. تغییرات حداکثر تا ۳۰ ثانیه دیگر روی سایت دیده می‌شود.");
logoUrl: form.value.logoUrl || null, } catch (e) {
socials: socials.filter((s) => s.label.trim() || s.url.trim()), message.error(apiErrorMessage(e, "خطا در ذخیره."));
}; } finally {
const { data } = await api.put("/admin/settings", payload); saving.value = false;
form.value.logoUrl = data.logoUrl || "";
msg.value = "ذخیره شد. برای دیدن در سایت صفحه را رفرش کنید.";
} catch (e: any) {
const detail = e?.response?.data?.message;
msg.value = Array.isArray(detail)
? `خطا: ${detail.join("، ")}`
: detail
? `خطا: ${detail}`
: "خطا در ذخیره.";
} }
} }
</script> </script>
<style scoped> <style scoped>
.item-box { .social-row {
border: 1px solid var(--border); display: grid;
border-radius: 10px; grid-template-columns: 1fr 2fr auto;
padding: 0.9rem; gap: 8px;
margin-bottom: 0.9rem; margin-bottom: 8px;
background: #12151b; }
.actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
} }
</style> </style>
+4 -1
View File
@@ -14,7 +14,10 @@
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"paths": { "@/*": ["./src/*"] } "paths": {
"@/*": ["./src/*"],
"@montech/shared": ["../../packages/shared/src/index.ts"]
}
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
} }
+14 -2
View File
@@ -1,12 +1,24 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
import path from "path"; import path from "path";
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
Components({
dts: "src/components.d.ts",
dirs: [],
resolvers: [AntDesignVueResolver({ importStyle: false, resolveIcons: true })],
}),
],
base: "/adm/", base: "/adm/",
resolve: { resolve: {
alias: { "@": path.resolve(__dirname, "src") }, alias: {
"@": path.resolve(__dirname, "src"),
"@montech/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
},
}, },
server: { server: {
port: 5174, port: 5174,
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"businessType" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'consult',
"aiQuery" TEXT,
"status" TEXT NOT NULL DEFAULT 'new',
"note" TEXT,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Lead_status_createdAt_idx" ON "Lead"("status", "createdAt");
-- Landing redesign: sections from the previous design are no longer rendered.
DELETE FROM "SectionContent" WHERE "key" IN ('marquee', 'manifesto', 'services', 'work', 'process', 'stats', 'team');
+16
View File
@@ -85,6 +85,22 @@ model GalleryItem {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Lead {
id String @id @default(cuid())
name String
phone String
businessType String
source String @default("consult")
aiQuery String?
status String @default("new")
note String?
ip String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
}
model Media { model Media {
id String @id @default(cuid()) id String @id @default(cuid())
filename String filename String
+62 -185
View File
@@ -1,133 +1,56 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import * as bcrypt from "bcryptjs"; import * as bcrypt from "bcryptjs";
import { DEFAULT_SECTIONS, LEGACY_SECTION_KEYS } from "@montech/shared";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const defaultSections: Record<string, unknown> = { const contactDefaults = {
hero: { brandName: "منتک",
eyebrow: "گروه توسعه نرم‌افزار", tagline: "هوش‌افزار منظر",
location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]", email: "info@montech.ir",
loaderTitle: "هوش‌افزار منتک", phone: "۰۹۱۲ ۳۴۵ ۶۷۸۹",
lines: [ address: "تهران، ایران",
"در هوش‌افزار منتک", socials: [
"بهترین نرم‌افزارها و خدمات", { label: "تلگرام", url: "#" },
"متناسب با کسب‌وکار شما پیشنهاد می‌شود", { label: "لینکدین", url: "#" },
], { label: "اینستاگرام", url: "#" },
highlightWord: "هوش‌افزار منتک", ],
highlightWords: ["هوش‌افزار منتک"],
body: "منتک یک تیم مهندسی ارشد است که پلتفرم‌های وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسب‌وکارهایی می‌سازد که نمی‌خواهند معمولی باشند.",
cta: "مشاهده نمونه‌کارها",
badge: "مهندسی منتک • از ۱۴۰۰ •",
coords: "تهران، ایران",
projectsLabel: "بیش از ۱۲۰ پروژه تحویل‌شده",
},
marquee: {
items: [
"پلتفرم وب",
"اپلیکیشن موبایل",
"ابرو DevOps",
"هوش مصنوعی",
"سیستم طراحی",
"معماری API",
],
},
manifesto: {
tag: "ما که هستیم",
words: [
{ text: "ما" },
{ text: "نرم‌افزاری" },
{ text: "می‌سازیم", volt: true },
{ text: "که" },
{ text: "تفاوت" },
{ text: "ایجاد" },
{ text: "کند." },
{ text: "منتک", volt: true },
],
body: "استراتژی، طراحی و مهندسی زیر یک سقف — با دقت وسواس‌گونه نسبت به کیفیت و عملکرد.",
},
services: {
tag: "چه می‌کنیم",
items: [
{
n: "۰۱",
title: "استراتژی محصول",
desc: "ایده‌های مبهم را به نقشه راه معتبر تبدیل می‌کنیم. اسپرینت کشف، پژوهش کاربر و نمونه‌سازی سریع قبل از نوشتن کد.",
tags: ["کشف محصول", "پژوهش کاربر", "نقشه راه", "MVP"],
},
{
n: "۰۲",
title: "مهندسی وب",
desc: "پلتفرم‌های وب سریع و مقیاس‌پذیر — از سایت‌های بازاریابی تا داشبوردهای پیچیده SaaS.",
tags: ["Nuxt", "NestJS", "Node.js", "React"],
},
{
n: "۰۳",
title: "اپلیکیشن موبایل",
desc: "تجربهٔ بومی‌گونه روی iOS و Android از یک کدبیس مشترک — یا کاملاً نیتیو وقتی لازم باشد.",
tags: ["React Native", "Flutter", "Swift", "Kotlin"],
},
{
n: "۰۴",
title: "ابرو DevOps",
desc: "زیرساختی که با رشد شما مقیاس می‌شود؛ استقرار خودکار، امنیت و مانیتورینگ.",
tags: ["AWS / GCP", "کوبرنتیز", "CI/CD", "Terraform"],
},
{
n: "۰۵",
title: "هوش مصنوعی و داده",
desc: "هوش مصنوعی کاربردی؛ اتصال LLM، اتوماسیون هوشمند و خطوط لوله داده.",
tags: ["LLM", "اتوماسیون", "مهندسی داده", "تحلیل"],
},
],
},
work: {
tag: "نمونه کارها",
items: [
{ n: "۰۱", title: "پالس‌بورد", cat: "پلتفرم تحلیلی فین‌تک", year: "۱۴۰۳" },
{ n: "۰۲", title: "ایرو‌لوپ", cat: "نرم‌افزار لجستیک", year: "۱۴۰۳" },
{ n: "۰۳", title: "مد‌سینک", cat: "پلتفرم هلث‌تک", year: "۱۴۰۲" },
{ n: "۰۴", title: "نوواپی", cat: "زیرساخت پرداخت", year: "۱۴۰۲" },
],
},
process: {
tag: "چگونه کار می‌کنیم",
steps: [
{ n: "۰۱", title: "کشف", desc: "کارگاه، پژوهش و هم‌راستایی روی نتایجی که واقعاً مهم‌اند." },
{ n: "۰۲", title: "طراحی", desc: "سیستم‌ها، جریان‌ها و رابط‌هایی که ساده و قابل‌اعتماد به نظر می‌رسند." },
{ n: "۰۳", title: "توسعه", desc: "مهندسان ارشد که نرم‌افزار آمادهٔ تولید تحویل می‌دهند." },
{ n: "۰۴", title: "استقرار و مقیاس", desc: "CI/CD، مانیتورینگ و زیرساختی که با شما رشد می‌کند." },
],
},
stats: {
items: [
{ value: 120, suffix: "+", label: "پروژه تحویل‌شده" },
{ value: 40, suffix: "+", label: "مشتری" },
{ value: 15, suffix: "", label: "مهندس و طراح" },
{ value: 98, suffix: "%", label: "ماندگاری مشتری" },
],
},
team: {
tag: "تیم",
headline: "با جمع ما آشنا شوید",
body: "تیمی جمع‌وجور از متخصصان محصول، طراحی و مهندسی.",
members: [
{ name: "علی رضایی", role: "مدیرعامل و هم‌بنیان‌گذار" },
{ name: "سارا محمدی", role: "مدیر فنی و هم‌بنیان‌گذار" },
{ name: "مریم حسینی", role: "سرپرست طراحی" },
{ name: "رضا کریمی", role: "مهندس ارشد" },
],
},
footer: {
tag: "تماس",
marquee: ["پروژه‌ای در ذهن دارید؟", "سلام کنید", "با هم کار کنیم"],
cta: "شروع همکاری",
email: "hello@montech.ir",
phone: "+98 21 0000 0000",
address: ["تهران", "ایران"],
socials: ["لینکدین", "گیت‌هاب", "اینستاگرام"],
},
}; };
const pages = [
{
key: "home",
title: "خانه",
seo: {
title: "منتک | هوش‌افزار منظر - پلتفرم راهکارهای نرم‌افزاری و هوش مصنوعی",
description:
"با کمک هوش مصنوعی منتک، بهترین نرم‌افزارها و خدمات متناسب با کسب‌وکار شما پیشنهاد می‌شود. نرم‌افزار اختصاصی، حسابداری، CRM، فروشگاه آنلاین و نرم‌افزار طلا و جواهر.",
canonical: "https://montech.ir/",
},
},
{
key: "blog",
title: "بلاگ",
seo: {
title: "بلاگ — منتک",
description: "یادداشت‌هایی درباره محصول، مهندسی و ساخت نرم‌افزار.",
canonical: "https://montech.ir/blog",
},
},
{
key: "gallery",
title: "گالری",
seo: {
title: "گالری — منتک",
description: "تصاویر منتخب از پروژه‌ها و فضای استودیو منتک.",
canonical: "https://montech.ir/gallery",
},
},
];
/** Set SEED_RESET_CONTENT=1 to overwrite existing settings/sections/SEO with the defaults. */
const resetContent = process.env.SEED_RESET_CONTENT === "1";
async function main() { async function main() {
const mobile = process.env.ADMIN_MOBILE || "09121234567"; const mobile = process.env.ADMIN_MOBILE || "09121234567";
const password = process.env.ADMIN_PASSWORD || "change-me-strong"; const password = process.env.ADMIN_PASSWORD || "change-me-strong";
@@ -142,57 +65,10 @@ async function main() {
await prisma.siteSettings.upsert({ await prisma.siteSettings.upsert({
where: { id: "default" }, where: { id: "default" },
create: { create: { id: "default", timezone: "Asia/Tehran", ...contactDefaults },
id: "default", update: resetContent ? contactDefaults : {},
brandName: "منتک",
tagline: "گروه توسعه نرم‌افزار",
email: "hello@montech.ir",
phone: "+98 21 0000 0000",
address: "تهران، ایران",
timezone: "Asia/Tehran",
socials: [
{ label: "لینکدین", url: "https://linkedin.com" },
{ label: "گیت‌هاب", url: "https://github.com" },
],
},
update: {
brandName: "منتک",
tagline: "گروه توسعه نرم‌افزار",
address: "تهران، ایران",
},
}); });
const pages = [
{
key: "home",
title: "خانه",
seo: {
title: "منتک — گروه توسعه نرم‌افزار",
description:
"منتک پلتفرم‌های وب، اپلیکیشن موبایل و زیرساخت ابری با عملکرد بالا می‌سازد. مستقر در تهران، فعال در سراسر ایران.",
canonical: "https://montech.ir/",
},
},
{
key: "blog",
title: "بلاگ",
seo: {
title: "بلاگ — منتک",
description: "یادداشت‌هایی درباره محصول، مهندسی و ساخت نرم‌افزار.",
canonical: "https://montech.ir/blog",
},
},
{
key: "gallery",
title: "گالری",
seo: {
title: "گالری — منتک",
description: "تصاویر منتخب از پروژه‌ها و فضای استودیو منتک.",
canonical: "https://montech.ir/gallery",
},
},
];
for (const p of pages) { for (const p of pages) {
const page = await prisma.page.upsert({ const page = await prisma.page.upsert({
where: { key: p.key }, where: { key: p.key },
@@ -202,15 +78,23 @@ async function main() {
await prisma.pageSeo.upsert({ await prisma.pageSeo.upsert({
where: { pageId: page.id }, where: { pageId: page.id },
create: { pageId: page.id, ...p.seo }, create: { pageId: page.id, ...p.seo },
update: { ...p.seo }, update: resetContent ? { ...p.seo } : {},
}); });
} }
for (const [key, data] of Object.entries(defaultSections)) { await prisma.sectionContent.deleteMany({ where: { key: { in: [...LEGACY_SECTION_KEYS] } } });
for (const [key, data] of Object.entries(DEFAULT_SECTIONS)) {
const json = {
...data,
...(key === "footer"
? { email: contactDefaults.email, phone: contactDefaults.phone, address: contactDefaults.address }
: {}),
} as object;
await prisma.sectionContent.upsert({ await prisma.sectionContent.upsert({
where: { key }, where: { key },
create: { key, data: data as object }, create: { key, data: json },
update: { data: data as object }, update: resetContent ? { data: json } : {},
}); });
} }
@@ -227,17 +111,10 @@ async function main() {
seoTitle: "به منتک خوش آمدید — بلاگ", seoTitle: "به منتک خوش آمدید — بلاگ",
seoDesc: "معرفی منتک و نحوه ساخت نرم‌افزار در تیم ما.", seoDesc: "معرفی منتک و نحوه ساخت نرم‌افزار در تیم ما.",
}, },
update: { update: {},
title: "به منتک خوش آمدید",
excerpt: "ما نرم‌افزاری می‌سازیم که واقعاً کار می‌کند — و اینجا درباره‌اش می‌نویسیم.",
content:
"منتک یک گروه توسعه نرم‌افزار است که پلتفرم وب، اپلیکیشن موبایل و زیرساخت ابری می‌سازد.\n\nدر این بلاگ درباره ساخت محصول، مهندسی و درس‌آموخته‌های استودیو می‌نویسیم.",
seoTitle: "به منتک خوش آمدید — بلاگ",
seoDesc: "معرفی منتک و نحوه ساخت نرم‌افزار در تیم ما.",
},
}); });
console.log("Seed complete. Admin:", mobile); console.log(`Seed complete (${resetContent ? "content reset" : "missing rows only"}). Admin:`, mobile);
} }
main() main()
+2
View File
@@ -8,6 +8,7 @@ import { BlogModule } from "./blog/blog.module";
import { GalleryModule } from "./gallery/gallery.module"; import { GalleryModule } from "./gallery/gallery.module";
import { MediaModule } from "./media/media.module"; import { MediaModule } from "./media/media.module";
import { SeoModule } from "./seo/seo.module"; import { SeoModule } from "./seo/seo.module";
import { LeadsModule } from "./leads/leads.module";
@Module({ @Module({
imports: [ imports: [
@@ -20,6 +21,7 @@ import { SeoModule } from "./seo/seo.module";
GalleryModule, GalleryModule,
MediaModule, MediaModule,
SeoModule, SeoModule,
LeadsModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+46 -33
View File
@@ -1,8 +1,12 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { resolveTimezone } from "@montech/shared"; import { DEFAULT_SECTIONS, SECTION_KEYS, resolveTimezone, type SectionKey } from "@montech/shared";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
function isSectionKey(key: string): key is SectionKey {
return (SECTION_KEYS as readonly string[]).includes(key);
}
@Injectable() @Injectable()
export class ContentService { export class ContentService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -31,58 +35,67 @@ export class ContentService {
}, },
}); });
// Keep footer section contact fields in sync with site settings // Keep the footer section's contact fields mirrored from site settings.
const footer = await this.prisma.sectionContent.findUnique({ where: { key: "footer" } }); const footer = await this.prisma.sectionContent.findUnique({ where: { key: "footer" } });
if (footer) { const prev = (footer?.data || DEFAULT_SECTIONS.footer) as Record<string, unknown>;
const prev = (footer.data || {}) as Record<string, unknown>; const nextData: Prisma.InputJsonValue = {
const addressLines = settings.address ...prev,
? settings.address.split(/\n|,/).map((s) => s.trim()).filter(Boolean) email: settings.email,
: Array.isArray(prev.address) phone: settings.phone,
? (prev.address as string[]) address: settings.address,
: []; } as Prisma.InputJsonObject;
const socials: string[] = Array.isArray(settings.socials) await this.prisma.sectionContent.upsert({
? (settings.socials as Array<{ label: string; url: string }>).map((s) => s.label).filter(Boolean) where: { key: "footer" },
: Array.isArray(prev.socials) create: { key: "footer", data: nextData },
? (prev.socials as string[]) update: { data: nextData },
: []; });
const nextData: Prisma.InputJsonValue = {
...prev,
email: settings.email,
phone: settings.phone,
address: addressLines,
socials,
};
await this.prisma.sectionContent.update({
where: { key: "footer" },
data: { data: nextData },
});
}
return settings; return settings;
} }
/** Every known section, falling back to built-in defaults for rows that do not exist yet. */
async listSections() { async listSections() {
return this.prisma.sectionContent.findMany({ orderBy: { key: "asc" } }); const rows = await this.prisma.sectionContent.findMany({
where: { key: { in: [...SECTION_KEYS] } },
});
const byKey = new Map(rows.map((r) => [r.key, r]));
return SECTION_KEYS.map((key) => {
const row = byKey.get(key);
return {
key,
data: row?.data ?? DEFAULT_SECTIONS[key],
updatedAt: row?.updatedAt ?? null,
isDefault: !row,
};
});
} }
async getSection(key: string) { async getSection(key: string) {
if (!isSectionKey(key)) throw new BadRequestException(`Unknown section ${key}`);
const row = await this.prisma.sectionContent.findUnique({ where: { key } }); const row = await this.prisma.sectionContent.findUnique({ where: { key } });
if (!row) throw new NotFoundException(`Section ${key} not found`); return {
return row; key,
data: row?.data ?? DEFAULT_SECTIONS[key],
updatedAt: row?.updatedAt ?? null,
isDefault: !row,
defaults: DEFAULT_SECTIONS[key],
};
} }
async upsertSection(key: string, data: object) { async upsertSection(key: string, data: object) {
if (!isSectionKey(key)) throw new BadRequestException(`Unknown section ${key}`);
const json = data as Prisma.InputJsonObject;
return this.prisma.sectionContent.upsert({ return this.prisma.sectionContent.upsert({
where: { key }, where: { key },
create: { key, data }, create: { key, data: json },
update: { data }, update: { data: json },
}); });
} }
async getPublicSite() { async getPublicSite() {
const [settingsRaw, sections, pages] = await Promise.all([ const [settingsRaw, sections, pages] = await Promise.all([
this.getSettings(), this.getSettings(),
this.listSections(), this.prisma.sectionContent.findMany({ where: { key: { in: [...SECTION_KEYS] } } }),
this.prisma.page.findMany({ include: { seo: true } }), this.prisma.page.findMany({ include: { seo: true } }),
]); ]);
const settings = { const settings = {
+40
View File
@@ -0,0 +1,40 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { CreateLeadDto, ListLeadsQuery, UpdateLeadDto } from "./leads.dto";
import { LeadsService } from "./leads.service";
function clientIp(req: Request) {
const forwarded = req.headers["x-forwarded-for"];
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0];
return (first || req.ip || "unknown").trim();
}
@Controller()
export class LeadsController {
constructor(private leads: LeadsService) {}
@Post("public/leads")
@HttpCode(201)
create(@Body() dto: CreateLeadDto, @Req() req: Request) {
return this.leads.create(dto, clientIp(req));
}
@UseGuards(JwtAuthGuard)
@Get("admin/leads")
list(@Query() query: ListLeadsQuery) {
return this.leads.list(query);
}
@UseGuards(JwtAuthGuard)
@Patch("admin/leads/:id")
update(@Param("id") id: string, @Body() dto: UpdateLeadDto) {
return this.leads.update(id, dto);
}
@UseGuards(JwtAuthGuard)
@Delete("admin/leads/:id")
remove(@Param("id") id: string) {
return this.leads.remove(id);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Transform, Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min, MinLength } from "class-validator";
import { LEAD_SOURCES, LEAD_STATUSES } from "@montech/shared";
const toAsciiDigits = (v: unknown) =>
typeof v === "string"
? v
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/[\s\-()]/g, "")
: v;
const trim = ({ value }: { value: unknown }) => (typeof value === "string" ? value.trim() : value);
export class CreateLeadDto {
@Transform(trim)
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@Transform(({ value }) => toAsciiDigits(value))
@IsString()
@Matches(/^\+?\d{7,15}$/, { message: "phone must be a valid phone number" })
phone!: string;
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(120)
businessType!: string;
@IsOptional()
@IsIn(LEAD_SOURCES)
source?: (typeof LEAD_SOURCES)[number];
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(500)
aiQuery?: string;
}
export class UpdateLeadDto {
@IsOptional()
@IsIn(LEAD_STATUSES)
status?: (typeof LEAD_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
export class ListLeadsQuery {
@IsOptional()
@IsIn(LEAD_STATUSES)
status?: (typeof LEAD_STATUSES)[number];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { LeadsController } from "./leads.controller";
import { LeadsService } from "./leads.service";
@Module({
controllers: [LeadsController],
providers: [LeadsService],
})
export class LeadsModule {}
+65
View File
@@ -0,0 +1,65 @@
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CreateLeadDto, ListLeadsQuery, UpdateLeadDto } from "./leads.dto";
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
@Injectable()
export class LeadsService {
private readonly hits = new Map<string, number[]>();
constructor(private prisma: PrismaService) {}
/** Simple in-memory per-IP limiter for the public form (single API instance). */
private assertRateLimit(ip: string) {
const now = Date.now();
const recent = (this.hits.get(ip) || []).filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_PER_WINDOW) {
throw new HttpException("Too many requests", HttpStatus.TOO_MANY_REQUESTS);
}
recent.push(now);
this.hits.set(ip, recent);
if (this.hits.size > 10_000) this.hits.clear();
}
create(dto: CreateLeadDto, ip: string) {
this.assertRateLimit(ip);
return this.prisma.lead.create({
data: {
name: dto.name,
phone: dto.phone,
businessType: dto.businessType,
source: dto.source ?? "consult",
aiQuery: dto.aiQuery || null,
ip,
},
select: { id: true, createdAt: true },
});
}
async list(query: ListLeadsQuery) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = query.status ? { status: query.status } : {};
const [items, total, newCount] = await Promise.all([
this.prisma.lead.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.lead.count({ where }),
this.prisma.lead.count({ where: { status: "new" } }),
]);
return { items, total, page, pageSize, newCount };
}
update(id: string, dto: UpdateLeadDto) {
return this.prisma.lead.update({ where: { id }, data: dto });
}
remove(id: string) {
return this.prisma.lead.delete({ where: { id } });
}
}
+2 -10
View File
@@ -1,13 +1,5 @@
<template> <template>
<div class="relative min-h-screen bg-ink text-bone selection:bg-volt selection:text-ink"> <NuxtLayout>
<ClientOnly>
<IntroLoader :title="loaderTitle" />
</ClientOnly>
<div class="noise" aria-hidden="true" />
<NuxtPage /> <NuxtPage />
</div> </NuxtLayout>
</template> </template>
<script setup lang="ts">
const loaderTitle = useState("intro-loader-title", () => "هوش‌افزار منتک");
</script>
+7 -65
View File
@@ -1,65 +1,7 @@
/* IRANSans (FaNum) — local webfonts; paths quoted for special characters */ /* Self-hosted Vazirmatn (arabic + latin subsets via unicode-range, font-display: swap). Only weights used by the UI. */
@font-face { @import "@fontsource/vazirmatn/400.css";
font-family: "IRANSans"; @import "@fontsource/vazirmatn/500.css";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"), @import "@fontsource/vazirmatn/600.css";
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff") format("woff"); @import "@fontsource/vazirmatn/700.css";
font-weight: 200; @import "@fontsource/vazirmatn/800.css";
font-style: normal; @import "@fontsource/vazirmatn/900.css";
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2") format("woff2"),
url("../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("../fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum).woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
url("../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("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
url("../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("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"),
url("../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("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
url("../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("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
font-weight: 900;
font-style: normal;
font-display: swap;
}
+216 -92
View File
@@ -1,107 +1,231 @@
@import "./fonts.css"; /*
@import "tailwindcss"; * Design-specific global styles. They are declared before the Tailwind layers on purpose:
* utilities must keep winning equal-specificity conflicts (e.g. `.fade-item` + `transition-all`).
*/
@theme { /* Lenis */
--color-ink: #0a0a0b; html.lenis,
--color-coal: #121215; html.lenis body {
--color-smoke: #1b1b1f; height: auto;
--color-bone: #f2f2ee; }
--color-ash: #8b8b93; .lenis.lenis-smooth {
--color-volt: #c8ff2e; scroll-behavior: auto !important;
}
--font-display: "IRANSans", ui-sans-serif, system-ui, sans-serif; .lenis.lenis-smooth [data-lenis-prevent] {
--font-body: "IRANSans", ui-sans-serif, system-ui, sans-serif; overscroll-behavior: contain;
--font-serif: "IRANSans", ui-serif, Georgia, serif; }
--font-mono: "IRANSans", ui-monospace, monospace; .lenis.lenis-stopped {
overflow: hidden;
} }
html { background: var(--color-ink); }
body { body {
@apply bg-ink text-bone font-body antialiased; font-family: "Vazirmatn", sans-serif;
overflow-x: clip; background-color: #fafcff;
font-family: "IRANSans", ui-sans-serif, system-ui, sans-serif; color: #0f172a;
} overflow-x: hidden;
::selection { text-rendering: optimizeLegibility;
background: var(--color-volt); -webkit-font-smoothing: antialiased;
color: var(--color-ink); transition: background-color 0.3s ease, color 0.3s ease;
} }
html.lenis, html.lenis body { height: auto; } .hero-mesh {
.lenis.lenis-smooth { scroll-behavior: auto !important; } background-image:
radial-gradient(circle at 15% 15%, rgba(0, 98, 245, 0.06) 0%, transparent 45%),
radial-gradient(circle at 85% 25%, rgba(56, 178, 248, 0.07) 0%, transparent 40%),
radial-gradient(circle at 50% 80%, rgba(2, 132, 199, 0.05) 0%, transparent 60%);
}
html.dark .hero-mesh {
background-image:
radial-gradient(circle at 15% 15%, rgba(0, 98, 245, 0.18) 0%, transparent 50%),
radial-gradient(circle at 85% 25%, rgba(56, 178, 248, 0.12) 0%, transparent 45%),
radial-gradient(circle at 50% 80%, rgba(2, 132, 199, 0.09) 0%, transparent 65%);
}
.noise { .perspective-stage {
position: fixed; perspective: 1800px;
inset: -50%; perspective-origin: 50% 45%;
width: 200%; }
height: 200%;
.studio-botanical-leaf {
position: absolute;
top: -45px;
right: 25px;
width: 280px;
height: 300px;
pointer-events: none; pointer-events: none;
z-index: 999; z-index: 5;
opacity: 0.032; filter: blur(12px) saturate(1.15);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); opacity: 0.42;
animation: grain 8s steps(10) infinite; transform: rotate(22deg) scale(1.08);
} transform-origin: top right;
@keyframes grain {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-5%, -8%); }
20% { transform: translate(-12%, 4%); }
30% { transform: translate(6%, -14%); }
40% { transform: translate(-4%, 12%); }
50% { transform: translate(-12%, 7%); }
60% { transform: translate(11%, 0%); }
70% { transform: translate(0%, 10%); }
80% { transform: translate(3%, 13%); }
90% { transform: translate(-8%, 8%); }
} }
@keyframes marquee { .mockup-macbook-wrap {
from { transform: translate3d(0, 0, 0); } transform-style: preserve-3d;
to { transform: translate3d(-50%, 0, 0); } will-change: transform;
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s ease;
filter: drop-shadow(0 35px 55px rgba(15, 23, 42, 0.26)) drop-shadow(0 15px 25px rgba(0, 98, 245, 0.12));
} }
.animate-marquee {
animation: marquee var(--marquee-duration, 28s) linear infinite; .macbook-screen-chassis {
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border: 1.5px solid rgba(255, 255, 255, 0.2);
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.95),
inset 0 1px 2px rgba(255, 255, 255, 0.35),
0 1px 0 rgba(255, 255, 255, 0.1);
} }
.macbook-deck-base {
background: linear-gradient(180deg, #94a3b8 0%, #64748b 35%, #334155 70%, #1e293b 100%);
border-top: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 45px -5px rgba(15, 23, 42, 0.45),
0 6px 14px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.mockup-iphone-wrap {
transform-style: preserve-3d;
will-change: transform;
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s ease;
filter: drop-shadow(0 32px 50px rgba(15, 23, 42, 0.42)) drop-shadow(0 14px 24px rgba(0, 98, 245, 0.2));
}
.iphone-titanium-bezel {
background: linear-gradient(135deg, #475569 0%, #1e293b 35%, #0f172a 75%, #334155 100%);
border: 2.5px solid #475569;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 0 0 2px rgba(15, 23, 42, 0.9);
position: relative;
}
.iphone-titanium-bezel::before {
content: "";
position: absolute;
left: -5.5px;
top: 95px;
width: 3.5px;
height: 48px;
background: #475569;
border-radius: 2px 0 0 2px;
box-shadow: -1px 0 2px rgba(0, 0, 0, 0.4);
}
.iphone-titanium-bezel::after {
content: "";
position: absolute;
right: -5.5px;
top: 85px;
width: 3.5px;
height: 65px;
background: #475569;
border-radius: 0 2px 2px 0;
box-shadow: 1px 0 2px rgba(0, 0, 0, 0.4);
}
.mockup-glass-sheen {
position: absolute;
inset: 0;
background: linear-gradient(132deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.05) 30%, transparent 60%);
pointer-events: none;
z-index: 30;
}
.gold-bar-realistic {
background: linear-gradient(135deg, #fffbeb 0%, #fef08a 22%, #f59e0b 55%, #b45309 85%, #78350f 100%);
box-shadow:
inset 0 1.5px 3px rgba(255, 255, 255, 0.95),
inset 0 -2px 4px rgba(0, 0, 0, 0.45),
0 8px 16px -3px rgba(217, 119, 6, 0.4);
}
.device-ground-shadow {
position: absolute;
bottom: -15px;
left: 6%;
right: 6%;
height: 38px;
background: radial-gradient(ellipse at center, rgba(15, 23, 42, 0.35) 0%, rgba(15, 23, 42, 0.12) 50%, transparent 80%);
filter: blur(9px);
pointer-events: none;
z-index: 1;
}
.spotlight-card {
position: relative;
overflow: hidden;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease, border-color 0.3s ease;
will-change: transform;
}
.spotlight-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(300px circle at var(--mouse-x, -200px) var(--mouse-y, -200px), rgba(0, 98, 245, 0.12), transparent 70%);
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
.spotlight-card:hover::before {
opacity: 1;
}
.scroll-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: linear-gradient(90deg, #38b2f8, #0062f5, #0050d8);
z-index: 100;
transform-origin: 0% 50%;
transform: scaleX(0);
}
.fade-item {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.75s cubic-bezier(0.16, 1, 0.3, 1), transform 0.75s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
.fade-item.revealed {
opacity: 1;
transform: translateY(0);
}
::-webkit-scrollbar {
width: 7px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
html.dark ::-webkit-scrollbar-track {
background: #0b1329;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
html.dark ::-webkit-scrollbar-thumb {
background: #334155;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-marquee { animation: none; } .fade-item {
opacity: 1;
transform: none;
transition: none;
}
} }
@keyframes blink { @tailwind base;
0%, 100% { opacity: 1; } @tailwind components;
50% { opacity: 0.15; } @tailwind utilities;
}
.animate-blink { animation: blink 1.4s ease-in-out infinite; }
@keyframes spin-slow {
to { transform: rotate(360deg); }
}
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
@keyframes fade-up {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-up {
animation: fade-up 1s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes scroll-line {
0% { transform: scaleY(0); transform-origin: top; opacity: 0; }
40% { transform: scaleY(1); transform-origin: top; opacity: 1; }
60% { transform: scaleY(1); transform-origin: bottom; opacity: 1; }
100% { transform: scaleY(0); transform-origin: bottom; opacity: 0; }
}
.animate-scroll-line {
animation: scroll-line 1.8s ease-in-out infinite;
}
.text-outline {
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
color: transparent;
}
/* Persian accent — volt + lighter weight (Instrument Serif has no Arabic glyphs) */
.font-accent {
font-family: "IRANSans", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
font-style: normal;
letter-spacing: -0.01em;
}
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
<template>
<section id="top" class="relative flex min-h-svh flex-col overflow-hidden">
<ClientOnly>
<ParticleField />
</ClientOnly>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.08),transparent_55%)]" />
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-ink/40 via-transparent to-ink" />
<div class="relative flex flex-1 flex-col justify-end px-6 pb-12 pt-36 md:px-10 md:pb-16 md:pt-44">
<div
class="mb-10 flex items-center justify-between font-mono text-[12px] tracking-[0.14em] text-ash animate-fade-up md:mb-16 md:text-[13px] md:tracking-[0.22em]"
style="animation-delay: 0.2s"
>
<span class="flex items-center gap-3">
<span class="h-1.5 w-1.5 animate-blink rounded-full bg-volt" />
{{ data.eyebrow }}
</span>
<span class="hidden md:block">{{ data.location }}</span>
</div>
<h1 class="w-full font-display text-[clamp(2.2rem,8.5vw,7.5rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">
<div
v-for="(line, i) in data.lines"
:key="i"
class="overflow-hidden pb-[0.06em] -mb-[0.06em] animate-fade-up"
:style="{ animationDelay: `${0.15 + i * 0.12}s` }"
>
<div class="flex flex-wrap items-baseline gap-x-[0.28em]">
<span
v-for="(token, ti) in tokenize(line)"
:key="ti"
:class="token.volt ? 'font-accent text-volt' : undefined"
>{{ token.text }}</span>
</div>
</div>
</h1>
<div
class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 font-mono text-[11px] tracking-[0.14em] text-ash animate-fade-up md:mt-20 md:text-xs md:tracking-[0.22em]"
style="animation-delay: 0.8s"
>
<span class="flex items-center gap-2">
<span class="inline-block h-8 w-px animate-scroll-line bg-volt/80" />
اسکرول
</span>
<span class="hidden sm:block">{{ data.coords }}</span>
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
eyebrow: string;
location: string;
lines: string[];
highlightWord?: string;
highlightWords?: string[];
coords: string;
projectsLabel: string;
};
}>();
type Token = { text: string; volt?: boolean };
function highlights() {
return [
...(props.data.highlightWords || []),
props.data.highlightWord || "",
]
.map((h) => h.trim())
.filter(Boolean)
.filter((h, i, arr) => arr.indexOf(h) === i)
.sort((a, b) => b.length - a.length);
}
function tokenize(line: string): Token[] {
const hs = highlights();
const tokens: Token[] = [];
let rest = line.trim();
while (rest.length) {
let matched = false;
for (const h of hs) {
if (rest === h || rest.startsWith(`${h} `) || rest.startsWith(h)) {
tokens.push({ text: h, volt: true });
rest = rest.slice(h.length).trimStart();
matched = true;
break;
}
}
if (matched) continue;
const m = rest.match(/^(\S+)(\s*)/);
if (!m) break;
tokens.push({ text: m[1] });
rest = rest.slice(m[0].length);
}
return tokens;
}
</script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<Transition name="intro-fade">
<div
v-if="visible"
class="fixed inset-0 z-[1000] flex flex-col items-center justify-center overflow-hidden bg-ink text-bone"
aria-live="polite"
aria-busy="true"
>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.12),transparent_55%)]" />
<p
class="relative px-6 text-center font-display font-extrabold leading-[0.95] tracking-[-0.04em]"
:style="{ fontSize: 'clamp(2.6rem, 13vw, 9rem)' }"
>
<span class="block animate-fade-up">{{ brandParts.first }}</span>
<span
v-if="brandParts.second"
class="mt-2 block font-accent tracking-normal text-volt animate-fade-up md:mt-3"
style="animation-delay: 0.12s"
>{{ brandParts.second }}</span>
</p>
<div class="relative mt-16 flex w-[min(300px,72vw)] flex-col items-center gap-3 animate-fade-up" style="animation-delay: 0.25s">
<div class="h-px w-full overflow-hidden bg-white/10">
<div class="h-full bg-volt transition-[width] duration-75 ease-linear" :style="{ width: `${progress}%` }" />
</div>
<span class="font-mono text-xs tracking-[0.4em] text-ash tabular-nums" dir="ltr">
{{ String(Math.floor(progress)).padStart(2, "0") }}%
</span>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ title?: string }>(),
{ title: "هوش‌افزار منتک" },
);
const visible = ref(false);
const progress = ref(0);
const brandParts = computed(() => {
const raw = (props.title || "هوش‌افزار منتک").trim();
const parts = raw.split(/\s+/);
if (parts.length <= 1) return { first: raw, second: "" };
return {
first: parts.slice(0, -1).join(" "),
second: parts.at(-1) || "",
};
});
onMounted(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) return;
visible.value = true;
document.documentElement.style.overflow = "hidden";
const start = performance.now();
const duration = 2400;
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
progress.value = Math.min(100, eased * 100);
if (t < 1) {
raf = requestAnimationFrame(tick);
return;
}
progress.value = 100;
window.setTimeout(() => {
visible.value = false;
document.documentElement.style.overflow = "";
}, 320);
};
raf = requestAnimationFrame(tick);
onUnmounted(() => {
cancelAnimationFrame(raf);
document.documentElement.style.overflow = "";
});
});
</script>
<style scoped>
.intro-fade-leave-active {
transition: opacity 0.75s cubic-bezier(0.22, 1, 0.36, 1), transform 0.75s cubic-bezier(0.22, 1, 0.36, 1);
}
.intro-fade-leave-to {
opacity: 0;
transform: scale(1.06);
}
</style>
@@ -1,37 +0,0 @@
<template>
<section id="manifesto" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۱" :title="data.tag" right="نسخه کوتاه" />
<div class="mt-14 flex flex-col gap-10 md:max-w-2xl animate-fade-up">
<p class="text-lg leading-relaxed text-ash md:text-xl md:leading-relaxed">
{{ intro?.body || data.body }}
</p>
<a
v-if="intro?.cta"
href="#work"
class="group inline-flex w-fit items-center gap-4 font-mono text-xs tracking-[0.16em] text-bone transition-colors hover:text-volt md:text-[13px] md:tracking-[0.22em]"
>
{{ intro.cta }}
<span class="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 transition-all group-hover:border-volt group-hover:bg-volt group-hover:text-ink">
↓
</span>
</a>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
words?: Array<{ text: string; outline?: boolean; volt?: boolean }>;
body: string;
};
intro?: {
body?: string;
cta?: string;
} | null;
}>();
</script>
-162
View File
@@ -1,162 +0,0 @@
<template>
<canvas
ref="el"
class="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
/>
</template>
<script setup lang="ts">
type Particle = { x: number; y: number; vx: number; vy: number; r: number; a: number };
const el = ref<HTMLCanvasElement | null>(null);
onMounted(() => {
const canvas = el.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let particles: Particle[] = [];
let raf = 0;
let w = 0;
let h = 0;
let dpr = 1;
const mouse = { x: -9999, y: -9999, tx: -9999, ty: -9999 };
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const resize = () => {
const parent = canvas.parentElement;
if (!parent) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = parent.clientWidth;
h = parent.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(110, Math.max(40, Math.floor((w * h) / 12000)));
particles = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.45,
vy: (Math.random() - 0.5) * 0.45,
r: Math.random() * 1.8 + 0.5,
a: Math.random() * 0.5 + 0.18,
}));
};
const onMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.tx = e.clientX - rect.left;
mouse.ty = e.clientY - rect.top;
};
const onLeave = () => {
mouse.tx = -9999;
mouse.ty = -9999;
};
const draw = () => {
ctx.clearRect(0, 0, w, h);
// smooth mouse follow
if (mouse.tx > -9000) {
mouse.x += (mouse.tx - mouse.x) * 0.12;
mouse.y += (mouse.ty - mouse.y) * 0.12;
} else {
mouse.x += (-9999 - mouse.x) * 0.05;
mouse.y += (-9999 - mouse.y) * 0.05;
}
const hasMouse = mouse.x > -5000;
for (const p of particles) {
if (!reduced && hasMouse) {
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.hypot(dx, dy) || 1;
if (dist < 220) {
const force = (1 - dist / 220) * 0.065;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
// soft orbital swirl near cursor
if (dist < 160) {
p.vx += (-dy / dist) * 0.02;
p.vy += (dx / dist) * 0.02;
}
}
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
// keep a tiny base drift so field never freezes
if (Math.abs(p.vx) < 0.05) p.vx += (Math.random() - 0.5) * 0.04;
if (Math.abs(p.vy) < 0.05) p.vy += (Math.random() - 0.5) * 0.04;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200, 255, 46, ${p.a})`;
ctx.fill();
}
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i];
const b = particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist > 120) continue;
let alpha = (1 - dist / 120) * 0.2;
if (hasMouse) {
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const toMouse = Math.hypot(midX - mouse.x, midY - mouse.y);
if (toMouse < 180) alpha += (1 - toMouse / 180) * 0.25;
}
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(200, 255, 46, ${Math.min(alpha, 0.55)})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
if (hasMouse) {
const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, 140);
g.addColorStop(0, "rgba(200, 255, 46, 0.12)");
g.addColorStop(1, "rgba(200, 255, 46, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 140, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
resize();
draw();
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onMove, { passive: true });
document.addEventListener("pointerleave", onLeave);
onUnmounted(() => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerleave", onLeave);
});
});
</script>
@@ -1,23 +0,0 @@
<template>
<section id="process" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۴" :title="data.tag" right="معمولاً ۱۲ هفته" />
<div class="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div v-for="step in data.steps" :key="step.n" class="border-t border-white/[0.1] pt-6">
<p class="font-mono text-[11px] tracking-[0.2em] text-volt">{{ step.n }}</p>
<h3 class="mt-4 font-display text-xl font-bold tracking-[-0.02em] md:text-2xl">{{ step.title }}</h3>
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
steps: Array<{ n: string; title: string; desc: string }>;
};
}>();
</script>
-13
View File
@@ -1,13 +0,0 @@
<template>
<div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 font-mono text-[11px] tracking-[0.14em] text-ash md:tracking-[0.22em]">
<div class="flex items-center gap-3">
<span class="text-volt">{{ index }}</span>
<span class="text-bone">{{ title }}</span>
</div>
<span v-if="right" class="hidden sm:block">{{ right }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ index: string; title: string; right?: string }>();
</script>
@@ -1,45 +0,0 @@
<template>
<section id="services" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۲" :title="data.tag" :right="`${String(data.items.length).padStart(2, '0')} قابلیت`" />
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
<li
v-for="(s, i) in data.items"
:key="s.n"
class="group cursor-pointer py-8 md:py-10"
@click="open = open === i ? -1 : i"
>
<div class="flex items-start justify-between gap-6">
<div class="flex flex-1 flex-col gap-3 md:flex-row md:items-baseline md:gap-10">
<span class="font-mono text-[11px] tracking-[0.2em] text-ash">{{ s.n }}</span>
<h3 class="font-display text-2xl font-bold tracking-[-0.02em] transition-colors md:text-4xl group-hover:text-volt">
{{ s.title }}
</h3>
</div>
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span>
</div>
<div v-if="open === i" class="mt-6 max-w-3xl pe-0 md:pe-16 animate-fade-up">
<p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.12em] text-ash"
>{{ t }}</span>
</div>
</div>
</li>
</ul>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
items: Array<{ n: string; title: string; desc: string; tags: string[] }>;
};
}>();
const open = ref(0);
</script>
-113
View File
@@ -1,113 +0,0 @@
<template>
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
<div class="border-b border-white/[0.07] py-8 md:py-10">
<SiteMarquee
:items="data.marquee || []"
:duration="20"
class-name="font-display text-xl font-bold tracking-[-0.01em] md:text-3xl"
/>
</div>
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
<SectionTag index="۰۶" :title="data.tag || 'تماس'" right="پاسخ تا ۲۴ ساعت" />
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
<div>
<h2 class="font-display text-[clamp(2.4rem,6.5vw,5.5rem)] font-extrabold leading-[1.08] tracking-[-0.03em]">
{{ data.cta || "شروع همکاری" }}
</h2>
<a
v-if="email"
:href="`mailto:${email}`"
class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 font-mono text-[11px] tracking-[0.16em] text-ink transition hover:bg-transparent hover:text-volt"
dir="ltr"
>
{{ email }} ↗
</a>
</div>
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">کسب‌وکار جدید</p>
<p v-if="email" class="mt-2 text-sm" dir="ltr">{{ email }}</p>
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
</div>
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">استودیو</p>
<p v-for="line in addressLines" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
</div>
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">شبکه‌های اجتماعی</p>
<template v-if="socialLinks.length">
<a
v-for="s in socialLinks"
:key="s.label + s.url"
:href="s.url || undefined"
class="mt-1 block text-sm text-ash hover:text-volt"
:target="s.url ? '_blank' : undefined"
rel="noopener"
>{{ s.label }}</a>
</template>
<p v-else-if="legacySocials.length" v-for="s in legacySocials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
</div>
</div>
</div>
<div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.16em] text-ash">
<span>© {{ year }} گروه نرم‌افزاری {{ brand || "منتک" }}</span>
<span>{{ brand || "منتک" }}®</span>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const props = defineProps<{
brand?: string;
email?: string;
phone?: string;
address?: string;
socials?: Array<{ label: string; url: string }>;
data: {
tag?: string;
marquee?: string[];
cta?: string;
email?: string;
phone?: string;
address?: string[] | string;
socials?: string[] | Array<{ label: string; url: string }>;
};
}>();
const year = new Date().getFullYear();
const email = computed(() => props.email || props.data.email || "");
const phone = computed(() => props.phone || props.data.phone || "");
const addressLines = computed(() => {
if (props.address) {
return props.address
.split(/\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
const a = props.data.address;
if (Array.isArray(a)) return a;
if (typeof a === "string") return a.split(/\n|,/).map((s) => s.trim()).filter(Boolean);
return [];
});
const socialLinks = computed(() => {
if (props.socials?.length) return props.socials.filter((s) => s.label);
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [];
if (typeof raw[0] === "object" && raw[0] !== null) {
return (raw as Array<{ label: string; url: string }>).filter((s) => s.label);
}
return [];
});
const legacySocials = computed(() => {
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [] as string[];
if (typeof raw[0] === "string") return raw as string[];
return [];
});
</script>
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="overflow-hidden" :class="className" dir="ltr">
<div
class="animate-marquee flex w-max will-change-transform"
:style="{ '--marquee-duration': `${duration}s` } as any"
>
<div v-for="dup in 2" :key="dup" class="flex shrink-0 items-center" :aria-hidden="dup > 1">
<span v-for="(item, i) in items" :key="`${dup}-${i}`" class="flex items-center">
<span class="mx-5 whitespace-nowrap md:mx-8" :class="itemClassName">{{ item }}</span>
<span class="text-current/40" :class="separatorClassName">{{ separator }}</span>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
items: string[];
duration?: number;
className?: string;
itemClassName?: string;
separatorClassName?: string;
separator?: string;
}>(),
{
duration: 28,
className: "",
itemClassName: "",
separatorClassName: "",
separator: "✦",
},
);
</script>
-54
View File
@@ -1,54 +0,0 @@
<template>
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-ink/70 backdrop-blur-md">
<div class="mx-auto flex max-w-[1500px] items-center justify-between px-6 py-4 md:px-10">
<NuxtLink to="/" class="flex items-center gap-3 font-display text-lg font-extrabold tracking-tight">
<img
v-if="logoSrc"
:src="logoSrc"
:alt="brand"
class="h-9 w-auto max-w-[140px] object-contain"
/>
<template v-else>
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
</template>
</NuxtLink>
<nav class="hidden items-center gap-7 font-mono text-[11px] tracking-[0.16em] text-ash md:flex">
<a href="/#services" class="transition-colors hover:text-volt">خدمات</a>
<a href="/#work" class="transition-colors hover:text-volt">نمونه‌کارها</a>
<a href="/#team" class="transition-colors hover:text-volt">تیم</a>
<NuxtLink to="/blog" class="transition-colors hover:text-volt">بلاگ</NuxtLink>
<NuxtLink to="/gallery" class="transition-colors hover:text-volt">گالری</NuxtLink>
<a href="/#contact" class="text-bone transition-colors hover:text-volt">تماس</a>
</nav>
<span class="font-mono text-[12px] tracking-[0.14em] text-ash tabular-nums md:text-[13px]" dir="ltr">{{ time }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { formatClock } from "~/utils/timezone";
import { mediaUrl } from "~/utils/media";
const props = defineProps<{
brand?: string;
logoUrl?: string | null;
timezone?: string;
}>();
const brand = computed(() => props.brand || "منتک");
const logoSrc = computed(() => mediaUrl(props.logoUrl));
const time = ref("");
onMounted(() => {
const tick = () => {
try {
time.value = formatClock(new Date(), props.timezone);
} catch {
time.value = "";
}
};
tick();
const id = setInterval(tick, 1000);
onUnmounted(() => clearInterval(id));
});
</script>
-20
View File
@@ -1,20 +0,0 @@
<template>
<section class="border-y border-white/[0.07] bg-coal px-6 py-20 md:px-10 md:py-28">
<div class="mx-auto grid max-w-[1500px] gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div v-for="(s, i) in data.items" :key="i">
<p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl">
{{ s.value }}{{ s.suffix }}
</p>
<p class="mt-3 font-mono text-[11px] tracking-[0.14em] text-ash md:tracking-[0.2em]">{{ s.label }}</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
items: Array<{ value: number; suffix: string; label: string }>;
};
}>();
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<section id="team" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۵" :title="data.tag" right="رهبری" />
<div class="mb-16 mt-14 flex flex-col justify-between gap-8 md:flex-row md:items-end">
<h2 class="font-display text-[clamp(2.4rem,7vw,6.5rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
<template v-if="headlineParts.rest">
{{ headlineParts.rest }}
<span class="font-accent text-volt">{{ headlineParts.last }}</span>
</template>
<template v-else>
{{ data.headline }}
</template>
</h2>
<p class="max-w-xs text-sm leading-relaxed text-ash">{{ data.body }}</p>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<figure v-for="m in data.members" :key="m.name" class="group">
<div
class="aspect-[3/4] overflow-hidden bg-gradient-to-br from-smoke to-ink bg-cover bg-center transition-transform duration-700 group-hover:scale-[1.02]"
:style="m.imageUrl ? { backgroundImage: `url(${m.imageUrl})` } : {}"
/>
<figcaption class="mt-4">
<p class="font-display text-xl font-bold tracking-[-0.02em] transition-colors group-hover:text-volt">
{{ m.name }}
</p>
<p class="mt-1 font-mono text-[10px] tracking-[0.16em] text-ash">{{ m.role }}</p>
</figcaption>
</figure>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
tag: string;
headline: string;
body: string;
members: Array<{ name: string; role: string; imageUrl?: string }>;
};
}>();
const headlineParts = computed(() => {
const parts = (props.data.headline || "").trim().split(/\s+/);
if (parts.length < 2) return { rest: "", last: props.data.headline };
return {
rest: parts.slice(0, -1).join(" "),
last: parts.at(-1) || "",
};
});
</script>
-38
View File
@@ -1,38 +0,0 @@
<template>
<section id="work" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۳" :title="data.tag" right="۱۴۰۲ — ۱۴۰۴" />
<div class="mt-16 grid gap-6 md:grid-cols-2">
<article
v-for="p in data.items"
:key="p.n"
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 transition-colors hover:border-volt/30 md:p-8"
>
<div
class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink bg-cover bg-center transition-transform duration-700 group-hover:scale-[1.03]"
:style="p.imageUrl ? { backgroundImage: `url(${p.imageUrl})` } : {}"
/>
<div class="flex items-end justify-between gap-4">
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">{{ p.n }} / {{ p.year }}</p>
<h3 class="mt-2 font-display text-2xl font-extrabold tracking-[-0.02em] transition-colors group-hover:text-volt md:text-3xl">
{{ p.title }}
</h3>
<p class="mt-2 text-sm text-ash">{{ p.cat }}</p>
</div>
<span class="text-volt transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">↗</span>
</div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
items: Array<{ n: string; title: string; cat: string; year: string; imageUrl?: string }>;
};
}>();
</script>
@@ -0,0 +1,50 @@
<template>
<section class="py-16 cta-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="relative overflow-hidden rounded-[36px] bg-gradient-to-l from-sky-50 via-blue-50 to-blue-100/70 dark:from-slate-900 dark:via-[#0c1e3f] dark:to-slate-900 border border-brand-200/80 dark:border-brand-500/30 p-8 sm:p-12 shadow-soft-card">
<div class="relative z-10 flex flex-col md:flex-row items-center justify-between gap-8 text-right">
<div class="order-3 md:order-1 flex items-center justify-center" aria-hidden="true">
<div class="w-28 h-28 sm:w-32 sm:h-32 rounded-3xl bg-white dark:bg-slate-800 shadow-xl border border-white/80 dark:border-slate-700 p-3 flex items-center justify-center transform hover:scale-105 transition-transform duration-500">
<div class="w-full h-full rounded-2xl bg-gradient-to-br from-brand-600 via-brand-500 to-sky-400 flex items-center justify-center text-white shadow-inner">
<svg class="w-16 h-16" viewBox="0 0 32 32" fill="none">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="16" cy="22" r="2.2" fill="white" />
</svg>
</div>
</div>
</div>
<div class="order-2 flex-shrink-0">
<button
type="button"
class="inline-flex items-center gap-2.5 px-8 py-3.5 rounded-full bg-brand-600 hover:bg-brand-700 text-white font-bold text-sm shadow-glow-cta active:scale-95 transition-all duration-300"
@click="consult.open()"
>
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ cta.ctaLabel }}</span>
</button>
</div>
<div class="order-1 md:order-3 flex-1 space-y-2">
<h2 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
{{ cta.titleLine1 }}
<br>
{{ cta.titleLine2 }}
</h2>
<p class="text-slate-600 dark:text-slate-300 text-sm font-normal">
{{ cta.body }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const cta = section("cta");
const consult = useConsultModal();
</script>
@@ -0,0 +1,90 @@
<template>
<section id="features" class="py-24 bg-slate-50/70 dark:bg-[#070d19]/90 relative overflow-hidden customization-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
<div class="lg:col-span-7 relative flex justify-center items-center py-6 min-h-[380px]" aria-hidden="true">
<div class="w-4/5 sm:w-2/3 bg-white/90 dark:bg-slate-900/80 rounded-2xl border border-slate-200/90 dark:border-slate-800 p-5 shadow-xl -rotate-6 transform transition-transform duration-700 hover:-rotate-3">
<div class="flex items-center gap-2 border-b border-slate-100 dark:border-slate-800 pb-2.5 mb-3">
<span class="w-2.5 h-2.5 rounded-full bg-slate-300 dark:bg-slate-700" />
<span class="w-2.5 h-2.5 rounded-full bg-slate-300 dark:bg-slate-700" />
<div class="text-[11px] text-slate-400 dark:text-slate-500 font-bold">{{ features.baseCardLabel }}</div>
</div>
<div class="space-y-3 opacity-60">
<div class="h-3.5 w-1/3 bg-slate-200 dark:bg-slate-700 rounded" />
<div class="h-16 w-full bg-slate-100 dark:bg-slate-800 rounded-lg" />
<div class="grid grid-cols-3 gap-2">
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
</div>
</div>
</div>
<div class="absolute top-0 right-1/4 z-30 transform -translate-y-6">
<svg class="w-28 h-20 text-brand-500" viewBox="0 0 100 60" fill="none">
<path d="M10,50 Q50,-10 90,30" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 6" />
<polygon points="90,30 82,18 96,22" fill="currentColor" />
</svg>
</div>
<div class="absolute -bottom-4 -right-2 sm:right-6 w-5/6 sm:w-3/4 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-brand-200 dark:border-brand-500/40 p-6 shadow-2xl rotate-2 hover:rotate-0 transition-transform duration-700">
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3 mb-4">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-brand-600 text-white flex items-center justify-center font-bold text-xs shadow-sm">M</div>
<div class="text-xs font-bold text-slate-800 dark:text-slate-100">{{ features.customCardTitle }}</div>
</div>
<span class="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/60 px-2.5 py-1 rounded-full border border-emerald-100 dark:border-emerald-800/40">{{ features.customCardBadge }}</span>
</div>
<div class="grid grid-cols-3 gap-3 text-right">
<div class="col-span-2 space-y-2">
<div class="text-xs text-slate-400 dark:text-slate-400 font-medium">{{ features.salesLabel }}</div>
<div class="text-xl font-black text-slate-900 dark:text-white">{{ features.salesValue }} <span class="text-xs font-normal text-slate-500 dark:text-slate-400">تومان</span></div>
<div class="h-11 bg-gradient-to-l from-brand-50 to-transparent dark:from-brand-950/50 dark:to-transparent border border-brand-100 dark:border-brand-900/60 rounded-xl flex items-center px-3">
<span class="text-[10px] text-brand-700 dark:text-brand-300 font-bold">{{ features.linkNote }}</span>
</div>
</div>
<div class="space-y-1 bg-slate-50 dark:bg-slate-800/70 p-2.5 rounded-xl text-center flex flex-col justify-center border border-slate-100 dark:border-slate-800">
<div class="text-[10px] text-slate-500 dark:text-slate-400">{{ features.aiLabel }}</div>
<div class="text-brand-600 dark:text-brand-400 font-extrabold text-sm">{{ features.aiStatus }}</div>
<div class="text-[9px] text-slate-400 dark:text-slate-500">{{ features.aiNote }}</div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-5 text-right space-y-5 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ features.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">
{{ features.titleLine1 }}
<br>
<span>{{ features.titleLine2 }}</span>
</h2>
<p class="text-base text-slate-600 dark:text-slate-300 leading-relaxed font-normal">
{{ features.body }}
</p>
<div class="pt-2">
<a
:href="resolveHref(features.ctaHref)"
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 font-bold text-sm hover:border-brand-600 dark:hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-brand-50/50 dark:hover:bg-slate-800/50 transition-all duration-300"
>
<svg class="w-4 h-4 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ features.ctaLabel }}</span>
</a>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const features = section("features");
const resolveHref = useSectionHref();
</script>
@@ -0,0 +1,210 @@
<template>
<div id="hero-laptop" class="mockup-macbook-wrap relative w-full max-w-[580px] select-none cursor-pointer z-10" :style="transformStyle">
<div class="macbook-screen-chassis rounded-[22px] p-2.5 sm:p-3 relative">
<div class="mockup-glass-sheen rounded-[16px]" />
<div class="bg-[#0b0f19] rounded-[16px] overflow-hidden border border-slate-800 relative shadow-2xl">
<div class="bg-[#111827] px-3.5 py-2 flex items-center justify-between border-b border-slate-800 text-[10px] text-slate-400">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56] inline-block shadow-sm" />
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e] inline-block shadow-sm" />
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f] inline-block shadow-sm" />
</div>
<div class="w-24 h-3 bg-black rounded-b-md -mt-2 mx-auto flex items-center justify-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-slate-900 border border-slate-700" />
<span class="w-1 h-1 rounded-full bg-emerald-500/80" />
</div>
<div class="flex items-center gap-1.5 text-slate-300 font-bold">
<div class="w-3.5 h-3.5 rounded bg-brand-600 flex items-center justify-center text-[8px] text-white">M</div>
<span class="text-[9px] bg-brand-500/20 text-brand-300 px-2 py-0.5 rounded-full font-mono">{{ mockup.domain }}</span>
</div>
</div>
<div class="p-3 sm:p-4 bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 min-h-[320px] flex gap-3 text-right">
<div class="w-28 hidden sm:flex flex-col justify-between py-1 px-1 border-l border-slate-200 dark:border-slate-800 text-[11px] text-slate-600 dark:text-slate-400 font-medium">
<div class="space-y-1.5">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
:aria-pressed="heroTab === tab.id"
:class="heroTab === tab.id ? tab.activeClass : 'hover:bg-slate-100 dark:hover:bg-slate-900 text-slate-600 dark:text-slate-400'"
class="w-full text-right px-2.5 py-1.5 rounded-lg flex items-center gap-1.5 transition-all duration-200"
@click.stop="heroTab = tab.id"
>
<span class="w-1.5 h-1.5 rounded-full transition-transform duration-200" :class="heroTab === tab.id ? tab.dotClass : 'bg-slate-400'" />
{{ tab.label }}
</button>
</div>
<div class="px-2 py-1 text-[9px] text-slate-400 dark:text-slate-500 flex items-center justify-between">
<span>{{ mockup.version }}</span>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />
</div>
</div>
<div class="flex-1 space-y-2.5">
<div v-if="heroTab === 'dashboard'" class="space-y-2.5">
<div class="grid grid-cols-2 gap-2">
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm hover:border-brand-300 dark:hover:border-brand-500/50 transition-colors">
<div class="text-[10px] text-slate-400 dark:text-slate-500 font-medium">فروش کل دوره‌ای</div>
<div class="text-xs sm:text-sm md:text-base font-extrabold text-slate-800 dark:text-white mt-0.5">
{{ mockup.salesValue }} <span class="text-[9px] font-normal text-slate-500 dark:text-slate-400">تومان</span>
</div>
<div class="text-[9px] text-emerald-600 dark:text-emerald-400 mt-0.5 font-semibold flex items-center gap-1">
{{ mockup.salesGrowth }}
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm hover:border-brand-300 dark:hover:border-brand-500/50 transition-colors">
<div class="text-[10px] text-slate-400 dark:text-slate-500 font-medium">مشتریان وفادار فعال</div>
<div class="text-xs sm:text-sm md:text-base font-extrabold text-slate-800 dark:text-white mt-0.5">
{{ mockup.customersValue }} <span class="text-[9px] font-normal text-slate-500 dark:text-slate-400">نفر</span>
</div>
<div class="text-[9px] text-brand-600 dark:text-brand-400 mt-0.5 font-semibold flex items-center gap-1">
{{ mockup.satisfaction }}
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm relative group/chart">
<div class="flex items-center justify-between text-[10px] mb-1.5">
<span class="font-bold text-slate-700 dark:text-slate-300">روند عملکرد فروش و تحلیل هوش مصنوعی</span>
<span class="text-brand-600 dark:text-brand-400 font-bold text-[9px] flex items-center gap-1 bg-brand-50 dark:bg-brand-950 px-2 py-0.5 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping" />
زنده
</span>
</div>
<div class="relative">
<svg class="w-full h-20 overflow-visible" viewBox="0 0 300 90" fill="none" aria-hidden="true">
<defs>
<linearGradient id="heroChartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0062f5" stop-opacity="0.32" />
<stop offset="100%" stop-color="#0062f5" stop-opacity="0.0" />
</linearGradient>
</defs>
<path d="M0,70 Q40,55 80,35 T160,45 T240,15 T300,25 L300,90 L0,90 Z" fill="url(#heroChartGrad)" />
<path d="M0,70 Q40,55 80,35 T160,45 T240,15 T300,25" stroke="#0062f5" stroke-width="2.8" stroke-linecap="round" />
<circle cx="80" cy="35" r="4" fill="#0062f5" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[0]" />
<circle cx="160" cy="45" r="4" fill="#0062f5" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[1]" />
<circle cx="240" cy="15" r="5" fill="#0062f5" stroke="#ffffff" stroke-width="2" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[2]" />
</svg>
<div class="absolute top-1 left-2 bg-slate-900/90 text-white text-[9px] px-2 py-0.5 rounded font-mono shadow pointer-events-none">
{{ chartTooltip }}
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'gold'" class="space-y-2.5">
<div class="bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-amber-950 dark:text-amber-200">محاسبه‌گر نرخ لحظه‌ای و مظنه طلا</span>
<span class="text-[9px] bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100 px-2 py-0.5 rounded-full font-bold">اتصال مستقیم به اتحادیه</span>
</div>
<div class="grid grid-cols-2 gap-2 text-[10px]">
<div class="bg-white dark:bg-slate-900 p-2 rounded-lg border border-amber-100 dark:border-amber-900/40">
<span class="text-slate-400 dark:text-slate-500 block">مظنه ۱۷ عیار:</span>
<span class="font-black text-slate-800 dark:text-slate-200 text-xs">۱۸,۷۵۰,۰۰۰ تومان</span>
</div>
<div class="bg-white dark:bg-slate-900 p-2 rounded-lg border border-amber-100 dark:border-amber-900/40">
<span class="text-slate-400 dark:text-slate-500 block">گرم ۱۸ عیار:</span>
<span class="font-black text-slate-800 dark:text-slate-200 text-xs">۴,۳۲۰,۰۰۰ تومان</span>
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'crm'" class="space-y-2.5">
<div class="bg-indigo-50/70 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-indigo-950 dark:text-indigo-200">قیف فروش و وضعیت سرنخ‌ها</span>
<span class="text-[9px] bg-indigo-200 dark:bg-indigo-800 text-indigo-900 dark:text-indigo-100 px-2 py-0.5 rounded-full font-bold">اتوماسیون فعال</span>
</div>
<div class="space-y-1.5 text-[10px]">
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-indigo-100 dark:border-indigo-900/40">
<span class="font-semibold text-slate-700 dark:text-slate-300">تماس‌های موفق امروز</span>
<span class="font-bold text-indigo-600 dark:text-indigo-400">۱۴۲ مورد (+۱۸٪)</span>
</div>
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-indigo-100 dark:border-indigo-900/40">
<span class="font-semibold text-slate-700 dark:text-slate-300">پیش‌فاکتورهای در انتظار تایید</span>
<span class="font-bold text-amber-600 dark:text-amber-400">۲۹ قرارداد</span>
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'financial'" class="space-y-2.5">
<div class="bg-cyan-50/70 dark:bg-cyan-950/40 border border-cyan-200 dark:border-cyan-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-cyan-950 dark:text-cyan-200">تراز مالی و سامانه مودیان</span>
<span class="text-[9px] bg-emerald-100 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-300 px-2 py-0.5 rounded-full font-bold">همگام‌سازی برخط</span>
</div>
<div class="space-y-1.5 text-[10px]">
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-cyan-100 dark:border-cyan-900/40">
<span class="text-slate-600 dark:text-slate-400">تسویه تجمیعی کارتخوان‌ها:</span>
<span class="font-bold text-slate-900 dark:text-white">۱,۲۸۰,۰۰۰,۰۰۰ تومان</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-800 text-[10px] text-slate-600 dark:text-slate-400">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping" />
<span>آخرین تسویه حسابداری متصل به شتاب</span>
</div>
<span class="font-bold text-slate-800 dark:text-slate-200">موفق (۱ دقیقه پیش)</span>
</div>
</div>
</div>
</div>
</div>
<div class="macbook-deck-base h-4 rounded-b-[18px] mx-auto -mt-0.5 flex justify-center items-center relative">
<div class="w-24 h-1.5 bg-slate-400/80 rounded-full shadow-inner" />
</div>
</div>
</template>
<script setup lang="ts">
import type { HeroMockupContent } from "@montech/shared";
type HeroTab = "dashboard" | "gold" | "crm" | "financial";
const props = defineProps<{ mockup: HeroMockupContent; transformStyle: string }>();
const TABS: Array<{ id: HeroTab; label: string; activeClass: string; dotClass: string }> = [
{
id: "dashboard",
label: "داشبورد",
activeClass: "bg-brand-50 dark:bg-brand-950 text-brand-600 dark:text-brand-400 font-bold shadow-sm",
dotClass: "bg-brand-600 dark:bg-brand-400 scale-125",
},
{
id: "gold",
label: "انبارداری طلا",
activeClass: "bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-400 font-bold shadow-sm",
dotClass: "bg-amber-600 dark:bg-amber-400 scale-125",
},
{
id: "crm",
label: "مشتریان (CRM)",
activeClass: "bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-400 font-bold shadow-sm",
dotClass: "bg-indigo-600 dark:bg-indigo-400 scale-125",
},
{
id: "financial",
label: "گزارش مالی",
activeClass: "bg-cyan-50 dark:bg-cyan-950/60 text-cyan-700 dark:text-cyan-400 font-bold shadow-sm",
dotClass: "bg-cyan-600 dark:bg-cyan-400 scale-125",
},
];
const heroTab = ref<HeroTab>("dashboard");
const chartTooltip = ref(props.mockup.chartDefaultTooltip);
</script>
@@ -0,0 +1,89 @@
<template>
<div
id="hero-mobile"
class="mockup-iphone-wrap absolute -bottom-10 -right-2 sm:-right-4 lg:right-2 w-[225px] sm:w-[250px] h-[465px] sm:h-[515px] select-none cursor-pointer z-20"
:style="transformStyle"
@click="goldBarCount = goldBarCount === baseCount ? baseCount + 1 : baseCount"
>
<div class="iphone-titanium-bezel rounded-[46px] p-2.5 w-full h-full relative">
<div class="mockup-glass-sheen rounded-[38px]" />
<div class="bg-white dark:bg-slate-950 rounded-[38px] overflow-hidden text-right p-3.5 pt-2 text-slate-800 dark:text-slate-100 text-[10px] w-full h-full flex flex-col justify-between relative border border-slate-100 dark:border-slate-800 shadow-xl">
<div>
<div class="flex items-center justify-between px-2 text-[9px] text-slate-800 dark:text-slate-200 font-bold mb-1">
<span>۰۹:۴۱</span>
<div class="flex items-center gap-1 text-[8px]">
<span>5G</span>
<svg class="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25c-.2.2-.2.51 0 .71.2.2.51.2.71 0l.64-.64C7.26 19.46 9.49 20.1 12 20.1c4.97 0 9-4.03 9-9s-4.03-9-9-9z" /></svg>
<span class="w-4 h-2 border border-slate-800 dark:border-slate-300 rounded-sm p-0.5 flex items-center"><span class="w-full h-full bg-slate-800 dark:bg-slate-300 rounded-xs" /></span>
</div>
</div>
<div class="w-24 h-4.5 bg-black mx-auto rounded-full mb-2.5 flex items-center justify-between px-2.5 shadow-sm">
<span class="w-1.5 h-1.5 rounded-full bg-slate-900 border border-slate-800" />
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
</div>
<div class="flex items-center justify-between px-1 mb-2.5">
<div class="flex items-center gap-1.5">
<span class="w-5 h-5 rounded-lg bg-amber-500 text-white flex items-center justify-center text-[10px] font-bold">M</span>
<span class="font-black text-slate-900 dark:text-white text-xs">طلا و جواهر</span>
</div>
<span class="text-[9px] bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 font-bold px-2 py-0.5 rounded-full">انبار آنلاین</span>
</div>
<div class="bg-gradient-to-l from-amber-50 to-amber-100/70 dark:from-amber-950/30 dark:to-amber-900/20 border border-amber-200/90 dark:border-amber-800/50 rounded-2xl p-2.5 flex items-center gap-2.5 shadow-sm hover:shadow-md transition-shadow mb-2.5">
<div class="gold-bar-realistic w-12 h-12 rounded-xl flex flex-col items-center justify-center text-amber-950 font-black text-[10px] tracking-tight relative overflow-hidden flex-shrink-0">
<div class="absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-white/40 pointer-events-none" />
<span class="relative z-10 text-[9px] font-extrabold tracking-wider">AU 999</span>
<span class="relative z-10 text-[7px] opacity-90 font-mono">100g</span>
</div>
<div class="text-[9px] leading-tight flex-1">
<div class="font-extrabold text-slate-900 dark:text-white text-[10px]">شمش ۱۰۰ گرمی ۲۴ عیار</div>
<div class="text-amber-800 dark:text-amber-300 font-semibold mt-1">موجودی صندوق: {{ goldBarCount }} قطعه</div>
<div class="text-[8px] text-slate-400 dark:text-slate-500 mt-0.5">شناسه استاندارد زرین‌بانک</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-900 rounded-xl p-2 border border-slate-200/80 dark:border-slate-800 space-y-1.5 mb-2.5">
<div class="flex items-center justify-between text-[9px]">
<span class="text-slate-500 dark:text-slate-400 font-medium">مظنه ۱۷ اتحادیه:</span>
<span class="font-black text-slate-900 dark:text-white">۱۸,۷۵۰,۰۰۰ <span class="text-[8px] font-normal text-slate-400">تومان</span></span>
</div>
<div class="flex items-center justify-between text-[9px]">
<span class="text-slate-500 dark:text-slate-400 font-medium">تسویه آنی شتاب:</span>
<span class="font-black text-emerald-600 dark:text-emerald-400">۴۵۰,۳۰۰,۰۰۰ <span class="text-[8px] font-normal text-slate-400">تومان</span></span>
</div>
</div>
<div class="space-y-1.5">
<div class="text-[9px] font-bold text-slate-400 dark:text-slate-500 px-1">آخرین فاکتورهای سیستمی:</div>
<div class="bg-white dark:bg-slate-900 rounded-lg p-1.5 border border-slate-100 dark:border-slate-800 flex items-center justify-between text-[9px] shadow-2xs">
<span class="text-slate-700 dark:text-slate-300 font-medium">فاکتور ۱۸۴۲ (زرگری شمس)</span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold">تایید شد ✓</span>
</div>
<div class="bg-white dark:bg-slate-900 rounded-lg p-1.5 border border-slate-100 dark:border-slate-800 flex items-center justify-between text-[9px] shadow-2xs">
<span class="text-slate-700 dark:text-slate-300 font-medium">حواله سکه بهار آزادی</span>
<span class="text-brand-600 dark:text-brand-400 font-bold">در انتظار</span>
</div>
</div>
</div>
<div class="pt-2 border-t border-slate-100 dark:border-slate-800 flex flex-col items-center gap-1.5">
<div class="w-full flex justify-around items-center text-slate-400 dark:text-slate-500 text-xs">
<span class="text-brand-600 font-black">●</span>
<span>■</span>
<span>▲</span>
<span>⚙</span>
</div>
<div class="w-24 h-1 bg-slate-800 dark:bg-slate-400 rounded-full mt-0.5" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ baseCount: number; transformStyle: string }>();
const goldBarCount = ref(props.baseCount);
</script>
@@ -0,0 +1,113 @@
<template>
<section ref="sectionEl" class="relative pt-8 pb-16 lg:pt-14 lg:pb-24 overflow-hidden hero-parallax-section">
<div class="absolute top-1/4 right-0 w-[550px] h-[550px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="absolute bottom-10 left-10 w-[500px] h-[500px] bg-sky-100/50 dark:bg-sky-600/10 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-8 items-center">
<div class="lg:col-span-5 space-y-6 text-right">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200/70 dark:border-slate-700/70 text-xs font-semibold text-slate-500 dark:text-slate-300">
<span class="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
<span>{{ hero.eyebrow }}</span>
</div>
<h1 class="text-4xl sm:text-5xl lg:text-[54px] font-black text-slate-900 dark:text-white leading-[1.28] tracking-tight">
{{ hero.titleLine1 }}
<br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-brand-600 via-brand-500 to-sky-400">
{{ hero.titleHighlight }}
</span>
</h1>
<p class="text-base sm:text-lg text-slate-600 dark:text-slate-300 font-normal leading-relaxed max-w-xl">
{{ hero.subtitle }}
</p>
<div class="pt-2 max-w-xl">
<form class="relative group" role="search" @submit.prevent="search">
<div class="relative flex items-center bg-white dark:bg-slate-900/95 rounded-full p-2 pl-2 shadow-soft-card hover:shadow-hover-card border border-slate-200/90 dark:border-slate-700/80 focus-within:border-brand-500 focus-within:ring-4 focus-within:ring-brand-100/70 dark:focus-within:ring-brand-900/40 transition-all duration-300">
<button
type="submit"
aria-label="جستجوی هوشمند"
class="flex-shrink-0 w-12 h-12 rounded-full bg-brand-600 text-white flex items-center justify-center hover:bg-brand-700 active:scale-95 transition-all shadow-md shadow-brand-600/30"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<input
v-model="ai.query.value"
type="text"
aria-label="نیاز کسب‌وکار خود را بنویسید"
:placeholder="hero.searchPlaceholder"
class="w-full bg-transparent text-sm sm:text-base text-slate-800 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none pr-3 pl-3 font-normal text-right"
>
<div class="pr-3 pl-2 text-brand-500 flex items-center justify-center" aria-hidden="true">
<svg class="w-5 h-5 text-brand-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L14.2 8.3L20.5 10.5L14.2 12.7L12 19L9.8 12.7L3.5 10.5L9.8 8.3L12 2Z" />
</svg>
</div>
</div>
<div class="mt-3.5 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span class="font-medium text-slate-400 dark:text-slate-500">{{ hero.suggestionsLabel }}</span>
<button
v-for="item in hero.suggestions"
:key="item.label"
type="button"
class="px-3 py-1 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-brand-50 dark:hover:bg-brand-950 hover:text-brand-600 dark:hover:text-brand-300 border border-transparent hover:border-brand-200 dark:hover:border-brand-800 transition-all"
@click="suggest(item.query)"
>
{{ item.label }}
</button>
</div>
</form>
</div>
</div>
<div
class="lg:col-span-7 relative perspective-stage flex justify-center items-center py-6 min-h-[520px] sm:min-h-[580px]"
@mousemove="parallax.onMouseMove"
@mouseleave="parallax.onMouseLeave"
>
<div class="studio-botanical-leaf hidden sm:block" aria-hidden="true">
<svg viewBox="0 0 280 280" fill="none" class="w-full h-full text-emerald-950/25 dark:text-emerald-500/15">
<path d="M50,220 C35,120 100,40 220,25 C250,90 200,190 95,235 C75,240 60,235 50,220 Z" fill="currentColor" />
<path d="M75,195 C110,160 145,120 195,75" stroke="#047857" stroke-width="5" stroke-linecap="round" />
<path d="M105,170 C130,175 160,190 180,210" stroke="#047857" stroke-width="4" stroke-linecap="round" />
<path d="M130,135 C160,135 185,150 205,165" stroke="#047857" stroke-width="4" stroke-linecap="round" />
<path d="M155,100 C185,95 215,105 235,120" stroke="#047857" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<div class="device-ground-shadow" />
<HeroLaptopMockup :mockup="hero.mockup" :transform-style="parallax.laptop.value" />
<HeroPhoneMockup :base-count="hero.mockup.goldBarCount" :transform-style="parallax.mobile.value" />
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const hero = section("hero");
const ai = useAiModal();
const parallax = useHeroParallax();
const sectionEl = ref<HTMLElement | null>(null);
function search() {
if (!ai.query.value.trim()) ai.query.value = hero.value.searchDefaultQuery;
ai.open();
}
function suggest(query: string) {
ai.query.value = query;
search();
}
useHeroScrollParallax(sectionEl);
</script>
@@ -0,0 +1,42 @@
<template>
<section class="py-14 border-y border-slate-200/80 dark:border-slate-800/80 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md relative z-10" aria-label="آمار منتک">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div
v-for="item in metrics.items"
:key="item.label"
class="fade-item flex items-center justify-center sm:justify-start gap-4 p-4 rounded-2xl hover:bg-slate-50 dark:hover:bg-slate-800/60 transition-all duration-300"
>
<div class="w-13 h-13 p-3 rounded-2xl bg-sky-50 dark:bg-sky-950/60 text-sky-600 dark:text-sky-400 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path v-for="(d, i) in ICON_PATHS[item.icon]" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="d" />
</svg>
</div>
<div class="text-right">
<div v-if="item.emphasis === 'text'" class="text-xl font-extrabold text-slate-900 dark:text-white">{{ item.value }}</div>
<div v-else class="text-3xl font-black text-slate-900 dark:text-white tracking-tight">{{ item.value }}</div>
<div class="text-sm text-slate-500 dark:text-slate-400 font-medium">{{ item.label }}</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { MetricIcon } from "@montech/shared";
const ICON_PATHS: Record<MetricIcon, string[]> = {
gear: [
"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
],
users: [
"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
],
box: ["M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"],
};
const { section } = useSiteContent();
const metrics = section("metrics");
</script>
@@ -0,0 +1,100 @@
<template>
<div
role="button"
tabindex="0"
:aria-label="`${item.title} — درخواست مشاوره`"
class="spotlight-card fade-item group bg-white dark:bg-slate-900/90 rounded-3xl p-6 border border-slate-200/90 dark:border-slate-800 shadow-soft-card hover:shadow-hover-card transition-all duration-300 flex flex-col justify-between text-center cursor-pointer"
:class="accent.border"
:style="tilt.style.value"
@mousemove="tilt.onMouseMove"
@mouseleave="tilt.onMouseLeave"
@click="select"
@keydown.enter.prevent="select"
@keydown.space.prevent="select"
>
<div>
<div class="w-20 h-20 mx-auto mb-6 flex items-center justify-center group-hover:scale-110 transition-transform duration-500">
<svg class="w-16 h-16 drop-shadow-md" viewBox="0 0 64 64" fill="none" aria-hidden="true">
<template v-if="item.icon === 'cube'">
<polygon points="32,6 58,20 32,34 6,20" fill="#38b2f8" />
<polygon points="6,20 32,34 32,60 6,46" fill="#0062f5" />
<polygon points="58,20 32,34 32,60 58,46" fill="#0046b8" />
</template>
<template v-else-if="item.icon === 'bars'">
<rect x="8" y="34" width="12" height="24" rx="3" fill="#38b2f8" />
<rect x="26" y="20" width="12" height="38" rx="3" fill="#007dfc" />
<rect x="44" y="8" width="12" height="50" rx="3" fill="#0050d8" />
</template>
<template v-else-if="item.icon === 'avatars'">
<circle cx="32" cy="18" r="10" fill="#6366f1" />
<path d="M16 46C16 38 23 34 32 34C41 34 48 38 48 46V50H16V46Z" fill="#4f46e5" />
<circle cx="48" cy="22" r="7" fill="#818cf8" />
<path d="M46 36C49 36 56 39 56 46V48H46V36Z" fill="#6366f1" />
</template>
<template v-else-if="item.icon === 'cart'">
<path d="M10 12H18L24 40H48L54 20H20" stroke="#06b6d4" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="26" cy="50" r="5" fill="#0891b2" />
<circle cx="46" cy="50" r="5" fill="#0891b2" />
</template>
<template v-else>
<polygon points="12,42 22,26 44,26 34,42" fill="#fde047" />
<polygon points="34,42 44,26 52,32 42,48" fill="#d97706" />
<polygon points="12,42 34,42 42,48 20,48" fill="#f59e0b" />
<polygon points="20,24 30,8 52,8 42,24" fill="#fef08a" />
<polygon points="42,24 52,8 60,14 50,30" fill="#b45309" />
<polygon points="20,24 42,24 50,30 28,30" fill="#f59e0b" />
</template>
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white transition-colors" :class="accent.title">{{ item.title }}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2 font-normal">{{ item.subtitle }}</p>
</div>
<div class="mt-6 pt-4 flex justify-center">
<span class="w-8 h-8 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-300 group-hover:text-white flex items-center justify-center transition-all duration-300 shadow-sm" :class="accent.bubble">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { SolutionAccent, SolutionItem } from "@montech/shared";
const props = defineProps<{ item: SolutionItem }>();
const ACCENTS: Record<SolutionAccent, { border: string; title: string; bubble: string }> = {
brand: {
border: "hover:border-brand-300 dark:hover:border-brand-500/50",
title: "group-hover:text-brand-600 dark:group-hover:text-brand-400",
bubble: "group-hover:bg-brand-600",
},
sky: {
border: "hover:border-sky-300 dark:hover:border-sky-500/50",
title: "group-hover:text-sky-600 dark:group-hover:text-sky-400",
bubble: "group-hover:bg-sky-500",
},
indigo: {
border: "hover:border-indigo-300 dark:hover:border-indigo-500/50",
title: "group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
bubble: "group-hover:bg-indigo-600",
},
cyan: {
border: "hover:border-cyan-300 dark:hover:border-cyan-500/50",
title: "group-hover:text-cyan-600 dark:group-hover:text-cyan-400",
bubble: "group-hover:bg-cyan-500",
},
amber: {
border: "hover:border-amber-300 dark:hover:border-amber-500/50",
title: "group-hover:text-amber-600 dark:group-hover:text-amber-400",
bubble: "group-hover:bg-amber-500",
},
};
const accent = computed(() => ACCENTS[props.item.accent] ?? ACCENTS.brand);
const tilt = useCardTilt();
const consult = useConsultModal();
function select() {
consult.open({ type: props.item.consultType, source: "solution" });
}
</script>
@@ -0,0 +1,20 @@
<template>
<section id="solutions" class="py-24 bg-white dark:bg-[#070d19] relative solutions-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center max-w-2xl mx-auto mb-16 space-y-2 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ solutions.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white">{{ solutions.title }}</h2>
<p class="text-sm sm:text-base text-slate-500 dark:text-slate-400 font-normal">{{ solutions.subtitle }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
<SolutionCard v-for="item in solutions.items" :key="item.title" :item="item" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const solutions = section("solutions");
</script>
@@ -0,0 +1,83 @@
<template>
<section id="testimonials" class="py-24 bg-slate-50/70 dark:bg-[#070d19]/90 overflow-hidden relative">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
<div class="lg:col-span-7 relative flex justify-center items-center py-10 fade-item">
<div
v-for="(src, i) in decorAvatars"
:key="i"
class="absolute w-14 h-14 rounded-full bg-white dark:bg-slate-800 shadow-md p-1 border border-slate-200 dark:border-slate-700 opacity-60"
:class="i === 0 ? '-right-2 top-4' : '-right-4 bottom-6'"
aria-hidden="true"
>
<SafeImage :src="src" alt="" width="56" height="56" class="w-full h-full rounded-full object-cover" />
</div>
<figure class="relative z-10 w-full max-w-lg bg-white dark:bg-slate-900/95 rounded-3xl p-8 border border-slate-100 dark:border-slate-800 shadow-xl space-y-6 text-right">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/60 px-3 py-1 rounded-full border border-brand-100 dark:border-brand-900/50">
<svg class="w-4 h-4 text-brand-600 dark:text-brand-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span>{{ t.badge }}</span>
</div>
<div class="text-slate-300 dark:text-slate-700" aria-hidden="true">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
</div>
</div>
<blockquote class="text-slate-800 dark:text-slate-100 text-lg font-bold leading-relaxed min-h-[60px]">
{{ t.quote }}
</blockquote>
<figcaption class="flex items-center justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-800">
<div class="text-right">
<div class="font-bold text-slate-900 dark:text-white text-sm">{{ t.name }}</div>
<div class="text-xs text-slate-400 dark:text-slate-500 font-medium">{{ t.role }}</div>
</div>
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-brand-500 shadow-sm">
<SafeImage :src="t.avatarUrl" :alt="t.name" width="48" height="48" class="w-full h-full object-cover" />
</div>
</figcaption>
</figure>
</div>
<div class="lg:col-span-5 text-right space-y-5 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ t.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">
{{ t.titleLine1 }}
<br>
<span>{{ t.titleLine2 }}</span>
</h2>
<p class="text-base text-slate-600 dark:text-slate-300 leading-relaxed font-normal">
{{ t.body }}
</p>
<div class="pt-2">
<button
type="button"
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full bg-brand-600 text-white font-bold text-sm hover:bg-brand-700 active:scale-95 transition-all duration-300 shadow-md shadow-brand-600/25"
@click="consult.open()"
>
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ t.ctaLabel }}</span>
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const t = section("testimonials");
const consult = useConsultModal();
const decorAvatars = computed(() => t.value.decorAvatars.slice(0, 2));
</script>
@@ -0,0 +1,118 @@
<template>
<footer id="contact" class="bg-white dark:bg-[#060a14] border-t border-slate-200/80 dark:border-slate-800/80 pt-16 pb-12 text-right">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-10 pb-12 border-b border-slate-100 dark:border-slate-800/80">
<div class="lg:col-span-3 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.contactTitle }}</h2>
<ul class="space-y-3 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span>{{ settings.phone }}</span>
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span>{{ settings.email }}</span>
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span>{{ settings.address }}</span>
</li>
</ul>
</div>
<div class="lg:col-span-3 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.productsTitle }}</h2>
<ul class="space-y-2 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li v-for="link in footer.products" :key="link.label">
<a :href="resolveHref(link.href)" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ link.label }}</a>
</li>
</ul>
</div>
<div class="lg:col-span-2 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.quickLinksTitle }}</h2>
<ul class="space-y-2 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li v-for="link in footer.quickLinks" :key="link.label">
<a :href="resolveHref(link.href)" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ link.label }}</a>
</li>
</ul>
</div>
<div class="lg:col-span-4 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-brand-600 to-sky-400 text-white">
<svg class="w-6 h-6" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<div class="flex flex-col">
<span class="text-xl font-black text-slate-900 dark:text-white">{{ footer.brandTitle }}</span>
<span class="text-[9px] font-bold text-slate-400 tracking-wider">{{ footer.brandSubtitle }}</span>
</div>
</div>
<p class="text-xs text-slate-400 dark:text-slate-500 leading-relaxed font-normal">
<template v-for="(line, i) in footer.descriptionLines" :key="i">
<br v-if="i > 0">
{{ line }}
</template>
</p>
<div class="flex items-center gap-3 pt-2">
<a
v-for="social in socials"
:key="social.kind + social.url"
:href="social.url"
:aria-label="social.label"
:target="social.url.startsWith('http') ? '_blank' : undefined"
:rel="social.url.startsWith('http') ? 'noopener noreferrer' : undefined"
class="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-brand-600 hover:text-white flex items-center justify-center transition-colors"
>
<SocialIcon :kind="social.kind" />
</a>
</div>
</div>
</div>
<div class="pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-slate-400 dark:text-slate-500 gap-4">
<div>{{ footer.copyright }}</div>
<div class="flex items-center gap-6">
<a
v-for="link in footer.legalLinks"
:key="link.label"
:href="resolveHref(link.href)"
class="hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>{{ link.label }}</a>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
type SocialKind = "telegram" | "linkedin" | "instagram" | "link";
const DEFAULT_SOCIALS: Array<{ kind: SocialKind; label: string; url: string }> = [
{ kind: "telegram", label: "تلگرام", url: "#" },
{ kind: "linkedin", label: "لینکدین", url: "#" },
{ kind: "instagram", label: "اینستاگرام", url: "#" },
];
function detectKind(label: string, url: string): SocialKind {
const s = `${label} ${url}`.toLowerCase();
if (s.includes("t.me") || s.includes("telegram") || s.includes("تلگرام")) return "telegram";
if (s.includes("linkedin") || s.includes("لینکدین")) return "linkedin";
if (s.includes("instagram") || s.includes("اینستاگرام")) return "instagram";
return "link";
}
const { section, settings } = useSiteContent();
const footer = section("footer");
const resolveHref = useSectionHref();
const socials = computed(() => {
const list = settings.value.socials;
if (!list.length) return DEFAULT_SOCIALS;
return list.map((s) => ({ kind: detectKind(s.label, s.url), label: s.label, url: s.url }));
});
</script>
@@ -0,0 +1,94 @@
<template>
<header class="sticky top-0 z-50 transition-all duration-300 bg-white/90 dark:bg-[#0b1329]/90 backdrop-blur-xl border-b border-slate-200/70 dark:border-slate-800/80 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
<div class="flex items-center gap-3">
<ThemeToggle />
<button
type="button"
class="hidden sm:inline-flex items-center gap-2.5 px-6 py-2.5 rounded-full bg-brand-600 hover:bg-brand-700 active:scale-95 text-white text-sm font-semibold transition-all duration-300 shadow-md shadow-brand-600/25 group"
@click="consult.open()"
>
<span class="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center -mr-1 transition-transform group-hover:-translate-x-1">
<svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</span>
<span>{{ header.ctaLabel }}</span>
</button>
<button
type="button"
class="md:hidden p-2 rounded-xl text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors focus:outline-none"
:aria-expanded="isMobileMenuOpen"
aria-controls="mobile-nav"
:aria-label="isMobileMenuOpen ? 'بستن منو' : 'باز کردن منو'"
@click="isMobileMenuOpen = !isMobileMenuOpen"
>
<svg v-if="!isMobileMenuOpen" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav class="hidden md:flex items-center gap-8 text-sm font-semibold text-slate-600 dark:text-slate-300" aria-label="منوی اصلی">
<a
v-for="link in desktopNav"
:key="link.href + link.label"
:href="resolveHref(link.href)"
class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors duration-200"
>{{ link.label }}</a>
</nav>
<a :href="resolveHref('#')" class="flex items-center gap-3 group focus:outline-none" :aria-label="header.brandTitle">
<div class="flex flex-col text-right">
<span class="text-2xl font-black tracking-tight text-slate-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ header.brandTitle }}</span>
<span class="text-[9px] font-black text-slate-400 dark:text-slate-500 tracking-[0.25em] uppercase -mt-1">{{ header.brandSubtitle }}</span>
</div>
<div class="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-br from-brand-600 via-brand-500 to-sky-400 text-white shadow-md shadow-brand-600/25 transition-all duration-500 group-hover:scale-105 group-hover:shadow-brand-600/40">
<svg class="w-7 h-7" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="16" cy="22" r="2.2" fill="white" />
</svg>
</div>
</a>
</div>
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0"
>
<div v-if="isMobileMenuOpen" id="mobile-nav" class="md:hidden bg-white/95 dark:bg-[#0b1329]/95 backdrop-blur-xl border-b border-slate-200 dark:border-slate-800 px-6 py-5 space-y-4 shadow-2xl">
<a
v-for="link in header.nav"
:key="link.href + link.label"
:href="resolveHref(link.href)"
class="block text-base font-semibold text-slate-700 dark:text-slate-200 hover:text-brand-600 dark:hover:text-brand-400"
@click="isMobileMenuOpen = false"
>{{ link.label }}</a>
<div class="pt-2 flex flex-col gap-2">
<button type="button" class="w-full flex items-center justify-center gap-2 py-3 rounded-full bg-brand-600 text-white font-bold shadow-md" @click="consult.open(); isMobileMenuOpen = false">
<span>{{ header.ctaLabel }}</span>
<svg class="w-4 h-4 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button>
</div>
</div>
</transition>
</header>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const header = section("header");
const consult = useConsultModal();
const isMobileMenuOpen = ref(false);
const desktopNav = computed(() => [...header.value.nav].reverse());
const resolveHref = useSectionHref();
</script>
@@ -0,0 +1,22 @@
<template>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path :d="PATHS[kind]" />
</svg>
</template>
<script setup lang="ts">
export type SocialKind = "telegram" | "linkedin" | "instagram" | "link";
defineProps<{ kind: SocialKind }>();
const PATHS: Record<SocialKind, string> = {
telegram:
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z",
linkedin:
"M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.28 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.75M6.46 8.76a1.64 1.64 0 1 0 0-3.28 1.64 1.64 0 0 0 0 3.28m1.4 9.74v-8.37H5.06v8.37h2.8z",
instagram:
"M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
link:
"M10.59 13.41a1 1 0 0 0 1.41 0l3.54-3.54a3 3 0 0 0-4.24-4.24l-1.77 1.77a1 1 0 1 0 1.41 1.41l1.77-1.77a1 1 0 0 1 1.42 1.42l-3.54 3.54a1 1 0 0 0 0 1.41zm2.82-2.82a1 1 0 0 0-1.41 0l-3.54 3.54a3 3 0 0 0 4.24 4.24l1.77-1.77a1 1 0 1 0-1.41-1.41l-1.77 1.77a1 1 0 0 1-1.42-1.42l3.54-3.54a1 1 0 0 0 0-1.41z",
};
</script>
@@ -0,0 +1,23 @@
<template>
<button
type="button"
:title="label"
:aria-label="label"
class="p-2.5 rounded-full border border-slate-200/90 dark:border-slate-700/80 bg-slate-100/80 dark:bg-slate-800/90 text-slate-600 dark:text-amber-400 hover:text-brand-600 dark:hover:text-amber-300 transition-all duration-300 shadow-sm hover:scale-105 active:scale-95 focus:outline-none"
@click="toggle"
>
<!-- Both icons are server-rendered; the `dark` class picks one, so SSR and hydration always agree. -->
<svg class="hidden dark:block w-5 h-5 transition-transform rotate-0 hover:rotate-45" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg class="dark:hidden w-5 h-5 text-slate-700 transition-transform hover:-rotate-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
</template>
<script setup lang="ts">
const { isDark, sync, toggle } = useTheme();
const label = computed(() => (isDark.value ? "تغییر به حالت روشن" : "تغییر به حالت تاریک"));
onMounted(sync);
</script>
@@ -0,0 +1,74 @@
<template>
<transition
appear
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="ai.isOpen.value" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 dark:bg-black/75 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-modal-title"
class="bg-white dark:bg-slate-900 rounded-3xl p-6 sm:p-8 max-w-lg w-full shadow-2xl border border-slate-100 dark:border-slate-800 text-right space-y-5"
>
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-emerald-500 animate-ping" />
<span id="ai-modal-title" class="font-bold text-slate-800 dark:text-white text-sm">{{ content.title }}</span>
</div>
<button type="button" aria-label="بستن" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="ai.close()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">{{ content.queryLabel }}</div>
<div class="text-sm font-semibold text-slate-800 dark:text-slate-200 bg-slate-50 dark:bg-slate-800 p-3 rounded-xl border border-slate-200 dark:border-slate-700">
«{{ ai.query.value || content.fallbackQuery }}»
</div>
</div>
<div class="space-y-3">
<div class="text-xs font-bold text-brand-600 dark:text-brand-400">{{ content.packageLabel }}</div>
<div class="bg-brand-50/70 dark:bg-brand-950/40 border border-brand-200 dark:border-brand-800/60 rounded-2xl p-4 space-y-2">
<div class="flex items-center justify-between">
<span class="font-bold text-slate-900 dark:text-white text-sm">{{ content.packageTitle }}</span>
<span class="text-xs bg-brand-600 text-white px-2 py-0.5 rounded-full font-bold">{{ content.matchBadge }}</span>
</div>
<ul class="text-xs text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
<li v-for="item in content.items" :key="item">{{ item }}</li>
</ul>
</div>
</div>
<div class="flex gap-3 pt-2">
<button type="button" class="flex-1 py-3 rounded-xl bg-brand-600 text-white text-sm font-bold shadow-md hover:bg-brand-700 transition-colors" @click="consultThisPackage">
{{ content.primaryCta }}
</button>
<button type="button" class="px-5 py-3 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" @click="ai.close()">
{{ content.closeLabel }}
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const hero = section("hero");
const content = computed(() => hero.value.ai);
const ai = useAiModal();
const consult = useConsultModal();
useEscapeKey(ai.isOpen, ai.close);
function consultThisPackage() {
ai.close();
consult.open({ source: "ai", aiQuery: ai.query.value });
}
</script>
@@ -0,0 +1,140 @@
<template>
<transition
appear
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="consult.isOpen.value" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 dark:bg-black/75 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="consult-modal-title"
class="bg-white dark:bg-slate-900 rounded-3xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-100 dark:border-slate-800 text-right space-y-5"
>
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3">
<h3 id="consult-modal-title" class="font-bold text-slate-900 dark:text-white text-base">{{ content.title }}</h3>
<button type="button" aria-label="بستن" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="consult.close()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label for="consult-name" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.nameLabel }}</label>
<input
id="consult-name"
v-model="name"
required
type="text"
autocomplete="name"
maxlength="120"
:placeholder="content.namePlaceholder"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40"
>
</div>
<div>
<label for="consult-phone" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.phoneLabel }}</label>
<input
id="consult-phone"
v-model="phone"
required
type="tel"
dir="ltr"
autocomplete="tel"
inputmode="tel"
maxlength="20"
:placeholder="content.phonePlaceholder"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40 text-left"
>
</div>
<div>
<label for="consult-type" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.typeLabel }}</label>
<select
id="consult-type"
v-model="consult.type.value"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40"
>
<option v-for="opt in content.types" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div v-if="submitted" role="status" class="p-3 bg-emerald-50 dark:bg-emerald-950/60 text-emerald-700 dark:text-emerald-300 text-xs rounded-xl font-medium border border-emerald-200 dark:border-emerald-800/50">
{{ content.successMessage }}
</div>
<div v-else-if="failed" role="alert" class="p-3 bg-rose-50 dark:bg-rose-950/60 text-rose-700 dark:text-rose-300 text-xs rounded-xl font-medium border border-rose-200 dark:border-rose-800/50">
{{ content.errorMessage }}
</div>
<div class="pt-2">
<button type="submit" class="w-full py-3 rounded-xl bg-brand-600 text-white text-sm font-bold hover:bg-brand-700 shadow-md transition-colors">
{{ content.submitLabel }}
</button>
</div>
</form>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import type { LeadInput } from "@montech/shared";
const CLOSE_DELAY_MS = 2000;
const { section } = useSiteContent();
const content = section("consult");
const consult = useConsultModal();
const name = ref("");
const phone = ref("");
const submitted = ref(false);
const failed = ref(false);
let sending = false;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
useEscapeKey(consult.isOpen, consult.close);
watch(consult.isOpen, (open) => {
if (open) {
submitted.value = false;
failed.value = false;
}
});
async function submit() {
if (sending) return;
sending = true;
failed.value = false;
const payload: LeadInput = {
name: name.value.trim(),
phone: phone.value.trim(),
businessType: consult.type.value,
source: consult.source.value,
aiQuery: consult.aiQuery.value || undefined,
};
try {
await apiFetch("/api/public/leads", { method: "POST", body: payload });
submitted.value = true;
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
consult.close();
name.value = "";
phone.value = "";
consult.resetType();
submitted.value = false;
}, CLOSE_DELAY_MS);
} catch {
failed.value = true;
} finally {
sending = false;
}
}
onBeforeUnmount(() => clearTimeout(closeTimer));
</script>
@@ -0,0 +1,23 @@
<template>
<div class="text-center max-w-2xl mx-auto mb-16 space-y-2 fade-item">
<nav v-if="crumbs?.length" aria-label="مسیر صفحه" class="text-xs font-bold text-slate-400 dark:text-slate-500">
<ol class="inline-flex items-center gap-2">
<li v-for="(c, i) in crumbs" :key="c.to" class="inline-flex items-center gap-2">
<NuxtLink v-if="i < crumbs.length - 1" :to="c.to" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ c.label }}</NuxtLink>
<span v-else aria-current="page">{{ c.label }}</span>
<span v-if="i < crumbs.length - 1" aria-hidden="true">/</span>
</li>
</ol>
</nav>
<h1 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">{{ title }}</h1>
<p v-if="subtitle" class="text-sm sm:text-base text-slate-500 dark:text-slate-400 font-normal">{{ subtitle }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string;
subtitle?: string;
crumbs?: Array<{ label: string; to: string }>;
}>();
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<img
v-if="src && !failed"
ref="imgEl"
:src="src"
:alt="alt"
loading="lazy"
decoding="async"
@error="failed = true"
>
</template>
<script setup lang="ts">
/** Lazy image that removes itself on load failure so a broken-image icon never shows inside the design. */
const props = defineProps<{ src?: string | null; alt: string }>();
const failed = ref(false);
const imgEl = ref<HTMLImageElement | null>(null);
watch(() => props.src, () => (failed.value = false));
onMounted(() => {
// A server-rendered image can fail before hydration attaches the error listener.
const img = imgEl.value;
if (img && img.complete && img.naturalWidth === 0) failed.value = true;
});
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { CSSProperties } from "vue";
const REST: CSSProperties = {
transform: "perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(0px) scale3d(1, 1, 1)",
};
/** Pointer-follow 3D tilt + spotlight position for a card, batched to one update per frame. */
export function useCardTilt() {
const style = ref<CSSProperties>({});
let frame = 0;
let el: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
el.style.setProperty("--mouse-x", `${x}px`);
el.style.setProperty("--mouse-y", `${y}px`);
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -7;
const rotateY = ((x - centerX) / centerX) * 7;
style.value = {
transform: `perspective(1000px) rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateY(-6px) scale3d(1.02, 1.02, 1.02)`,
};
};
function onMouseMove(e: MouseEvent) {
el = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
el = null;
style.value = REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { style, onMouseMove, onMouseLeave };
}
+18
View File
@@ -0,0 +1,18 @@
import type { Ref } from "vue";
/** Calls `handler` on Escape while `active` is true. */
export function useEscapeKey(active: Ref<boolean>, handler: () => void) {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") handler();
};
watch(
active,
(on) => {
if (!import.meta.client) return;
if (on) document.addEventListener("keydown", onKey);
else document.removeEventListener("keydown", onKey);
},
{ immediate: true },
);
onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
}
@@ -0,0 +1,55 @@
const LAPTOP_REST = "transform: rotateY(-14deg) rotateX(8deg) rotateZ(-1deg);";
const MOBILE_REST = "transform: rotateY(-10deg) rotateX(6deg) translateY(-12px);";
/**
* Mouse-driven 3D tilt for the hero devices. Pointer events are coalesced into one
* layout read + one style write per animation frame.
*/
export function useHeroParallax() {
const laptop = ref(LAPTOP_REST);
const mobile = ref(MOBILE_REST);
let frame = 0;
let stage: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!stage) return;
const rect = stage.getBoundingClientRect();
const x = (clientX - rect.left) / rect.width - 0.5;
const y = (clientY - rect.top) / rect.height - 0.5;
const laptopRotateY = -14 + x * 14;
const laptopRotateX = 8 - y * 12;
const mobileRotateY = -10 + x * 20;
const mobileRotateX = 6 - y * 15;
const mobileTransY = -12 + y * 18;
const mobileTransX = x * 15;
laptop.value = `transform: rotateY(${laptopRotateY.toFixed(2)}deg) rotateX(${laptopRotateX.toFixed(2)}deg) rotateZ(-1deg);`;
mobile.value = `transform: rotateY(${mobileRotateY.toFixed(2)}deg) rotateX(${mobileRotateX.toFixed(2)}deg) translateY(${mobileTransY.toFixed(2)}px) translateX(${mobileTransX.toFixed(2)}px);`;
};
function onMouseMove(e: MouseEvent) {
stage = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
stage = null;
laptop.value = LAPTOP_REST;
mobile.value = MOBILE_REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { laptop, mobile, onMouseMove, onMouseLeave };
}
@@ -0,0 +1,46 @@
import type { Ref } from "vue";
/**
* Scroll-scrubbed hero device parallax. GSAP is imported only on the client after mount,
* keeping it off the critical rendering path; the context is reverted on unmount.
*/
export function useHeroScrollParallax(sectionEl: Ref<HTMLElement | null>) {
let revert: (() => void) | null = null;
let disposed = false;
onMounted(async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([import("gsap"), import("gsap/ScrollTrigger")]);
const root = sectionEl.value;
if (disposed || !root) return;
gsap.registerPlugin(ScrollTrigger);
const laptop = root.querySelector<HTMLElement>("#hero-laptop");
const mobile = root.querySelector<HTMLElement>("#hero-mobile");
if (!laptop || !mobile) return;
const ctx = gsap.context(() => {
gsap.to(laptop, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.2 },
y: -40,
rotateY: -25,
rotateX: 13,
scale: 0.98,
ease: "none",
});
gsap.to(mobile, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.5 },
y: -85,
rotateY: -6,
rotateX: 4,
scale: 1.03,
ease: "none",
});
}, root);
revert = () => ctx.revert();
});
onBeforeUnmount(() => {
disposed = true;
revert?.();
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { LeadSource } from "@montech/shared";
const DEFAULT_CONSULT_TYPE = "طلا و جواهر";
/** Consultation modal state shared by the header, hero, solution cards, banners and the AI modal. */
export function useConsultModal() {
const isOpen = useState("consult-open", () => false);
const mounted = useState("consult-mounted", () => false);
const type = useState("consult-type", () => DEFAULT_CONSULT_TYPE);
const source = useState<LeadSource>("consult-source", () => "consult");
const aiQuery = useState<string>("consult-ai-query", () => "");
function open(opts: { type?: string; source?: LeadSource; aiQuery?: string } = {}) {
if (opts.type) type.value = opts.type;
source.value = opts.source ?? "consult";
aiQuery.value = opts.aiQuery ?? "";
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function resetType() {
type.value = DEFAULT_CONSULT_TYPE;
}
return { isOpen, mounted, type, source, aiQuery, open, close, resetType };
}
export function useAiModal() {
const isOpen = useState("ai-open", () => false);
const mounted = useState("ai-mounted", () => false);
const query = useState("ai-query", () => "");
function open() {
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
return { isOpen, mounted, query, open, close };
}

Some files were not shown because too many files have changed in this diff Show More