Make admin section editor form-based and localize Montech site to Persian.

Operators can edit landing copy as plain fields instead of JSON/HTML; default CMS seed and UI chrome are now Farsi/RTL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 20:33:37 +03:30
co-authored by Cursor
parent 0f4363174c
commit 3410f3e5ed
22 changed files with 462 additions and 218 deletions
+8 -8
View File
@@ -20,28 +20,28 @@
<textarea v-model="form.excerpt" class="textarea" /> <textarea v-model="form.excerpt" class="textarea" />
</div> </div>
<div class="field"> <div class="field">
<label class="label">محتوا (HTML)</label> <label class="label">متن پست</label>
<textarea v-model="form.content" class="textarea" dir="ltr" style="min-height: 240px" /> <textarea v-model="form.content" class="textarea" style="min-height: 240px" placeholder="متن را عادی بنویسید. برای پاراگراف جدید Enter بزنید." />
</div> </div>
<div class="grid-2"> <div class="grid-2">
<div class="field"> <div class="field">
<label class="label">کاور URL</label> <label class="label">آدرس تصویر کاور</label>
<input v-model="form.coverUrl" class="input" dir="ltr" /> <input v-model="form.coverUrl" class="input" dir="ltr" placeholder="از بخش رسانه آپلود کنید و آدرس را اینجا بگذارید" />
</div> </div>
<div class="field"> <div class="field">
<label class="label">SEO Title</label> <label class="label">عنوان سئو</label>
<input v-model="form.seoTitle" class="input" /> <input v-model="form.seoTitle" class="input" />
</div> </div>
<div class="field"> <div class="field">
<label class="label">SEO Description</label> <label class="label">توضیح سئو</label>
<input v-model="form.seoDesc" class="input" /> <input v-model="form.seoDesc" class="input" />
</div> </div>
<div class="field"> <div class="field">
<label class="label">Canonical</label> <label class="label">آدرس Canonical</label>
<input v-model="form.canonical" class="input" dir="ltr" /> <input v-model="form.canonical" class="input" dir="ltr" />
</div> </div>
<div class="field"> <div class="field">
<label class="label">OG Image</label> <label class="label">تصویر اشتراک‌گذاری</label>
<input v-model="form.ogImage" class="input" dir="ltr" /> <input v-model="form.ogImage" class="input" dir="ltr" />
</div> </div>
<div class="field"> <div class="field">
+225 -12
View File
@@ -1,40 +1,253 @@
<template> <template>
<div> <div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem"> <div class="row" style="justify-content: space-between; margin-bottom: 1rem">
<h1>بخش: {{ key }}</h1> <h1>{{ titleFa }}</h1>
<RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink> <RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink>
</div> </div>
<div class="card">
<textarea v-model="jsonText" class="textarea" dir="ltr" style="min-height: 420px" /> <form v-if="ready" class="card" @submit.prevent="save">
<p v-if="msg" class="muted">{{ msg }}</p> <!-- HERO -->
<button class="btn" @click="save">ذخیره</button> <template v-if="key === 'hero'">
</div> <div class="field"><label class="label">برچسب بالای صفحه</label><input v-model="form.eyebrow" class="input" /></div>
<div class="field"><label class="label">محل / سال تأسیس</label><input v-model="form.location" class="input" /></div>
<div class="field"><label class="label">خط ۱ عنوان</label><input v-model="form.lines[0]" class="input" /></div>
<div class="field"><label class="label">خط ۲ عنوان</label><input v-model="form.lines[1]" class="input" /></div>
<div class="field"><label class="label">خط ۳ عنوان</label><input v-model="form.lines[2]" class="input" /></div>
<div class="field"><label class="label">کلمهٔ برجسته (رنگی)</label><input v-model="form.highlightWord" class="input" /></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>
<!-- MARQUEE -->
<template v-else-if="key === 'marquee'">
<div class="field">
<label class="label">موارد نوار متحرک (هر خط یک مورد)</label>
<textarea v-model="marqueeText" class="textarea" style="min-height: 200px" />
</div>
</template>
<!-- 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>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', cat: '', year: '' })">+ افزودن پروژه</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>
</div>
<button type="button" class="btn ghost" @click="form.members.push({ name: '', role: '' })">+ افزودن عضو</button>
</template>
<!-- FOOTER -->
<template v-else-if="key === 'footer'">
<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>
<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><textarea v-model="footerAddressText" class="textarea" /></div>
<div class="field"><label class="label">شبکه‌های اجتماعی (هر خط یک مورد)</label><textarea v-model="footerSocialsText" class="textarea" /></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 { onMounted, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { api } from "../lib/api"; import { api } from "../lib/api";
const TITLES: Record<string, string> = {
hero: "صفحه اول (هیرو)",
marquee: "نوار متحرک",
manifesto: "معرفی / مانیفست",
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const route = useRoute(); const route = useRoute();
const key = String(route.params.key); const key = String(route.params.key);
const jsonText = ref("{}"); const titleFa = computed(() => TITLES[key] || key);
const ready = ref(false);
const msg = ref(""); const msg = ref("");
const form = reactive<any>({});
const marqueeText = ref("");
const manifestoWordsText = ref("");
const footerMarqueeText = ref("");
const footerAddressText = ref("");
const footerSocialsText = ref("");
function splitTags(v: string) {
return 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}`); const { data } = await api.get(`/admin/sections/${key}`);
jsonText.value = JSON.stringify(data.data, null, 2); Object.assign(form, structuredClone(data.data));
if (key === "hero") {
form.lines = Array.isArray(form.lines) ? form.lines : ["", "", ""];
while (form.lines.length < 3) form.lines.push("");
}
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 === "process") form.steps = form.steps || [];
if (key === "team") form.members = form.members || [];
if (key === "footer") {
footerMarqueeText.value = (form.marquee || []).join("\n");
footerAddressText.value = (form.address || []).join("\n");
footerSocialsText.value = (form.socials || []).join("\n");
}
ready.value = true;
}); });
function buildPayload() {
if (key === "marquee") return { items: marqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean) };
if (key === "manifesto") {
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) };
}
if (key === "footer") {
return {
...form,
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
address: footerAddressText.value.split("\n").map((s) => s.trim()).filter(Boolean),
socials: footerSocialsText.value.split("\n").map((s) => s.trim()).filter(Boolean),
};
}
return { ...form };
}
async function save() { async function save() {
msg.value = ""; msg.value = "";
try { try {
const parsed = JSON.parse(jsonText.value); await api.put(`/admin/sections/${key}`, { data: buildPayload() });
await api.put(`/admin/sections/${key}`, { data: parsed });
msg.value = "ذخیره شد."; msg.value = "ذخیره شد.";
} catch { } catch {
msg.value = "JSON نامعتبر یا خطای سرور."; msg.value = "خطا در ذخیره.";
} }
} }
</script> </script>
<style scoped>
.item-box {
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem;
margin-bottom: 0.9rem;
background: #12151b;
}
</style>
+18 -3
View File
@@ -1,14 +1,14 @@
<template> <template>
<div> <div>
<h1>محتوای لندینگ</h1> <h1>محتوای لندینگ</h1>
<p class="muted">هر بخش JSON ساخت‌یافته دارد.</p> <p class="muted">متن‌ها را مثل فرم معمولی ویرایش کنید — نیازی به کدنویسی نیست.</p>
<table class="table"> <table class="table">
<thead> <thead>
<tr><th>کلید</th><th>آخرین ویرایش</th><th></th></tr> <tr><th>بخش</th><th>آخرین ویرایش</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="s in sections" :key="s.id"> <tr v-for="s in sections" :key="s.id">
<td>{{ s.key }}</td> <td>{{ label(s.key) }}</td>
<td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td> <td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td>
<td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td> <td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td>
</tr> </tr>
@@ -21,7 +21,22 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../lib/api"; import { api } from "../lib/api";
const TITLES: Record<string, string> = {
hero: "صفحه اول (هیرو)",
marquee: "نوار متحرک",
manifesto: "معرفی",
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const sections = ref<any[]>([]); const sections = ref<any[]>([]);
function label(key: string) {
return TITLES[key] || key;
}
onMounted(async () => { onMounted(async () => {
const { data } = await api.get("/admin/sections"); const { data } = await api.get("/admin/sections");
sections.value = data; sections.value = data;
+113 -103
View File
@@ -5,127 +5,127 @@ const prisma = new PrismaClient();
const defaultSections: Record<string, unknown> = { const defaultSections: Record<string, unknown> = {
hero: { hero: {
eyebrow: "SOFTWARE DEVELOPMENT GROUP", eyebrow: "گروه توسعه نرم‌افزار",
location: "[ EST. 2020 — TEHRAN / REMOTE ]", location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]",
lines: ["We craft", "software that", "performs"], lines: ["ما نرم‌افزار", "می‌سازیم که", "عمل می‌کند"],
highlightWord: "software", highlightWord: "نرم‌افزار",
body: "We are a senior engineering group shipping web platforms, mobile apps and cloud infrastructure for companies that refuse to blend in.", body: "مانتک یک تیم مهندسی ارشد است که پلتفرم‌های وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسب‌وکارهایی می‌سازد که نمی‌خواهند معمولی باشند.",
cta: "SEE OUR WORK", cta: "مشاهده نمونه‌کارها",
badge: "MONTECH ENGINEERING • SINCE 2020 •", badge: "مهندسی مانتک • از ۱۴۰۰ •",
coords: "35.6892° N / 51.3890° E", coords: "تهران، ایران",
projectsLabel: "120+ PROJECTS SHIPPED", projectsLabel: "بیش از ۱۲۰ پروژه تحویل‌شده",
}, },
marquee: { marquee: {
items: [ items: [
"WEB PLATFORMS", "پلتفرم وب",
"MOBILE APPS", "اپلیکیشن موبایل",
"CLOUD & DEVOPS", "ابرو DevOps",
"AI SOLUTIONS", "هوش مصنوعی",
"DESIGN SYSTEMS", "سیستم طراحی",
"API ARCHITECTURE", "معماری API",
], ],
}, },
manifesto: { manifesto: {
tag: "Who we are", tag: "ما که هستیم",
words: [ words: [
{ text: "We", outline: false }, { text: "ما" },
{ text: "build", outline: false }, { text: "نرم‌افزاری" },
{ text: "software", outline: true }, { text: "می‌سازیم", volt: true },
{ text: "that", outline: false }, { text: "که" },
{ text: "moves", outline: false }, { text: "تفاوت" },
{ text: "the", outline: false }, { text: "ایجاد" },
{ text: "needle.", outline: false }, { text: "کند." },
{ text: "MONTECH", outline: false, volt: true }, { text: "مانتک", volt: true },
], ],
body: "Strategy, design and engineering under one roof — shipped with obsessive attention to performance and craft.", body: "استراتژی، طراحی و مهندسی زیر یک سقف — با دقت وسواس‌گونه نسبت به کیفیت و عملکرد.",
}, },
services: { services: {
tag: "What we do", tag: "چه می‌کنیم",
items: [ items: [
{ {
n: "01", n: "۰۱",
title: "Product Strategy", title: "استراتژی محصول",
desc: "We turn fuzzy ideas into validated roadmaps. Discovery sprints, user research and rapid prototyping de-risk your build before a single line of code is written.", desc: "ایده‌های مبهم را به نقشه راه معتبر تبدیل می‌کنیم. اسپرینت کشف، پژوهش کاربر و نمونه‌سازی سریع قبل از نوشتن کد.",
tags: ["Discovery Sprints", "User Research", "Roadmapping", "MVP Scoping"], tags: ["کشف محصول", "پژوهش کاربر", "نقشه راه", "MVP"],
}, },
{ {
n: "02", n: "۰۲",
title: "Web Engineering", title: "مهندسی وب",
desc: "Blazing-fast, scalable web platforms with modern stacks. From marketing sites to complex SaaS dashboards — engineered for performance, accessibility and conversion.", desc: "پلتفرم‌های وب سریع و مقیاس‌پذیر — از سایت‌های بازاریابی تا داشبوردهای پیچیده SaaS.",
tags: ["React / Next.js", "Nuxt", "Node.js", "NestJS"], tags: ["Nuxt", "NestJS", "Node.js", "React"],
}, },
{ {
n: "03", n: "۰۳",
title: "Mobile Applications", title: "اپلیکیشن موبایل",
desc: "Native-quality iOS and Android experiences from a single codebase — or fully native when it matters.", desc: "تجربهٔ بومی‌گونه روی iOS و Android از یک کدبیس مشترک — یا کاملاً نیتیو وقتی لازم باشد.",
tags: ["React Native", "Flutter", "Swift", "Kotlin"], tags: ["React Native", "Flutter", "Swift", "Kotlin"],
}, },
{ {
n: "04", n: "۰۴",
title: "Cloud & DevOps", title: "ابرو DevOps",
desc: "Infrastructure that scales with your ambition. Resilient cloud systems, automated deployments, secure and observable platforms.", desc: "زیرساختی که با رشد شما مقیاس می‌شود؛ استقرار خودکار، امنیت و مانیتورینگ.",
tags: ["AWS / GCP", "Kubernetes", "Terraform", "CI/CD"], tags: ["AWS / GCP", "کوبرنتیز", "CI/CD", "Terraform"],
}, },
{ {
n: "05", n: "۰۵",
title: "AI & Data", title: "هوش مصنوعی و داده",
desc: "Practical AI, not science projects. LLM integrations, intelligent automation and data pipelines.", desc: "هوش مصنوعی کاربردی؛ اتصال LLM، اتوماسیون هوشمند و خطوط لوله داده.",
tags: ["LLM Integration", "ML Pipelines", "Data Engineering", "Analytics"], tags: ["LLM", "اتوماسیون", "مهندسی داده", "تحلیل"],
}, },
], ],
}, },
work: { work: {
tag: "Selected work", tag: "نمونه کارها",
items: [ items: [
{ n: "01", title: "PULSEBOARD", cat: "Fintech Analytics Platform", year: "2024" }, { n: "۰۱", title: "پالس‌بورد", cat: "پلتفرم تحلیلی فین‌تک", year: "۱۴۰۳" },
{ n: "02", title: "AEROLOOP", cat: "Logistics SaaS", year: "2024" }, { n: "۰۲", title: "ایرو‌لوپ", cat: "نرم‌افزار لجستیک", year: "۱۴۰۳" },
{ n: "03", title: "MEDSYNC", cat: "HealthTech Platform", year: "2023" }, { n: "۰۳", title: "مد‌سینک", cat: "پلتفرم هلث‌تک", year: "۱۴۰۲" },
{ n: "04", title: "NOVAPAY", cat: "Payment Infrastructure", year: "2023" }, { n: "۰۴", title: "نوواپی", cat: "زیرساخت پرداخت", year: "۱۴۰۲" },
], ],
}, },
process: { process: {
tag: "How we ship", tag: "چگونه کار می‌کنیم",
steps: [ steps: [
{ n: "01", title: "DISCOVER", desc: "Workshops, research and alignment on outcomes that matter." }, { n: "۰۱", title: "کشف", desc: "کارگاه، پژوهش و هم‌راستایی روی نتایجی که واقعاً مهم‌اند." },
{ n: "02", title: "DESIGN", desc: "Systems, flows and interfaces that feel inevitable." }, { n: "۰۲", title: "طراحی", desc: "سیستم‌ها، جریان‌ها و رابط‌هایی که ساده و قابل‌اعتماد به نظر می‌رسند." },
{ n: "03", title: "DEVELOP", desc: "Senior engineers shipping production-ready software." }, { n: "۰۳", title: "توسعه", desc: "مهندسان ارشد که نرم‌افزار آمادهٔ تولید تحویل می‌دهند." },
{ n: "04", title: "DEPLOY & SCALE", desc: "CI/CD, observability and infrastructure that grows with you." }, { n: "۰۴", title: "استقرار و مقیاس", desc: "CI/CD، مانیتورینگ و زیرساختی که با شما رشد می‌کند." },
], ],
}, },
stats: { stats: {
items: [ items: [
{ value: 120, suffix: "+", label: "Projects shipped" }, { value: 120, suffix: "+", label: "پروژه تحویل‌شده" },
{ value: 40, suffix: "+", label: "Clients worldwide" }, { value: 40, suffix: "+", label: "مشتری" },
{ value: 15, suffix: "", label: "Engineers & designers" }, { value: 15, suffix: "", label: "مهندس و طراح" },
{ value: 98, suffix: "%", label: "Client retention" }, { value: 98, suffix: "%", label: "ماندگاری مشتری" },
], ],
}, },
team: { team: {
tag: "The collective", tag: "تیم",
headline: "Meet the collective", headline: "با جمع ما آشنا شوید",
body: "A tight crew of senior builders — product, design and engineering.", body: "تیمی جمع‌وجور از متخصصان محصول، طراحی و مهندسی.",
members: [ members: [
{ name: "Alexandra Reyes", role: "CEO & CO-FOUNDER" }, { name: "علی رضایی", role: "مدیرعامل و هم‌بنیان‌گذار" },
{ name: "Marcus Chen", role: "CTO & CO-FOUNDER" }, { name: "سارا محمدی", role: "مدیر فنی و هم‌بنیان‌گذار" },
{ name: "Sofia Laurent", role: "HEAD OF DESIGN" }, { name: "مریم حسینی", role: "سرپرست طراحی" },
{ name: "David Okafor", role: "PRINCIPAL ENGINEER" }, { name: "رضا کریمی", role: "مهندس ارشد" },
], ],
}, },
footer: { footer: {
tag: "Contact", tag: "تماس",
marquee: ["GOT A PROJECT IN MIND", "SAY HELLO", "LET'S WORK TOGETHER"], marquee: ["پروژه‌ای در ذهن دارید؟", "سلام کنید", "با هم کار کنیم"],
cta: "Start a project", cta: "شروع همکاری",
email: "hello@montech.ir", email: "hello@montech.ir",
phone: "+98 21 0000 0000", phone: "+98 21 0000 0000",
address: ["Tehran", "Iran"], address: ["تهران", "ایران"],
socials: ["LinkedIn", "GitHub", "Dribbble"], socials: ["لینکدین", "گیت‌هاب", "اینستاگرام"],
}, },
}; };
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";
const name = process.env.ADMIN_NAME || "Montech Admin"; const name = process.env.ADMIN_NAME || "مدیر مانتک";
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
await prisma.admin.upsert({ await prisma.admin.upsert({
@@ -138,46 +138,50 @@ async function main() {
where: { id: "default" }, where: { id: "default" },
create: { create: {
id: "default", id: "default",
brandName: "Montech", brandName: "مانتک",
tagline: "Software Development Group", tagline: "گروه توسعه نرم‌افزار",
email: "hello@montech.ir", email: "hello@montech.ir",
phone: "+98 21 0000 0000", phone: "+98 21 0000 0000",
address: "Tehran, IR", address: "تهران، ایران",
timezone: "Asia/Tehran", timezone: "Asia/Tehran",
socials: [ socials: [
{ label: "LinkedIn", url: "https://linkedin.com" }, { label: "لینکدین", url: "https://linkedin.com" },
{ label: "GitHub", url: "https://github.com" }, { label: "گیت‌هاب", url: "https://github.com" },
], ],
}, },
update: {}, update: {
brandName: "مانتک",
tagline: "گروه توسعه نرم‌افزار",
address: "تهران، ایران",
},
}); });
const pages = [ const pages = [
{ {
key: "home", key: "home",
title: "Home", title: "خانه",
seo: { seo: {
title: "Montech — Software Development Group", title: "مانتک — گروه توسعه نرم‌افزار",
description: description:
"Montech engineers high-performance web platforms, mobile apps and cloud infrastructure. Based in Tehran, shipping worldwide.", "مانتک پلتفرم‌های وب، اپلیکیشن موبایل و زیرساخت ابری با عملکرد بالا می‌سازد. مستقر در تهران، فعال در سراسر ایران.",
canonical: "https://montech.ir/", canonical: "https://montech.ir/",
}, },
}, },
{ {
key: "blog", key: "blog",
title: "Blog", title: "بلاگ",
seo: { seo: {
title: "Blog — Montech", title: "بلاگ — مانتک",
description: "Notes on product, engineering and shipping software.", description: "یادداشت‌هایی درباره محصول، مهندسی و ساخت نرم‌افزار.",
canonical: "https://montech.ir/blog", canonical: "https://montech.ir/blog",
}, },
}, },
{ {
key: "gallery", key: "gallery",
title: "Gallery", title: "گالری",
seo: { seo: {
title: "Gallery — Montech", title: "گالری — مانتک",
description: "Selected visuals from Montech projects and studio life.", description: "تصاویر منتخب از پروژه‌ها و فضای استودیو مانتک.",
canonical: "https://montech.ir/gallery", canonical: "https://montech.ir/gallery",
}, },
}, },
@@ -204,22 +208,28 @@ async function main() {
}); });
} }
const existingPost = await prisma.blogPost.findUnique({ where: { slug: "welcome-to-montech" } }); await prisma.blogPost.upsert({
if (!existingPost) { where: { slug: "welcome-to-montech" },
await prisma.blogPost.create({ create: {
data: { slug: "welcome-to-montech",
slug: "welcome-to-montech", title: "به مانتک خوش آمدید",
title: "Welcome to Montech", excerpt: "ما نرم‌افزاری می‌سازیم که واقعاً کار می‌کند — و اینجا درباره‌اش می‌نویسیم.",
excerpt: "We build software that performs — and this is where we write about it.", content:
content: "مانتک یک گروه توسعه نرم‌افزار است که پلتفرم وب، اپلیکیشن موبایل و زیرساخت ابری می‌سازد.\n\nدر این بلاگ درباره ساخت محصول، مهندسی و درس‌آموخته‌های استودیو می‌نویسیم.",
"<p>Montech is a software development group shipping web platforms, mobile apps and cloud infrastructure.</p><p>This blog covers product craft, engineering practice and lessons from the studio.</p>", published: true,
published: true, publishedAt: new Date(),
publishedAt: new Date(), seoTitle: "به مانتک خوش آمدید — بلاگ",
seoTitle: "Welcome to Montech — Blog", seoDesc: "معرفی مانتک و نحوه ساخت نرم‌افزار در تیم ما.",
seoDesc: "Introducing Montech and how we ship software.", },
}, update: {
}); title: "به مانتک خوش آمدید",
} excerpt: "ما نرم‌افزاری می‌سازیم که واقعاً کار می‌کند — و اینجا درباره‌اش می‌نویسیم.",
content:
"مانتک یک گروه توسعه نرم‌افزار است که پلتفرم وب، اپلیکیشن موبایل و زیرساخت ابری می‌سازد.\n\nدر این بلاگ درباره ساخت محصول، مهندسی و درس‌آموخته‌های استودیو می‌نویسیم.",
seoTitle: "به مانتک خوش آمدید — بلاگ",
seoDesc: "معرفی مانتک و نحوه ساخت نرم‌افزار در تیم ما.",
},
});
console.log("Seed complete. Admin:", mobile); console.log("Seed complete. Admin:", mobile);
} }
+4 -4
View File
@@ -8,10 +8,10 @@
--color-ash: #8b8b93; --color-ash: #8b8b93;
--color-volt: #c8ff2e; --color-volt: #c8ff2e;
--font-display: "Syne", ui-sans-serif, system-ui, sans-serif; --font-display: "Vazirmatn", "Syne", ui-sans-serif, system-ui, sans-serif;
--font-body: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; --font-body: "Vazirmatn", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Instrument Serif", ui-serif, Georgia, serif; --font-serif: "Vazirmatn", "Instrument Serif", ui-serif, Georgia, serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace; --font-mono: "Vazirmatn", ui-monospace, monospace;
} }
html { background: var(--color-ink); } html { background: var(--color-ink); }
+9 -9
View File
@@ -16,36 +16,36 @@
</div> </div>
<div class="flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between"> <div class="flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
<h1 class="font-display text-[clamp(3rem,12.5vw,11.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.03em]"> <h1 class="font-display text-[clamp(2.4rem,9vw,7.5rem)] font-extrabold leading-[1.1] tracking-[-0.02em]">
<span <span
v-for="(line, i) in data.lines" v-for="(line, i) in data.lines"
:key="i" :key="i"
class="block overflow-hidden pb-[0.08em] -mb-[0.08em] animate-fade-up" class="block overflow-hidden pb-[0.08em] -mb-[0.08em] animate-fade-up"
:style="{ animationDelay: `${0.15 + i * 0.12}s` }" :style="{ animationDelay: `${0.15 + i * 0.12}s` }"
> >
<template v-if="line.includes(data.highlightWord)"> <template v-if="data.highlightWord && line.includes(data.highlightWord)">
<span <span
v-html=" v-html="
line.replace( line.replace(
data.highlightWord, data.highlightWord,
`<span class='font-serif normal-case italic tracking-normal text-volt'>${data.highlightWord}</span>`, `<span class='text-volt'>${data.highlightWord}</span>`,
) )
" "
/> />
</template> </template>
<template v-else> <template v-else>
{{ line }}<span v-if="i === data.lines.length - 1" class="text-volt">.</span> {{ line }}
</template> </template>
</span> </span>
</h1> </h1>
<div class="flex shrink-0 flex-col items-start gap-10 animate-fade-up lg:items-end lg:pb-3" style="animation-delay: 0.55s"> <div class="flex shrink-0 flex-col items-start gap-10 animate-fade-up lg:items-start lg:pb-3" style="animation-delay: 0.55s">
<p class="max-w-sm text-sm leading-relaxed text-ash md:text-base lg:text-right"> <p class="max-w-sm text-sm leading-relaxed text-ash md:text-base">
{{ data.body }} {{ data.body }}
</p> </p>
<a <a
href="#work" href="#work"
class="group flex items-center gap-4 font-mono text-[11px] tracking-[0.3em] text-bone transition-colors hover:text-volt" class="group flex items-center gap-4 text-sm text-bone transition-colors hover:text-volt"
> >
{{ data.cta }} {{ data.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 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">
@@ -56,10 +56,10 @@
</div> </div>
<div <div
class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mt-20" class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 text-xs text-ash animate-fade-up md:mt-20"
style="animation-delay: 0.8s" style="animation-delay: 0.8s"
> >
<span>SCROLL</span> <span>اسکرول کنید</span>
<span class="hidden sm:block">{{ data.coords }}</span> <span class="hidden sm:block">{{ data.coords }}</span>
<span class="hidden sm:block">{{ data.projectsLabel }}</span> <span class="hidden sm:block">{{ data.projectsLabel }}</span>
</div> </div>
+3 -3
View File
@@ -1,12 +1,12 @@
<template> <template>
<section id="manifesto" class="px-6 py-28 md:px-10 md:py-40"> <section id="manifesto" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<SectionTag index="01" :title="data.tag" right="The short version" /> <SectionTag index="۰۱" :title="data.tag" right="نسخه کوتاه" />
<p class="mt-14 max-w-5xl font-display text-[clamp(2rem,6vw,5.5rem)] font-extrabold uppercase leading-[1.05] tracking-[-0.02em]"> <p class="mt-14 max-w-5xl font-display text-[clamp(1.8rem,5vw,4.2rem)] font-extrabold leading-[1.35] tracking-[-0.01em]">
<span <span
v-for="(w, i) in data.words" v-for="(w, i) in data.words"
:key="i" :key="i"
class="mr-[0.28em] inline-block" class="ml-[0.28em] inline-block"
:class="{ :class="{
'text-outline': w.outline, 'text-outline': w.outline,
'text-volt': w.volt, 'text-volt': w.volt,
+3 -3
View File
@@ -1,11 +1,11 @@
<template> <template>
<section id="process" class="px-6 py-28 md:px-10 md:py-40"> <section id="process" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<SectionTag index="04" :title="data.tag" right="12 weeks, typical" /> <SectionTag index="۰۴" :title="data.tag" right="معمولاً ۱۲ هفته" />
<div class="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-4"> <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"> <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.25em] text-volt">{{ step.n }}</p> <p class="text-sm text-volt">{{ step.n }}</p>
<h3 class="mt-4 font-display text-2xl font-bold uppercase tracking-[-0.02em]">{{ step.title }}</h3> <h3 class="mt-4 font-display text-xl font-bold md:text-2xl">{{ step.title }}</h3>
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p> <p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 font-mono text-[11px] tracking-[0.28em] text-ash"> <div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 text-sm text-ash">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-volt">{{ index }}</span> <span class="text-volt">{{ index }}</span>
<span class="text-bone">{{ title }}</span> <span class="text-bone">{{ title }}</span>
+6 -6
View File
@@ -1,7 +1,7 @@
<template> <template>
<section id="services" class="px-6 py-28 md:px-10 md:py-40"> <section id="services" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<SectionTag index="02" :title="data.tag" :right="`${String(data.items.length).padStart(2, '0')} Capabilities`" /> <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]"> <ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
<li <li
v-for="(s, i) in data.items" v-for="(s, i) in data.items"
@@ -11,20 +11,20 @@
> >
<div class="flex items-start justify-between gap-6"> <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"> <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.25em] text-ash">{{ s.n }}</span> <span class="text-sm text-ash">{{ s.n }}</span>
<h3 class="font-display text-2xl font-bold uppercase tracking-[-0.02em] md:text-4xl group-hover:text-volt transition-colors"> <h3 class="font-display text-2xl font-bold md:text-4xl group-hover:text-volt transition-colors">
{{ s.title }} {{ s.title }}
</h3> </h3>
</div> </div>
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span> <span class="text-volt">{{ open === i ? "—" : "+" }}</span>
</div> </div>
<div v-if="open === i" class="mt-6 max-w-3xl pl-0 md:pl-16"> <div v-if="open === i" class="mt-6 max-w-3xl">
<p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p> <p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<span <span
v-for="t in s.tags" v-for="t in s.tags"
:key="t" :key="t"
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.18em] text-ash" class="border border-white/10 px-3 py-1 text-xs text-ash"
>{{ t }}</span> >{{ t }}</span>
</div> </div>
</div> </div>
+13 -12
View File
@@ -4,43 +4,44 @@
<SiteMarquee <SiteMarquee
:items="data.marquee" :items="data.marquee"
:duration="20" :duration="20"
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl" class-name="font-display text-xl font-bold md:text-3xl"
/> />
</div> </div>
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28"> <div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
<SectionTag index="06" :title="data.tag" right="Response within 24h" /> <SectionTag index="۰۶" :title="data.tag" right="پاسخ تا ۲۴ ساعت" />
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]"> <div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
<div> <div>
<h2 class="font-display text-[clamp(2.5rem,7vw,6rem)] font-extrabold uppercase leading-[0.95] tracking-[-0.03em]"> <h2 class="font-display text-[clamp(2.2rem,6vw,4.5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
{{ data.cta }} {{ data.cta }}
</h2> </h2>
<a <a
:href="`mailto:${data.email}`" :href="`mailto:${data.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.22em] text-ink transition hover:bg-transparent hover:text-volt" class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 text-sm text-ink transition hover:bg-transparent hover:text-volt"
dir="ltr"
> >
{{ data.email }} ↗ {{ data.email }} ↗
</a> </a>
</div> </div>
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1"> <div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
<div> <div>
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">NEW BUSINESS</p> <p class="text-xs text-ash">کسب‌وکار جدید</p>
<p class="mt-2 text-sm">{{ data.email }}</p> <p class="mt-2 text-sm" dir="ltr">{{ data.email }}</p>
<p class="text-sm text-ash">{{ data.phone }}</p> <p class="text-sm text-ash" dir="ltr">{{ data.phone }}</p>
</div> </div>
<div> <div>
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">STUDIO</p> <p class="text-xs text-ash">استودیو</p>
<p v-for="line in data.address" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p> <p v-for="line in data.address" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
</div> </div>
<div> <div>
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">SOCIALS</p> <p class="text-xs text-ash">شبکه‌های اجتماعی</p>
<p v-for="s in data.socials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p> <p v-for="s in data.socials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
</div> </div>
</div> </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.2em] text-ash"> <div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 text-xs text-ash">
<span>© {{ year }} MONTECH SOFTWARE GROUP</span> <span>© {{ year }} گروه نرم‌افزاری مانتک</span>
<span>{{ brand }}®</span> <span>{{ brand || "مانتک" }}®</span>
</div> </div>
</div> </div>
</footer> </footer>
+10 -10
View File
@@ -4,28 +4,28 @@
<NuxtLink to="/" class="font-display text-lg font-extrabold tracking-tight"> <NuxtLink to="/" class="font-display text-lg font-extrabold tracking-tight">
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span> {{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
</NuxtLink> </NuxtLink>
<nav class="hidden items-center gap-8 font-mono text-[11px] tracking-[0.22em] text-ash md:flex"> <nav class="hidden items-center gap-6 text-sm text-ash md:flex">
<a href="#services" class="hover:text-volt">SERVICES</a> <a href="#services" class="hover:text-volt">خدمات</a>
<a href="#work" class="hover:text-volt">WORK</a> <a href="#work" class="hover:text-volt">نمونه کارها</a>
<a href="#team" class="hover:text-volt">TEAM</a> <a href="#team" class="hover:text-volt">تیم</a>
<NuxtLink to="/blog" class="hover:text-volt">BLOG</NuxtLink> <NuxtLink to="/blog" class="hover:text-volt">بلاگ</NuxtLink>
<NuxtLink to="/gallery" class="hover:text-volt">GALLERY</NuxtLink> <NuxtLink to="/gallery" class="hover:text-volt">گالری</NuxtLink>
<a href="#contact" class="text-bone hover:text-volt">CONTACT</a> <a href="#contact" class="text-bone hover:text-volt">تماس</a>
</nav> </nav>
<span class="font-mono text-[10px] tracking-[0.2em] text-ash tabular-nums">{{ time }}</span> <span class="text-xs text-ash tabular-nums" dir="ltr">{{ time }}</span>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ brand?: string; timezone?: string }>(); const props = defineProps<{ brand?: string; timezone?: string }>();
const brand = computed(() => props.brand || "Montech"); const brand = computed(() => props.brand || "مانتک");
const time = ref(""); const time = ref("");
onMounted(() => { onMounted(() => {
const tz = props.timezone || "Asia/Tehran"; const tz = props.timezone || "Asia/Tehran";
const tick = () => { const tick = () => {
time.value = new Intl.DateTimeFormat("en-GB", { time.value = new Intl.DateTimeFormat("fa-IR", {
timeZone: tz, timeZone: tz,
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
+1 -1
View File
@@ -5,7 +5,7 @@
<p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl"> <p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl">
{{ s.value }}{{ s.suffix }} {{ s.value }}{{ s.suffix }}
</p> </p>
<p class="mt-3 font-mono text-[11px] tracking-[0.22em] text-ash">{{ s.label }}</p> <p class="mt-3 text-sm text-ash">{{ s.label }}</p>
</div> </div>
</div> </div>
</section> </section>
+6 -9
View File
@@ -1,24 +1,21 @@
<template> <template>
<section id="team" class="px-6 py-28 md:px-10 md:py-40"> <section id="team" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<SectionTag index="05" :title="data.tag" right="Leadership" /> <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"> <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.6rem,8vw,7.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.02em]"> <h2 class="font-display text-[clamp(2.2rem,6vw,5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
{{ data.headline.split(" ").slice(0, -1).join(" ") }} {{ data.headline }}
<span class="font-serif normal-case italic tracking-normal text-volt">
{{ data.headline.split(" ").slice(-1)[0] }}
</span>
</h2> </h2>
<p class="max-w-xs text-sm leading-relaxed text-ash md:text-right">{{ data.body }}</p> <p class="max-w-xs text-sm leading-relaxed text-ash">{{ data.body }}</p>
</div> </div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> <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"> <figure v-for="m in data.members" :key="m.name" class="group">
<div class="aspect-[3/4] bg-gradient-to-br from-smoke to-ink" /> <div class="aspect-[3/4] bg-gradient-to-br from-smoke to-ink" />
<figcaption class="mt-4"> <figcaption class="mt-4">
<p class="font-display text-xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors"> <p class="font-display text-xl font-bold group-hover:text-volt transition-colors">
{{ m.name }} {{ m.name }}
</p> </p>
<p class="mt-1 font-mono text-[10px] tracking-[0.22em] text-ash">{{ m.role }}</p> <p class="mt-1 text-xs text-ash">{{ m.role }}</p>
</figcaption> </figcaption>
</figure> </figure>
</div> </div>
+3 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<section id="work" class="px-6 py-28 md:px-10 md:py-40"> <section id="work" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<SectionTag index="03" :title="data.tag" right="2023 — 2025" /> <SectionTag index="۰۳" :title="data.tag" right="۱۴۰۲ — ۱۴۰۴" />
<div class="mt-16 grid gap-6 md:grid-cols-2"> <div class="mt-16 grid gap-6 md:grid-cols-2">
<article <article
v-for="p in data.items" v-for="p in data.items"
@@ -11,8 +11,8 @@
<div class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink" /> <div class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink" />
<div class="flex items-end justify-between gap-4"> <div class="flex items-end justify-between gap-4">
<div> <div>
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">{{ p.n }} / {{ p.year }}</p> <p class="text-xs text-ash">{{ p.n }} / {{ p.year }}</p>
<h3 class="mt-2 font-display text-3xl font-extrabold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors"> <h3 class="mt-2 font-display text-2xl font-extrabold group-hover:text-volt transition-colors md:text-3xl">
{{ p.title }} {{ p.title }}
</h3> </h3>
<p class="mt-2 text-sm text-ash">{{ p.cat }}</p> <p class="mt-2 text-sm text-ash">{{ p.cat }}</p>
+2 -2
View File
@@ -16,13 +16,13 @@ export default defineNuxtConfig({
}, },
app: { app: {
head: { head: {
htmlAttrs: { lang: "en" }, htmlAttrs: { lang: "fa", dir: "rtl" },
link: [ link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" }, { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@300;400;500;600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap", href: "https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&family=Syne:wght@600;700;800&family=Instrument+Serif:ital@0;1&display=swap",
}, },
], ],
}, },
+16 -7
View File
@@ -1,15 +1,15 @@
<template> <template>
<article v-if="post" class="min-h-screen px-6 pb-24 pt-28 md:px-10"> <article v-if="post" class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar brand="Montech" /> <SiteNavbar brand="مانتک" />
<div class="mx-auto max-w-[760px]"> <div class="mx-auto max-w-[760px]">
<NuxtLink to="/blog" class="font-mono text-[11px] tracking-[0.22em] text-ash hover:text-volt">← BACK</NuxtLink> <NuxtLink to="/blog" class="text-sm text-ash hover:text-volt">→ بازگشت به بلاگ</NuxtLink>
<h1 class="mt-8 font-display text-4xl font-extrabold uppercase tracking-[-0.03em] md:text-6xl"> <h1 class="mt-8 font-display text-3xl font-extrabold leading-tight md:text-5xl">
{{ post.title }} {{ post.title }}
</h1> </h1>
<p class="mt-4 font-mono text-[11px] tracking-[0.2em] text-ash"> <p class="mt-4 text-sm text-ash">
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }} {{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("fa-IR") : "" }}
</p> </p>
<div class="prose-montech mt-10 space-y-4 text-base leading-relaxed text-bone/90" v-html="post.content" /> <div class="mt-10 whitespace-pre-line text-base leading-relaxed text-bone/90">{{ plainContent }}</div>
</div> </div>
</article> </article>
</template> </template>
@@ -20,9 +20,18 @@ const slug = String(route.params.slug);
const { data: post } = await useAsyncData(`blog-${slug}`, () => fetchPublicPost(slug)); const { data: post } = await useAsyncData(`blog-${slug}`, () => fetchPublicPost(slug));
if (!post.value) { if (!post.value) {
throw createError({ statusCode: 404, statusMessage: "Post not found" }); throw createError({ statusCode: 404, statusMessage: "پست پیدا نشد" });
} }
const plainContent = computed(() => {
const raw = post.value?.content || "";
return raw
.replace(/<\/p>/gi, "\n\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.trim();
});
useSeoMeta({ useSeoMeta({
title: post.value.seoTitle || post.value.title, title: post.value.seoTitle || post.value.title,
description: post.value.seoDesc || post.value.excerpt, description: post.value.seoDesc || post.value.excerpt,
+7 -7
View File
@@ -1,23 +1,23 @@
<template> <template>
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10"> <div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar :brand="'Montech'" /> <SiteNavbar brand="مانتک" />
<div class="mx-auto max-w-[1100px]"> <div class="mx-auto max-w-[1100px]">
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Blog</h1> <h1 class="font-display text-4xl font-extrabold md:text-6xl">بلاگ</h1>
<p class="mt-4 max-w-xl text-ash">Notes on product, engineering and shipping software.</p> <p class="mt-4 max-w-xl text-ash">یادداشت‌هایی درباره محصول، مهندسی و ساخت نرم‌افزار.</p>
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]"> <ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
<li v-for="post in posts" :key="post.id" class="py-8"> <li v-for="post in posts" :key="post.id" class="py-8">
<NuxtLink :to="`/blog/${post.slug}`" class="group block"> <NuxtLink :to="`/blog/${post.slug}`" class="group block">
<p class="font-mono text-[10px] tracking-[0.22em] text-ash"> <p class="text-sm text-ash">
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }} {{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("fa-IR") : "" }}
</p> </p>
<h2 class="mt-2 font-display text-2xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt md:text-3xl"> <h2 class="mt-2 font-display text-2xl font-bold group-hover:text-volt md:text-3xl">
{{ post.title }} {{ post.title }}
</h2> </h2>
<p class="mt-3 max-w-2xl text-sm text-ash">{{ post.excerpt }}</p> <p class="mt-3 max-w-2xl text-sm text-ash">{{ post.excerpt }}</p>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
<p v-if="!posts?.length" class="mt-10 text-ash">No posts yet.</p> <p v-if="!posts?.length" class="mt-10 text-ash">هنوز پستی منتشر نشده است.</p>
</div> </div>
</div> </div>
</template> </template>
+6 -6
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10"> <div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar brand="Montech" /> <SiteNavbar brand="مانتک" />
<div class="mx-auto max-w-[1500px]"> <div class="mx-auto max-w-[1500px]">
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Gallery</h1> <h1 class="font-display text-4xl font-extrabold md:text-6xl">گالری</h1>
<p class="mt-4 max-w-xl text-ash">Selected visuals from projects and studio life.</p> <p class="mt-4 max-w-xl text-ash">تصاویر منتخب از پروژه‌ها و فضای استودیو.</p>
<div class="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<figure v-for="item in items" :key="item.id" class="overflow-hidden border border-white/[0.07] bg-coal"> <figure v-for="item in items" :key="item.id" class="overflow-hidden border border-white/[0.07] bg-coal">
<div <div
@@ -11,12 +11,12 @@
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}" :style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
/> />
<figcaption class="p-4"> <figcaption class="p-4">
<p class="font-display text-lg font-bold uppercase">{{ item.title }}</p> <p class="font-display text-lg font-bold">{{ item.title }}</p>
<p v-if="item.category" class="mt-1 font-mono text-[10px] tracking-[0.2em] text-ash">{{ item.category }}</p> <p v-if="item.category" class="mt-1 text-xs text-ash">{{ item.category }}</p>
</figcaption> </figcaption>
</figure> </figure>
</div> </div>
<p v-if="!items?.length" class="mt-10 text-ash">Gallery is empty.</p> <p v-if="!items?.length" class="mt-10 text-ash">گالری خالی است.</p>
</div> </div>
</div> </div>
</template> </template>
+1 -1
View File
@@ -8,7 +8,7 @@
<SiteMarquee <SiteMarquee
:items="site.sections.marquee.items" :items="site.sections.marquee.items"
:duration="22" :duration="22"
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl" class-name="font-display text-xl font-bold md:text-3xl"
item-class-name="mx-4 md:mx-6" item-class-name="mx-4 md:mx-6"
separator-class-name="text-ink/50" separator-class-name="text-ink/50"
/> />
+3 -4
View File
@@ -1,8 +1,7 @@
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:15432/montech?schema=public" DATABASE_URL="postgresql://montech:*******@127.0.0.1:15432/montech?schema=public"PORT=3050
PORT=3050 JWT_SECRET="Xk7Q4jY8sT6aHc3bF1zUpR2mNv9wL"
JWT_SECRET="replace-with-long-random-secret"
CORS_ORIGIN="https://montech.ir,https://www.montech.ir" CORS_ORIGIN="https://montech.ir,https://www.montech.ir"
UPLOADS_DIR="/var/www/montech/uploads" UPLOADS_DIR="/var/www/montech/uploads"
ADMIN_MOBILE=09121234567 ADMIN_MOBILE=09128081392
ADMIN_PASSWORD=change-me-strong-platform-password ADMIN_PASSWORD=change-me-strong-platform-password
ADMIN_NAME=Montech Admin ADMIN_NAME=Montech Admin
+4 -4
View File
@@ -1,10 +1,10 @@
export const brand = { export const brand = {
id: "montech", id: "montech",
name: "Montech", name: "مانتک",
nameFa: "مانتک", nameFa: "مانتک",
fullName: "Montech", fullName: "مانتک",
company: "Montech", company: "مانتک",
tagline: "Software Development Group", tagline: "گروه توسعه نرم‌افزار",
taglineFa: "گروه توسعه نرم‌افزار", taglineFa: "گروه توسعه نرم‌افزار",
domain: "montech.ir", domain: "montech.ir",
email: "hello@montech.ir", email: "hello@montech.ir",