Hero issue fixes and visual changes
This commit is contained in:
@@ -8,12 +8,22 @@
|
|||||||
<form v-if="ready" class="card" @submit.prevent="save">
|
<form v-if="ready" class="card" @submit.prevent="save">
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<template v-if="key === 'hero'">
|
<template v-if="key === 'hero'">
|
||||||
|
<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>
|
<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.location" class="input" /></div>
|
||||||
<div class="field"><label class="label">خط ۱ عنوان</label><input v-model="form.lines[0]" class="input" /></div>
|
<div class="field">
|
||||||
<div class="field"><label class="label">خط ۲ عنوان</label><input v-model="form.lines[1]" class="input" /></div>
|
<label class="label">خطوط عنوان اصلی</label>
|
||||||
<div class="field"><label class="label">خط ۳ عنوان</label><input v-model="form.lines[2]" class="input" /></div>
|
<div v-for="(_line, i) in form.lines" :key="i" class="row" style="gap: 0.5rem; margin-bottom: 0.5rem">
|
||||||
<div class="field"><label class="label">کلمهٔ برجسته (رنگی)</label><input v-model="form.highlightWord" class="input" /></div>
|
<input v-model="form.lines[i]" class="input" :placeholder="`خط ${i + 1}`" />
|
||||||
|
<button type="button" class="btn danger" @click="form.lines.splice(i, 1)">حذف</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn ghost" @click="form.lines.push('')">+ افزودن خط</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><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.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.coords" class="input" /></div>
|
||||||
@@ -168,6 +178,7 @@ const form = reactive<any>({});
|
|||||||
const marqueeText = ref("");
|
const marqueeText = ref("");
|
||||||
const manifestoWordsText = ref("");
|
const manifestoWordsText = ref("");
|
||||||
const footerMarqueeText = ref("");
|
const footerMarqueeText = ref("");
|
||||||
|
const highlightWordsText = ref("");
|
||||||
|
|
||||||
function splitTags(v: string) {
|
function splitTags(v: string) {
|
||||||
return v
|
return v
|
||||||
@@ -199,8 +210,15 @@ onMounted(async () => {
|
|||||||
Object.assign(form, structuredClone(data.data));
|
Object.assign(form, structuredClone(data.data));
|
||||||
|
|
||||||
if (key === "hero") {
|
if (key === "hero") {
|
||||||
form.lines = Array.isArray(form.lines) ? form.lines : ["", "", ""];
|
form.lines = Array.isArray(form.lines) ? [...form.lines] : [""];
|
||||||
while (form.lines.length < 3) form.lines.push("");
|
if (!form.lines.length) form.lines.push("");
|
||||||
|
form.loaderTitle = form.loaderTitle || "هوشافزار منتک";
|
||||||
|
const words = Array.isArray(form.highlightWords) && form.highlightWords.length
|
||||||
|
? form.highlightWords
|
||||||
|
: form.highlightWord
|
||||||
|
? [form.highlightWord]
|
||||||
|
: [];
|
||||||
|
highlightWordsText.value = words.join("\n");
|
||||||
}
|
}
|
||||||
if (key === "marquee") marqueeText.value = (form.items || []).join("\n");
|
if (key === "marquee") marqueeText.value = (form.items || []).join("\n");
|
||||||
if (key === "manifesto") manifestoWordsText.value = wordsToText(form.words || []);
|
if (key === "manifesto") manifestoWordsText.value = wordsToText(form.words || []);
|
||||||
@@ -211,7 +229,8 @@ onMounted(async () => {
|
|||||||
if (key === "process") form.steps = form.steps || [];
|
if (key === "process") form.steps = form.steps || [];
|
||||||
if (key === "team") {
|
if (key === "team") {
|
||||||
form.members = (form.members || []).map((m: any) => ({ ...m, imageUrl: m.imageUrl || "" }));
|
form.members = (form.members || []).map((m: any) => ({ ...m, imageUrl: m.imageUrl || "" }));
|
||||||
} if (key === "footer") {
|
}
|
||||||
|
if (key === "footer") {
|
||||||
footerMarqueeText.value = (form.marquee || []).join("\n");
|
footerMarqueeText.value = (form.marquee || []).join("\n");
|
||||||
}
|
}
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
@@ -222,6 +241,19 @@ function buildPayload() {
|
|||||||
if (key === "manifesto") {
|
if (key === "manifesto") {
|
||||||
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) };
|
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) };
|
||||||
}
|
}
|
||||||
|
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") {
|
if (key === "footer") {
|
||||||
return {
|
return {
|
||||||
tag: form.tag,
|
tag: form.tag,
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ const defaultSections: Record<string, unknown> = {
|
|||||||
hero: {
|
hero: {
|
||||||
eyebrow: "گروه توسعه نرمافزار",
|
eyebrow: "گروه توسعه نرمافزار",
|
||||||
location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]",
|
location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]",
|
||||||
lines: ["ما نرمافزار", "میسازیم که", "عمل میکند"],
|
loaderTitle: "هوشافزار منتک",
|
||||||
highlightWord: "نرمافزار",
|
lines: [
|
||||||
|
"در هوشافزار منتک",
|
||||||
|
"بهترین نرمافزارها و خدمات",
|
||||||
|
"متناسب با کسبوکار شما پیشنهاد میشود",
|
||||||
|
],
|
||||||
|
highlightWord: "هوشافزار منتک",
|
||||||
|
highlightWords: ["هوشافزار منتک"],
|
||||||
body: "منتک یک تیم مهندسی ارشد است که پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسبوکارهایی میسازد که نمیخواهند معمولی باشند.",
|
body: "منتک یک تیم مهندسی ارشد است که پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسبوکارهایی میسازد که نمیخواهند معمولی باشند.",
|
||||||
cta: "مشاهده نمونهکارها",
|
cta: "مشاهده نمونهکارها",
|
||||||
badge: "مهندسی منتک • از ۱۴۰۰ •",
|
badge: "مهندسی منتک • از ۱۴۰۰ •",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative min-h-screen bg-ink text-bone selection:bg-volt selection:text-ink">
|
<div class="relative min-h-screen bg-ink text-bone selection:bg-volt selection:text-ink">
|
||||||
|
<ClientOnly>
|
||||||
|
<IntroLoader :title="loaderTitle" />
|
||||||
|
</ClientOnly>
|
||||||
<div class="noise" aria-hidden="true" />
|
<div class="noise" aria-hidden="true" />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const loaderTitle = useState("intro-loader-title", () => "هوشافزار منتک");
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* IRANSans (FaNum) — local webfonts */
|
/* IRANSans (FaNum) — local webfonts; paths quoted for special characters */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IRANSans";
|
font-family: "IRANSans";
|
||||||
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"),
|
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"),
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ html.lenis, html.lenis body { height: auto; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes marquee {
|
@keyframes marquee {
|
||||||
to { transform: translateX(-50%); }
|
from { transform: translate3d(0, 0, 0); }
|
||||||
|
to { transform: translate3d(-50%, 0, 0); }
|
||||||
}
|
}
|
||||||
.animate-marquee {
|
.animate-marquee {
|
||||||
animation: marquee var(--marquee-duration, 24s) linear infinite;
|
animation: marquee var(--marquee-duration, 28s) linear infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-marquee { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
@@ -72,14 +76,32 @@ html.lenis, html.lenis body { height: auto; }
|
|||||||
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
|
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
|
||||||
|
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
from { opacity: 0; transform: translateY(24px); }
|
from { opacity: 0; transform: translateY(28px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
.animate-fade-up {
|
.animate-fade-up {
|
||||||
animation: fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
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 {
|
.text-outline {
|
||||||
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
|
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
|
||||||
color: transparent;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="top" class="relative flex min-h-svh flex-col overflow-hidden">
|
<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-[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="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="relative flex flex-1 flex-col justify-end px-6 pb-12 pt-36 md:px-10 md:pb-16 md:pt-44">
|
||||||
<div
|
<div
|
||||||
class="mb-10 flex items-center justify-between font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mb-16 md:text-[11px]"
|
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"
|
style="animation-delay: 0.2s"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-3">
|
<span class="flex items-center gap-3">
|
||||||
@@ -15,51 +18,31 @@
|
|||||||
<span class="hidden md:block">{{ data.location }}</span>
|
<span class="hidden md:block">{{ data.location }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
|
<h1 class="w-full font-display text-[clamp(2.2rem,8.5vw,7.5rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">
|
||||||
<h1 class="font-display text-[clamp(2.4rem,9vw,7.5rem)] font-extrabold leading-[1.1] tracking-[-0.02em]">
|
<div
|
||||||
<span
|
v-for="(line, i) in data.lines"
|
||||||
v-for="(line, i) in data.lines"
|
:key="i"
|
||||||
:key="i"
|
class="overflow-hidden pb-[0.06em] -mb-[0.06em] 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` }"
|
>
|
||||||
>
|
<div class="flex flex-wrap items-baseline gap-x-[0.28em]">
|
||||||
<template v-if="data.highlightWord && line.includes(data.highlightWord)">
|
<span
|
||||||
<span
|
v-for="(token, ti) in tokenize(line)"
|
||||||
v-html="
|
:key="ti"
|
||||||
line.replace(
|
:class="token.volt ? 'font-accent text-volt' : undefined"
|
||||||
data.highlightWord,
|
>{{ token.text }}</span>
|
||||||
`<span class='text-volt'>${data.highlightWord}</span>`,
|
</div>
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ line }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{{ data.body }}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="#work"
|
|
||||||
class="group flex items-center gap-4 text-sm text-bone transition-colors hover:text-volt"
|
|
||||||
>
|
|
||||||
{{ 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>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</h1>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
style="animation-delay: 0.8s"
|
||||||
>
|
>
|
||||||
<span>اسکرول کنید</span>
|
<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.coords }}</span>
|
||||||
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
|
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,16 +51,54 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
data: {
|
data: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
location: string;
|
location: string;
|
||||||
lines: string[];
|
lines: string[];
|
||||||
highlightWord: string;
|
highlightWord?: string;
|
||||||
body: string;
|
highlightWords?: string[];
|
||||||
cta: string;
|
|
||||||
coords: string;
|
coords: string;
|
||||||
projectsLabel: 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>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<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>
|
||||||
@@ -2,18 +2,22 @@
|
|||||||
<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="۰۱" :title="data.tag" right="نسخه کوتاه" />
|
<SectionTag index="۰۱" :title="data.tag" right="نسخه کوتاه" />
|
||||||
<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
|
<div class="mt-14 flex flex-col gap-10 md:max-w-2xl animate-fade-up">
|
||||||
v-for="(w, i) in data.words"
|
<p class="text-lg leading-relaxed text-ash md:text-xl md:leading-relaxed">
|
||||||
:key="i"
|
{{ intro?.body || data.body }}
|
||||||
class="ml-[0.28em] inline-block"
|
</p>
|
||||||
:class="{
|
<a
|
||||||
'text-outline': w.outline,
|
v-if="intro?.cta"
|
||||||
'text-volt': w.volt,
|
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]"
|
||||||
>{{ w.text }}</span>
|
>
|
||||||
</p>
|
{{ intro.cta }}
|
||||||
<p class="mt-10 max-w-xl text-sm leading-relaxed text-ash md:text-base">{{ data.body }}</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,8 +26,12 @@
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
data: {
|
data: {
|
||||||
tag: string;
|
tag: string;
|
||||||
words: Array<{ text: string; outline?: boolean; volt?: boolean }>;
|
words?: Array<{ text: string; outline?: boolean; volt?: boolean }>;
|
||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
intro?: {
|
||||||
|
body?: string;
|
||||||
|
cta?: string;
|
||||||
|
} | null;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<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>
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
<SectionTag index="۰۴" :title="data.tag" right="معمولاً ۱۲ هفته" />
|
<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="text-sm text-volt">{{ step.n }}</p>
|
<p class="font-mono text-[11px] tracking-[0.2em] text-volt">{{ step.n }}</p>
|
||||||
<h3 class="mt-4 font-display text-xl font-bold md:text-2xl">{{ step.title }}</h3>
|
<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>
|
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<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-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">
|
<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>
|
||||||
|
|||||||
@@ -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="text-sm text-ash">{{ s.n }}</span>
|
<span class="font-mono text-[11px] tracking-[0.2em] text-ash">{{ s.n }}</span>
|
||||||
<h3 class="font-display text-2xl font-bold md:text-4xl group-hover:text-volt transition-colors">
|
<h3 class="font-display text-2xl font-bold tracking-[-0.02em] transition-colors md:text-4xl group-hover:text-volt">
|
||||||
{{ s.title }}
|
{{ s.title }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-volt">{{ open === i ? "—" : "+" }}</span>
|
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="open === i" class="mt-6 max-w-3xl">
|
<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>
|
<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 text-xs text-ash"
|
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.12em] text-ash"
|
||||||
>{{ t }}</span>
|
>{{ t }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<SiteMarquee
|
<SiteMarquee
|
||||||
:items="data.marquee || []"
|
:items="data.marquee || []"
|
||||||
:duration="20"
|
:duration="20"
|
||||||
class-name="font-display text-xl font-bold md:text-3xl"
|
class-name="font-display text-xl font-bold tracking-[-0.01em] md:text-3xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
<SectionTag index="۰۶" :title="data.tag || 'تماس'" right="پاسخ تا ۲۴ ساعت" />
|
<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.2rem,6vw,4.5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
|
<h2 class="font-display text-[clamp(2.4rem,6.5vw,5.5rem)] font-extrabold leading-[1.08] tracking-[-0.03em]">
|
||||||
{{ data.cta || "شروع همکاری" }}
|
{{ data.cta || "شروع همکاری" }}
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
<a
|
||||||
v-if="email"
|
v-if="email"
|
||||||
:href="`mailto:${email}`"
|
:href="`mailto:${email}`"
|
||||||
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"
|
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"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
{{ email }} ↗
|
{{ email }} ↗
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
</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="text-xs text-ash">کسبوکار جدید</p>
|
<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="email" class="mt-2 text-sm" dir="ltr">{{ email }}</p>
|
||||||
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
|
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">استودیو</p>
|
<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>
|
<p v-for="line in addressLines" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">شبکههای اجتماعی</p>
|
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">شبکههای اجتماعی</p>
|
||||||
<template v-if="socialLinks.length">
|
<template v-if="socialLinks.length">
|
||||||
<a
|
<a
|
||||||
v-for="s in socialLinks"
|
v-for="s in socialLinks"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</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 text-xs text-ash">
|
<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>© {{ year }} گروه نرمافزاری {{ brand || "منتک" }}</span>
|
||||||
<span>{{ brand || "منتک" }}®</span>
|
<span>{{ brand || "منتک" }}®</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-hidden" :class="className">
|
<div class="overflow-hidden" :class="className" dir="ltr">
|
||||||
<div class="animate-marquee flex w-max" :style="{ '--marquee-duration': `${duration}s` } as any">
|
<div
|
||||||
<div v-for="dup in 2" :key="dup" class="flex shrink-0 items-center">
|
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 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="mx-5 whitespace-nowrap md:mx-8" :class="itemClassName">{{ item }}</span>
|
||||||
<span class="text-volt/70" :class="separatorClassName">{{ separator }}</span>
|
<span class="text-current/40" :class="separatorClassName">{{ separator }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +25,7 @@ withDefaults(
|
|||||||
separator?: string;
|
separator?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
duration: 24,
|
duration: 28,
|
||||||
className: "",
|
className: "",
|
||||||
itemClassName: "",
|
itemClassName: "",
|
||||||
separatorClassName: "",
|
separatorClassName: "",
|
||||||
|
|||||||
@@ -12,15 +12,15 @@
|
|||||||
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
|
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<nav class="hidden items-center gap-6 text-sm text-ash md:flex">
|
<nav class="hidden items-center gap-7 font-mono text-[11px] tracking-[0.16em] text-ash md:flex">
|
||||||
<a href="/#services" class="hover:text-volt">خدمات</a>
|
<a href="/#services" class="transition-colors hover:text-volt">خدمات</a>
|
||||||
<a href="/#work" class="hover:text-volt">نمونه کارها</a>
|
<a href="/#work" class="transition-colors hover:text-volt">نمونهکارها</a>
|
||||||
<a href="/#team" class="hover:text-volt">تیم</a>
|
<a href="/#team" class="transition-colors hover:text-volt">تیم</a>
|
||||||
<NuxtLink to="/blog" class="hover:text-volt">بلاگ</NuxtLink>
|
<NuxtLink to="/blog" class="transition-colors hover:text-volt">بلاگ</NuxtLink>
|
||||||
<NuxtLink to="/gallery" class="hover:text-volt">گالری</NuxtLink>
|
<NuxtLink to="/gallery" class="transition-colors hover:text-volt">گالری</NuxtLink>
|
||||||
<a href="/#contact" class="text-bone hover:text-volt">تماس</a>
|
<a href="/#contact" class="text-bone transition-colors hover:text-volt">تماس</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="text-xs text-ash tabular-nums" dir="ltr">{{ time }}</span>
|
<span class="font-mono text-[12px] tracking-[0.14em] text-ash tabular-nums md:text-[13px]" dir="ltr">{{ time }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 text-sm text-ash">{{ s.label }}</p>
|
<p class="mt-3 font-mono text-[11px] tracking-[0.14em] text-ash md:tracking-[0.2em]">{{ s.label }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,22 +3,28 @@
|
|||||||
<div class="mx-auto max-w-[1500px]">
|
<div class="mx-auto max-w-[1500px]">
|
||||||
<SectionTag index="۰۵" :title="data.tag" right="رهبری" />
|
<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.2rem,6vw,5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
|
<h2 class="font-display text-[clamp(2.4rem,7vw,6.5rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
|
||||||
{{ data.headline }}
|
<template v-if="headlineParts.rest">
|
||||||
|
{{ headlineParts.rest }}
|
||||||
|
<span class="font-accent text-volt">{{ headlineParts.last }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ data.headline }}
|
||||||
|
</template>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="max-w-xs text-sm leading-relaxed text-ash">{{ 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
|
<div
|
||||||
class="aspect-[3/4] bg-gradient-to-br from-smoke to-ink bg-cover bg-center"
|
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})` } : {}"
|
:style="m.imageUrl ? { backgroundImage: `url(${m.imageUrl})` } : {}"
|
||||||
/>
|
/>
|
||||||
<figcaption class="mt-4">
|
<figcaption class="mt-4">
|
||||||
<p class="font-display text-xl font-bold group-hover:text-volt transition-colors">
|
<p class="font-display text-xl font-bold tracking-[-0.02em] transition-colors group-hover:text-volt">
|
||||||
{{ m.name }}
|
{{ m.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-ash">{{ m.role }}</p>
|
<p class="mt-1 font-mono text-[10px] tracking-[0.16em] text-ash">{{ m.role }}</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +33,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
data: {
|
data: {
|
||||||
tag: string;
|
tag: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
@@ -35,4 +41,13 @@ defineProps<{
|
|||||||
members: Array<{ name: string; role: string; imageUrl?: 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>
|
</script>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
<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="۰۳" :title="data.tag" right="نمونهکارها" />
|
<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"
|
||||||
:key="p.n"
|
:key="p.n"
|
||||||
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 md:p-8"
|
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 transition-colors hover:border-volt/30 md:p-8"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink bg-cover bg-center"
|
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})` } : {}"
|
:style="p.imageUrl ? { backgroundImage: `url(${p.imageUrl})` } : {}"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-end justify-between gap-4">
|
<div class="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">{{ p.n }} / {{ p.year }}</p>
|
<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 group-hover:text-volt transition-colors md:text-3xl">
|
<h3 class="mt-2 font-display text-2xl font-extrabold tracking-[-0.02em] transition-colors group-hover:text-volt 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>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-volt">↗</span>
|
<span class="text-volt transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">↗</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,17 +8,20 @@
|
|||||||
<main>
|
<main>
|
||||||
<HeroSection :data="site.sections.hero" />
|
<HeroSection :data="site.sections.hero" />
|
||||||
<section class="relative overflow-hidden py-16 md:py-24">
|
<section class="relative overflow-hidden py-16 md:py-24">
|
||||||
<div class="-ml-[5%] w-[110%] -rotate-2 bg-volt py-4 text-ink md:py-5">
|
<div class="w-[110%] -translate-x-[5%] -rotate-2 bg-volt py-4 text-ink md:py-5 rtl:translate-x-[5%]">
|
||||||
<SiteMarquee
|
<SiteMarquee
|
||||||
:items="site.sections.marquee.items"
|
:items="site.sections.marquee.items"
|
||||||
:duration="22"
|
:duration="26"
|
||||||
class-name="font-display text-xl font-bold md:text-3xl"
|
class-name="font-display text-xl font-bold tracking-[-0.01em] 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<ManifestoSection :data="site.sections.manifesto" />
|
<ManifestoSection
|
||||||
|
:data="site.sections.manifesto"
|
||||||
|
:intro="{ body: site.sections.hero.body, cta: site.sections.hero.cta }"
|
||||||
|
/>
|
||||||
<ServicesSection :data="site.sections.services" />
|
<ServicesSection :data="site.sections.services" />
|
||||||
<WorkSection :data="site.sections.work" />
|
<WorkSection :data="site.sections.work" />
|
||||||
<ProcessSection :data="site.sections.process" />
|
<ProcessSection :data="site.sections.process" />
|
||||||
@@ -46,4 +49,10 @@ const { data: site } = await useAsyncData("public-site", () => fetchPublicSite()
|
|||||||
});
|
});
|
||||||
const homePage = computed(() => site.value?.pages?.find((p: any) => p.key === "home"));
|
const homePage = computed(() => site.value?.pages?.find((p: any) => p.key === "home"));
|
||||||
applyPageSeo(homePage.value?.seo);
|
applyPageSeo(homePage.value?.seo);
|
||||||
|
|
||||||
|
const loaderTitle = useState("intro-loader-title", () => "هوشافزار منتک");
|
||||||
|
watchEffect(() => {
|
||||||
|
const hero = site.value?.sections?.hero as { loaderTitle?: string } | undefined;
|
||||||
|
if (hero?.loaderTitle) loaderTitle.value = hero.loaderTitle;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight local mock API for UI preview when Postgres isn't configured.
|
||||||
|
* Serves the same shape as Nest public endpoints from seed defaults.
|
||||||
|
*/
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const sections = {
|
||||||
|
hero: {
|
||||||
|
eyebrow: "گروه توسعه نرمافزار",
|
||||||
|
location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]",
|
||||||
|
loaderTitle: "هوشافزار منتک",
|
||||||
|
lines: [
|
||||||
|
"در هوشافزار منتک",
|
||||||
|
"بهترین نرمافزارها و خدمات",
|
||||||
|
"متناسب با کسبوکار شما پیشنهاد میشود",
|
||||||
|
],
|
||||||
|
highlightWord: "هوشافزار منتک",
|
||||||
|
highlightWords: ["هوشافزار منتک"],
|
||||||
|
body: "منتک یک تیم مهندسی ارشد است که پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسبوکارهایی میسازد که نمیخواهند معمولی باشند.",
|
||||||
|
cta: "مشاهده نمونهکارها",
|
||||||
|
badge: "مهندسی منتک • از ۱۴۰۰ •",
|
||||||
|
coords: "تهران، ایران",
|
||||||
|
projectsLabel: "بیش از ۱۲۰ پروژه تحویلشده",
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
items: [
|
||||||
|
"آبشده همکار",
|
||||||
|
"آبشده خانگی",
|
||||||
|
"فروشگاه آنلاین",
|
||||||
|
"سامانه بد و بس",
|
||||||
|
"سامانه فوری سند",
|
||||||
|
"مدیریت ارتباط با مشتری CRM",
|
||||||
|
"هوش مصنوعی",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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: "پلتفرمهای وب سریع و مقیاسپذیر.", tags: ["Nuxt", "NestJS", "Node.js", "React"] },
|
||||||
|
{ n: "۰۳", title: "اپلیکیشن موبایل", desc: "تجربهٔ بومیگونه روی iOS و Android.", tags: ["React Native", "Flutter"] },
|
||||||
|
{ n: "۰۴", title: "ابرو DevOps", desc: "استقرار خودکار، امنیت و مانیتورینگ.", tags: ["AWS / GCP", "کوبرنتیز", "CI/CD"] },
|
||||||
|
{ n: "۰۵", title: "هوش مصنوعی و داده", desc: "هوش مصنوعی کاربردی و خطوط لوله داده.", 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: "شروع همکاری",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const site = {
|
||||||
|
settings: {
|
||||||
|
brandName: "منتک",
|
||||||
|
tagline: "گروه توسعه نرمافزار",
|
||||||
|
email: "hello@montech.ir",
|
||||||
|
phone: "+98 21 0000 0000",
|
||||||
|
address: "تهران، ایران",
|
||||||
|
timezone: "Asia/Tehran",
|
||||||
|
logoUrl: null,
|
||||||
|
socials: [
|
||||||
|
{ label: "لینکدین", url: "https://linkedin.com" },
|
||||||
|
{ label: "گیتهاب", url: "https://github.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
key: "home",
|
||||||
|
title: "خانه",
|
||||||
|
seo: {
|
||||||
|
title: "منتک — گروه توسعه نرمافزار",
|
||||||
|
description: "منتک پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری میسازد.",
|
||||||
|
canonical: "http://localhost:3000/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const blog = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "welcome-to-montech",
|
||||||
|
title: "به منتک خوش آمدید",
|
||||||
|
excerpt: "ما نرمافزاری میسازیم که واقعاً کار میکند.",
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3050);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = req.url || "/";
|
||||||
|
const json = (data, code = 200) => {
|
||||||
|
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url === "/api/health") return json({ ok: true, mock: true });
|
||||||
|
if (url === "/api/public/site") return json(site);
|
||||||
|
if (url === "/api/public/blog") return json(blog);
|
||||||
|
if (url.startsWith("/api/public/blog/")) {
|
||||||
|
const slug = decodeURIComponent(url.split("/").pop() || "");
|
||||||
|
const post = blog.find((p) => p.slug === slug);
|
||||||
|
return post
|
||||||
|
? json({ ...post, content: "محتوای نمونه برای پیشنمایش لوکال." })
|
||||||
|
: json({ message: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
if (url === "/api/public/gallery") return json([]);
|
||||||
|
if (url.startsWith("/api/public/pages/")) {
|
||||||
|
const key = decodeURIComponent(url.split("/").pop() || "");
|
||||||
|
const page = site.pages.find((p) => p.key === key);
|
||||||
|
return page ? json(page) : json({ message: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
json({ message: `Mock API — no route for ${url}` }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
console.log(`[mock-api] http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user