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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 20:36:06 +03:30
co-authored by Cursor
parent 5147c661f0
commit b0088c35d1
130 changed files with 8024 additions and 2801 deletions
+2 -10
View File
@@ -1,13 +1,5 @@
<template>
<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" />
<NuxtLayout>
<NuxtPage />
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
const loaderTitle = useState("intro-loader-title", () => "هوش‌افزار منتک");
</script>
+7 -65
View File
@@ -1,65 +1,7 @@
/* IRANSans (FaNum) — local webfonts; paths quoted for special characters */
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff") format("woff");
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Light.woff") format("woff");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum).woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff") format("woff");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
url("../fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
font-weight: 900;
font-style: normal;
font-display: swap;
}
/* Self-hosted Vazirmatn (arabic + latin subsets via unicode-range, font-display: swap). Only weights used by the UI. */
@import "@fontsource/vazirmatn/400.css";
@import "@fontsource/vazirmatn/500.css";
@import "@fontsource/vazirmatn/600.css";
@import "@fontsource/vazirmatn/700.css";
@import "@fontsource/vazirmatn/800.css";
@import "@fontsource/vazirmatn/900.css";
+216 -92
View File
@@ -1,107 +1,231 @@
@import "./fonts.css";
@import "tailwindcss";
/*
* Design-specific global styles. They are declared before the Tailwind layers on purpose:
* utilities must keep winning equal-specificity conflicts (e.g. `.fade-item` + `transition-all`).
*/
@theme {
--color-ink: #0a0a0b;
--color-coal: #121215;
--color-smoke: #1b1b1f;
--color-bone: #f2f2ee;
--color-ash: #8b8b93;
--color-volt: #c8ff2e;
--font-display: "IRANSans", ui-sans-serif, system-ui, sans-serif;
--font-body: "IRANSans", ui-sans-serif, system-ui, sans-serif;
--font-serif: "IRANSans", ui-serif, Georgia, serif;
--font-mono: "IRANSans", ui-monospace, monospace;
/* Lenis */
html.lenis,
html.lenis body {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto !important;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
html { background: var(--color-ink); }
body {
@apply bg-ink text-bone font-body antialiased;
overflow-x: clip;
font-family: "IRANSans", ui-sans-serif, system-ui, sans-serif;
}
::selection {
background: var(--color-volt);
color: var(--color-ink);
font-family: "Vazirmatn", sans-serif;
background-color: #fafcff;
color: #0f172a;
overflow-x: hidden;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
transition: background-color 0.3s ease, color 0.3s ease;
}
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.hero-mesh {
background-image:
radial-gradient(circle at 15% 15%, rgba(0, 98, 245, 0.06) 0%, transparent 45%),
radial-gradient(circle at 85% 25%, rgba(56, 178, 248, 0.07) 0%, transparent 40%),
radial-gradient(circle at 50% 80%, rgba(2, 132, 199, 0.05) 0%, transparent 60%);
}
html.dark .hero-mesh {
background-image:
radial-gradient(circle at 15% 15%, rgba(0, 98, 245, 0.18) 0%, transparent 50%),
radial-gradient(circle at 85% 25%, rgba(56, 178, 248, 0.12) 0%, transparent 45%),
radial-gradient(circle at 50% 80%, rgba(2, 132, 199, 0.09) 0%, transparent 65%);
}
.noise {
position: fixed;
inset: -50%;
width: 200%;
height: 200%;
.perspective-stage {
perspective: 1800px;
perspective-origin: 50% 45%;
}
.studio-botanical-leaf {
position: absolute;
top: -45px;
right: 25px;
width: 280px;
height: 300px;
pointer-events: none;
z-index: 999;
opacity: 0.032;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grain 8s steps(10) infinite;
}
@keyframes grain {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-5%, -8%); }
20% { transform: translate(-12%, 4%); }
30% { transform: translate(6%, -14%); }
40% { transform: translate(-4%, 12%); }
50% { transform: translate(-12%, 7%); }
60% { transform: translate(11%, 0%); }
70% { transform: translate(0%, 10%); }
80% { transform: translate(3%, 13%); }
90% { transform: translate(-8%, 8%); }
z-index: 5;
filter: blur(12px) saturate(1.15);
opacity: 0.42;
transform: rotate(22deg) scale(1.08);
transform-origin: top right;
}
@keyframes marquee {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-50%, 0, 0); }
.mockup-macbook-wrap {
transform-style: preserve-3d;
will-change: transform;
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s ease;
filter: drop-shadow(0 35px 55px rgba(15, 23, 42, 0.26)) drop-shadow(0 15px 25px rgba(0, 98, 245, 0.12));
}
.animate-marquee {
animation: marquee var(--marquee-duration, 28s) linear infinite;
.macbook-screen-chassis {
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border: 1.5px solid rgba(255, 255, 255, 0.2);
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.95),
inset 0 1px 2px rgba(255, 255, 255, 0.35),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.macbook-deck-base {
background: linear-gradient(180deg, #94a3b8 0%, #64748b 35%, #334155 70%, #1e293b 100%);
border-top: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 45px -5px rgba(15, 23, 42, 0.45),
0 6px 14px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.mockup-iphone-wrap {
transform-style: preserve-3d;
will-change: transform;
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s ease;
filter: drop-shadow(0 32px 50px rgba(15, 23, 42, 0.42)) drop-shadow(0 14px 24px rgba(0, 98, 245, 0.2));
}
.iphone-titanium-bezel {
background: linear-gradient(135deg, #475569 0%, #1e293b 35%, #0f172a 75%, #334155 100%);
border: 2.5px solid #475569;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 0 0 2px rgba(15, 23, 42, 0.9);
position: relative;
}
.iphone-titanium-bezel::before {
content: "";
position: absolute;
left: -5.5px;
top: 95px;
width: 3.5px;
height: 48px;
background: #475569;
border-radius: 2px 0 0 2px;
box-shadow: -1px 0 2px rgba(0, 0, 0, 0.4);
}
.iphone-titanium-bezel::after {
content: "";
position: absolute;
right: -5.5px;
top: 85px;
width: 3.5px;
height: 65px;
background: #475569;
border-radius: 0 2px 2px 0;
box-shadow: 1px 0 2px rgba(0, 0, 0, 0.4);
}
.mockup-glass-sheen {
position: absolute;
inset: 0;
background: linear-gradient(132deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.05) 30%, transparent 60%);
pointer-events: none;
z-index: 30;
}
.gold-bar-realistic {
background: linear-gradient(135deg, #fffbeb 0%, #fef08a 22%, #f59e0b 55%, #b45309 85%, #78350f 100%);
box-shadow:
inset 0 1.5px 3px rgba(255, 255, 255, 0.95),
inset 0 -2px 4px rgba(0, 0, 0, 0.45),
0 8px 16px -3px rgba(217, 119, 6, 0.4);
}
.device-ground-shadow {
position: absolute;
bottom: -15px;
left: 6%;
right: 6%;
height: 38px;
background: radial-gradient(ellipse at center, rgba(15, 23, 42, 0.35) 0%, rgba(15, 23, 42, 0.12) 50%, transparent 80%);
filter: blur(9px);
pointer-events: none;
z-index: 1;
}
.spotlight-card {
position: relative;
overflow: hidden;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease, border-color 0.3s ease;
will-change: transform;
}
.spotlight-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(300px circle at var(--mouse-x, -200px) var(--mouse-y, -200px), rgba(0, 98, 245, 0.12), transparent 70%);
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
.spotlight-card:hover::before {
opacity: 1;
}
.scroll-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: linear-gradient(90deg, #38b2f8, #0062f5, #0050d8);
z-index: 100;
transform-origin: 0% 50%;
transform: scaleX(0);
}
.fade-item {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.75s cubic-bezier(0.16, 1, 0.3, 1), transform 0.75s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
.fade-item.revealed {
opacity: 1;
transform: translateY(0);
}
::-webkit-scrollbar {
width: 7px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
html.dark ::-webkit-scrollbar-track {
background: #0b1329;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
html.dark ::-webkit-scrollbar-thumb {
background: #334155;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
@media (prefers-reduced-motion: reduce) {
.animate-marquee { animation: none; }
.fade-item {
opacity: 1;
transform: none;
transition: none;
}
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.15; }
}
.animate-blink { animation: blink 1.4s ease-in-out infinite; }
@keyframes spin-slow {
to { transform: rotate(360deg); }
}
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
@keyframes fade-up {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-up {
animation: fade-up 1s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes scroll-line {
0% { transform: scaleY(0); transform-origin: top; opacity: 0; }
40% { transform: scaleY(1); transform-origin: top; opacity: 1; }
60% { transform: scaleY(1); transform-origin: bottom; opacity: 1; }
100% { transform: scaleY(0); transform-origin: bottom; opacity: 0; }
}
.animate-scroll-line {
animation: scroll-line 1.8s ease-in-out infinite;
}
.text-outline {
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
color: transparent;
}
/* Persian accent — volt + lighter weight (Instrument Serif has no Arabic glyphs) */
.font-accent {
font-family: "IRANSans", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
font-style: normal;
letter-spacing: -0.01em;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
<template>
<section id="top" class="relative flex min-h-svh flex-col overflow-hidden">
<ClientOnly>
<ParticleField />
</ClientOnly>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.08),transparent_55%)]" />
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-ink/40 via-transparent to-ink" />
<div class="relative flex flex-1 flex-col justify-end px-6 pb-12 pt-36 md:px-10 md:pb-16 md:pt-44">
<div
class="mb-10 flex items-center justify-between font-mono text-[12px] tracking-[0.14em] text-ash animate-fade-up md:mb-16 md:text-[13px] md:tracking-[0.22em]"
style="animation-delay: 0.2s"
>
<span class="flex items-center gap-3">
<span class="h-1.5 w-1.5 animate-blink rounded-full bg-volt" />
{{ data.eyebrow }}
</span>
<span class="hidden md:block">{{ data.location }}</span>
</div>
<h1 class="w-full font-display text-[clamp(2.2rem,8.5vw,7.5rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">
<div
v-for="(line, i) in data.lines"
:key="i"
class="overflow-hidden pb-[0.06em] -mb-[0.06em] animate-fade-up"
:style="{ animationDelay: `${0.15 + i * 0.12}s` }"
>
<div class="flex flex-wrap items-baseline gap-x-[0.28em]">
<span
v-for="(token, ti) in tokenize(line)"
:key="ti"
:class="token.volt ? 'font-accent text-volt' : undefined"
>{{ token.text }}</span>
</div>
</div>
</h1>
<div
class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 font-mono text-[11px] tracking-[0.14em] text-ash animate-fade-up md:mt-20 md:text-xs md:tracking-[0.22em]"
style="animation-delay: 0.8s"
>
<span class="flex items-center gap-2">
<span class="inline-block h-8 w-px animate-scroll-line bg-volt/80" />
اسکرول
</span>
<span class="hidden sm:block">{{ data.coords }}</span>
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
eyebrow: string;
location: string;
lines: string[];
highlightWord?: string;
highlightWords?: string[];
coords: string;
projectsLabel: string;
};
}>();
type Token = { text: string; volt?: boolean };
function highlights() {
return [
...(props.data.highlightWords || []),
props.data.highlightWord || "",
]
.map((h) => h.trim())
.filter(Boolean)
.filter((h, i, arr) => arr.indexOf(h) === i)
.sort((a, b) => b.length - a.length);
}
function tokenize(line: string): Token[] {
const hs = highlights();
const tokens: Token[] = [];
let rest = line.trim();
while (rest.length) {
let matched = false;
for (const h of hs) {
if (rest === h || rest.startsWith(`${h} `) || rest.startsWith(h)) {
tokens.push({ text: h, volt: true });
rest = rest.slice(h.length).trimStart();
matched = true;
break;
}
}
if (matched) continue;
const m = rest.match(/^(\S+)(\s*)/);
if (!m) break;
tokens.push({ text: m[1] });
rest = rest.slice(m[0].length);
}
return tokens;
}
</script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<Transition name="intro-fade">
<div
v-if="visible"
class="fixed inset-0 z-[1000] flex flex-col items-center justify-center overflow-hidden bg-ink text-bone"
aria-live="polite"
aria-busy="true"
>
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.12),transparent_55%)]" />
<p
class="relative px-6 text-center font-display font-extrabold leading-[0.95] tracking-[-0.04em]"
:style="{ fontSize: 'clamp(2.6rem, 13vw, 9rem)' }"
>
<span class="block animate-fade-up">{{ brandParts.first }}</span>
<span
v-if="brandParts.second"
class="mt-2 block font-accent tracking-normal text-volt animate-fade-up md:mt-3"
style="animation-delay: 0.12s"
>{{ brandParts.second }}</span>
</p>
<div class="relative mt-16 flex w-[min(300px,72vw)] flex-col items-center gap-3 animate-fade-up" style="animation-delay: 0.25s">
<div class="h-px w-full overflow-hidden bg-white/10">
<div class="h-full bg-volt transition-[width] duration-75 ease-linear" :style="{ width: `${progress}%` }" />
</div>
<span class="font-mono text-xs tracking-[0.4em] text-ash tabular-nums" dir="ltr">
{{ String(Math.floor(progress)).padStart(2, "0") }}%
</span>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ title?: string }>(),
{ title: "هوش‌افزار منتک" },
);
const visible = ref(false);
const progress = ref(0);
const brandParts = computed(() => {
const raw = (props.title || "هوش‌افزار منتک").trim();
const parts = raw.split(/\s+/);
if (parts.length <= 1) return { first: raw, second: "" };
return {
first: parts.slice(0, -1).join(" "),
second: parts.at(-1) || "",
};
});
onMounted(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) return;
visible.value = true;
document.documentElement.style.overflow = "hidden";
const start = performance.now();
const duration = 2400;
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
progress.value = Math.min(100, eased * 100);
if (t < 1) {
raf = requestAnimationFrame(tick);
return;
}
progress.value = 100;
window.setTimeout(() => {
visible.value = false;
document.documentElement.style.overflow = "";
}, 320);
};
raf = requestAnimationFrame(tick);
onUnmounted(() => {
cancelAnimationFrame(raf);
document.documentElement.style.overflow = "";
});
});
</script>
<style scoped>
.intro-fade-leave-active {
transition: opacity 0.75s cubic-bezier(0.22, 1, 0.36, 1), transform 0.75s cubic-bezier(0.22, 1, 0.36, 1);
}
.intro-fade-leave-to {
opacity: 0;
transform: scale(1.06);
}
</style>
@@ -1,37 +0,0 @@
<template>
<section id="manifesto" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۱" :title="data.tag" right="نسخه کوتاه" />
<div class="mt-14 flex flex-col gap-10 md:max-w-2xl animate-fade-up">
<p class="text-lg leading-relaxed text-ash md:text-xl md:leading-relaxed">
{{ intro?.body || data.body }}
</p>
<a
v-if="intro?.cta"
href="#work"
class="group inline-flex w-fit items-center gap-4 font-mono text-xs tracking-[0.16em] text-bone transition-colors hover:text-volt md:text-[13px] md:tracking-[0.22em]"
>
{{ intro.cta }}
<span class="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 transition-all group-hover:border-volt group-hover:bg-volt group-hover:text-ink">
↓
</span>
</a>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
words?: Array<{ text: string; outline?: boolean; volt?: boolean }>;
body: string;
};
intro?: {
body?: string;
cta?: string;
} | null;
}>();
</script>
-162
View File
@@ -1,162 +0,0 @@
<template>
<canvas
ref="el"
class="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
/>
</template>
<script setup lang="ts">
type Particle = { x: number; y: number; vx: number; vy: number; r: number; a: number };
const el = ref<HTMLCanvasElement | null>(null);
onMounted(() => {
const canvas = el.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let particles: Particle[] = [];
let raf = 0;
let w = 0;
let h = 0;
let dpr = 1;
const mouse = { x: -9999, y: -9999, tx: -9999, ty: -9999 };
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const resize = () => {
const parent = canvas.parentElement;
if (!parent) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = parent.clientWidth;
h = parent.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(110, Math.max(40, Math.floor((w * h) / 12000)));
particles = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.45,
vy: (Math.random() - 0.5) * 0.45,
r: Math.random() * 1.8 + 0.5,
a: Math.random() * 0.5 + 0.18,
}));
};
const onMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.tx = e.clientX - rect.left;
mouse.ty = e.clientY - rect.top;
};
const onLeave = () => {
mouse.tx = -9999;
mouse.ty = -9999;
};
const draw = () => {
ctx.clearRect(0, 0, w, h);
// smooth mouse follow
if (mouse.tx > -9000) {
mouse.x += (mouse.tx - mouse.x) * 0.12;
mouse.y += (mouse.ty - mouse.y) * 0.12;
} else {
mouse.x += (-9999 - mouse.x) * 0.05;
mouse.y += (-9999 - mouse.y) * 0.05;
}
const hasMouse = mouse.x > -5000;
for (const p of particles) {
if (!reduced && hasMouse) {
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.hypot(dx, dy) || 1;
if (dist < 220) {
const force = (1 - dist / 220) * 0.065;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
// soft orbital swirl near cursor
if (dist < 160) {
p.vx += (-dy / dist) * 0.02;
p.vy += (dx / dist) * 0.02;
}
}
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
// keep a tiny base drift so field never freezes
if (Math.abs(p.vx) < 0.05) p.vx += (Math.random() - 0.5) * 0.04;
if (Math.abs(p.vy) < 0.05) p.vy += (Math.random() - 0.5) * 0.04;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200, 255, 46, ${p.a})`;
ctx.fill();
}
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i];
const b = particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist > 120) continue;
let alpha = (1 - dist / 120) * 0.2;
if (hasMouse) {
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const toMouse = Math.hypot(midX - mouse.x, midY - mouse.y);
if (toMouse < 180) alpha += (1 - toMouse / 180) * 0.25;
}
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(200, 255, 46, ${Math.min(alpha, 0.55)})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
if (hasMouse) {
const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, 140);
g.addColorStop(0, "rgba(200, 255, 46, 0.12)");
g.addColorStop(1, "rgba(200, 255, 46, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 140, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
resize();
draw();
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onMove, { passive: true });
document.addEventListener("pointerleave", onLeave);
onUnmounted(() => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerleave", onLeave);
});
});
</script>
@@ -1,23 +0,0 @@
<template>
<section id="process" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۴" :title="data.tag" right="معمولاً ۱۲ هفته" />
<div class="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div v-for="step in data.steps" :key="step.n" class="border-t border-white/[0.1] pt-6">
<p class="font-mono text-[11px] tracking-[0.2em] text-volt">{{ step.n }}</p>
<h3 class="mt-4 font-display text-xl font-bold tracking-[-0.02em] md:text-2xl">{{ step.title }}</h3>
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
steps: Array<{ n: string; title: string; desc: string }>;
};
}>();
</script>
-13
View File
@@ -1,13 +0,0 @@
<template>
<div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 font-mono text-[11px] tracking-[0.14em] text-ash md:tracking-[0.22em]">
<div class="flex items-center gap-3">
<span class="text-volt">{{ index }}</span>
<span class="text-bone">{{ title }}</span>
</div>
<span v-if="right" class="hidden sm:block">{{ right }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ index: string; title: string; right?: string }>();
</script>
@@ -1,45 +0,0 @@
<template>
<section id="services" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۲" :title="data.tag" :right="`${String(data.items.length).padStart(2, '0')} قابلیت`" />
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
<li
v-for="(s, i) in data.items"
:key="s.n"
class="group cursor-pointer py-8 md:py-10"
@click="open = open === i ? -1 : i"
>
<div class="flex items-start justify-between gap-6">
<div class="flex flex-1 flex-col gap-3 md:flex-row md:items-baseline md:gap-10">
<span class="font-mono text-[11px] tracking-[0.2em] text-ash">{{ s.n }}</span>
<h3 class="font-display text-2xl font-bold tracking-[-0.02em] transition-colors md:text-4xl group-hover:text-volt">
{{ s.title }}
</h3>
</div>
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span>
</div>
<div v-if="open === i" class="mt-6 max-w-3xl pe-0 md:pe-16 animate-fade-up">
<p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.12em] text-ash"
>{{ t }}</span>
</div>
</div>
</li>
</ul>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
items: Array<{ n: string; title: string; desc: string; tags: string[] }>;
};
}>();
const open = ref(0);
</script>
-113
View File
@@ -1,113 +0,0 @@
<template>
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
<div class="border-b border-white/[0.07] py-8 md:py-10">
<SiteMarquee
:items="data.marquee || []"
:duration="20"
class-name="font-display text-xl font-bold tracking-[-0.01em] md:text-3xl"
/>
</div>
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
<SectionTag index="۰۶" :title="data.tag || 'تماس'" right="پاسخ تا ۲۴ ساعت" />
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
<div>
<h2 class="font-display text-[clamp(2.4rem,6.5vw,5.5rem)] font-extrabold leading-[1.08] tracking-[-0.03em]">
{{ data.cta || "شروع همکاری" }}
</h2>
<a
v-if="email"
:href="`mailto:${email}`"
class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 font-mono text-[11px] tracking-[0.16em] text-ink transition hover:bg-transparent hover:text-volt"
dir="ltr"
>
{{ email }} ↗
</a>
</div>
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">کسب‌وکار جدید</p>
<p v-if="email" class="mt-2 text-sm" dir="ltr">{{ email }}</p>
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
</div>
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">استودیو</p>
<p v-for="line in addressLines" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
</div>
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">شبکه‌های اجتماعی</p>
<template v-if="socialLinks.length">
<a
v-for="s in socialLinks"
:key="s.label + s.url"
:href="s.url || undefined"
class="mt-1 block text-sm text-ash hover:text-volt"
:target="s.url ? '_blank' : undefined"
rel="noopener"
>{{ s.label }}</a>
</template>
<p v-else-if="legacySocials.length" v-for="s in legacySocials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
</div>
</div>
</div>
<div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.16em] text-ash">
<span>© {{ year }} گروه نرم‌افزاری {{ brand || "منتک" }}</span>
<span>{{ brand || "منتک" }}®</span>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const props = defineProps<{
brand?: string;
email?: string;
phone?: string;
address?: string;
socials?: Array<{ label: string; url: string }>;
data: {
tag?: string;
marquee?: string[];
cta?: string;
email?: string;
phone?: string;
address?: string[] | string;
socials?: string[] | Array<{ label: string; url: string }>;
};
}>();
const year = new Date().getFullYear();
const email = computed(() => props.email || props.data.email || "");
const phone = computed(() => props.phone || props.data.phone || "");
const addressLines = computed(() => {
if (props.address) {
return props.address
.split(/\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
const a = props.data.address;
if (Array.isArray(a)) return a;
if (typeof a === "string") return a.split(/\n|,/).map((s) => s.trim()).filter(Boolean);
return [];
});
const socialLinks = computed(() => {
if (props.socials?.length) return props.socials.filter((s) => s.label);
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [];
if (typeof raw[0] === "object" && raw[0] !== null) {
return (raw as Array<{ label: string; url: string }>).filter((s) => s.label);
}
return [];
});
const legacySocials = computed(() => {
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [] as string[];
if (typeof raw[0] === "string") return raw as string[];
return [];
});
</script>
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="overflow-hidden" :class="className" dir="ltr">
<div
class="animate-marquee flex w-max will-change-transform"
:style="{ '--marquee-duration': `${duration}s` } as any"
>
<div v-for="dup in 2" :key="dup" class="flex shrink-0 items-center" :aria-hidden="dup > 1">
<span v-for="(item, i) in items" :key="`${dup}-${i}`" class="flex items-center">
<span class="mx-5 whitespace-nowrap md:mx-8" :class="itemClassName">{{ item }}</span>
<span class="text-current/40" :class="separatorClassName">{{ separator }}</span>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
items: string[];
duration?: number;
className?: string;
itemClassName?: string;
separatorClassName?: string;
separator?: string;
}>(),
{
duration: 28,
className: "",
itemClassName: "",
separatorClassName: "",
separator: "✦",
},
);
</script>
-54
View File
@@ -1,54 +0,0 @@
<template>
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-ink/70 backdrop-blur-md">
<div class="mx-auto flex max-w-[1500px] items-center justify-between px-6 py-4 md:px-10">
<NuxtLink to="/" class="flex items-center gap-3 font-display text-lg font-extrabold tracking-tight">
<img
v-if="logoSrc"
:src="logoSrc"
:alt="brand"
class="h-9 w-auto max-w-[140px] object-contain"
/>
<template v-else>
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
</template>
</NuxtLink>
<nav class="hidden items-center gap-7 font-mono text-[11px] tracking-[0.16em] text-ash md:flex">
<a href="/#services" class="transition-colors hover:text-volt">خدمات</a>
<a href="/#work" class="transition-colors hover:text-volt">نمونه‌کارها</a>
<a href="/#team" class="transition-colors hover:text-volt">تیم</a>
<NuxtLink to="/blog" class="transition-colors hover:text-volt">بلاگ</NuxtLink>
<NuxtLink to="/gallery" class="transition-colors hover:text-volt">گالری</NuxtLink>
<a href="/#contact" class="text-bone transition-colors hover:text-volt">تماس</a>
</nav>
<span class="font-mono text-[12px] tracking-[0.14em] text-ash tabular-nums md:text-[13px]" dir="ltr">{{ time }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { formatClock } from "~/utils/timezone";
import { mediaUrl } from "~/utils/media";
const props = defineProps<{
brand?: string;
logoUrl?: string | null;
timezone?: string;
}>();
const brand = computed(() => props.brand || "منتک");
const logoSrc = computed(() => mediaUrl(props.logoUrl));
const time = ref("");
onMounted(() => {
const tick = () => {
try {
time.value = formatClock(new Date(), props.timezone);
} catch {
time.value = "";
}
};
tick();
const id = setInterval(tick, 1000);
onUnmounted(() => clearInterval(id));
});
</script>
-20
View File
@@ -1,20 +0,0 @@
<template>
<section class="border-y border-white/[0.07] bg-coal px-6 py-20 md:px-10 md:py-28">
<div class="mx-auto grid max-w-[1500px] gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div v-for="(s, i) in data.items" :key="i">
<p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl">
{{ s.value }}{{ s.suffix }}
</p>
<p class="mt-3 font-mono text-[11px] tracking-[0.14em] text-ash md:tracking-[0.2em]">{{ s.label }}</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
items: Array<{ value: number; suffix: string; label: string }>;
};
}>();
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<section id="team" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۵" :title="data.tag" right="رهبری" />
<div class="mb-16 mt-14 flex flex-col justify-between gap-8 md:flex-row md:items-end">
<h2 class="font-display text-[clamp(2.4rem,7vw,6.5rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
<template v-if="headlineParts.rest">
{{ headlineParts.rest }}
<span class="font-accent text-volt">{{ headlineParts.last }}</span>
</template>
<template v-else>
{{ data.headline }}
</template>
</h2>
<p class="max-w-xs text-sm leading-relaxed text-ash">{{ data.body }}</p>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<figure v-for="m in data.members" :key="m.name" class="group">
<div
class="aspect-[3/4] overflow-hidden bg-gradient-to-br from-smoke to-ink bg-cover bg-center transition-transform duration-700 group-hover:scale-[1.02]"
:style="m.imageUrl ? { backgroundImage: `url(${m.imageUrl})` } : {}"
/>
<figcaption class="mt-4">
<p class="font-display text-xl font-bold tracking-[-0.02em] transition-colors group-hover:text-volt">
{{ m.name }}
</p>
<p class="mt-1 font-mono text-[10px] tracking-[0.16em] text-ash">{{ m.role }}</p>
</figcaption>
</figure>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
tag: string;
headline: string;
body: string;
members: Array<{ name: string; role: string; imageUrl?: string }>;
};
}>();
const headlineParts = computed(() => {
const parts = (props.data.headline || "").trim().split(/\s+/);
if (parts.length < 2) return { rest: "", last: props.data.headline };
return {
rest: parts.slice(0, -1).join(" "),
last: parts.at(-1) || "",
};
});
</script>
-38
View File
@@ -1,38 +0,0 @@
<template>
<section id="work" class="px-6 py-28 md:px-10 md:py-40">
<div class="mx-auto max-w-[1500px]">
<SectionTag index="۰۳" :title="data.tag" right="۱۴۰۲ — ۱۴۰۴" />
<div class="mt-16 grid gap-6 md:grid-cols-2">
<article
v-for="p in data.items"
:key="p.n"
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 transition-colors hover:border-volt/30 md:p-8"
>
<div
class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink bg-cover bg-center transition-transform duration-700 group-hover:scale-[1.03]"
:style="p.imageUrl ? { backgroundImage: `url(${p.imageUrl})` } : {}"
/>
<div class="flex items-end justify-between gap-4">
<div>
<p class="font-mono text-[10px] tracking-[0.2em] text-ash">{{ p.n }} / {{ p.year }}</p>
<h3 class="mt-2 font-display text-2xl font-extrabold tracking-[-0.02em] transition-colors group-hover:text-volt md:text-3xl">
{{ p.title }}
</h3>
<p class="mt-2 text-sm text-ash">{{ p.cat }}</p>
</div>
<span class="text-volt transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">↗</span>
</div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
data: {
tag: string;
items: Array<{ n: string; title: string; cat: string; year: string; imageUrl?: string }>;
};
}>();
</script>
@@ -0,0 +1,50 @@
<template>
<section class="py-16 cta-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="relative overflow-hidden rounded-[36px] bg-gradient-to-l from-sky-50 via-blue-50 to-blue-100/70 dark:from-slate-900 dark:via-[#0c1e3f] dark:to-slate-900 border border-brand-200/80 dark:border-brand-500/30 p-8 sm:p-12 shadow-soft-card">
<div class="relative z-10 flex flex-col md:flex-row items-center justify-between gap-8 text-right">
<div class="order-3 md:order-1 flex items-center justify-center" aria-hidden="true">
<div class="w-28 h-28 sm:w-32 sm:h-32 rounded-3xl bg-white dark:bg-slate-800 shadow-xl border border-white/80 dark:border-slate-700 p-3 flex items-center justify-center transform hover:scale-105 transition-transform duration-500">
<div class="w-full h-full rounded-2xl bg-gradient-to-br from-brand-600 via-brand-500 to-sky-400 flex items-center justify-center text-white shadow-inner">
<svg class="w-16 h-16" viewBox="0 0 32 32" fill="none">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="16" cy="22" r="2.2" fill="white" />
</svg>
</div>
</div>
</div>
<div class="order-2 flex-shrink-0">
<button
type="button"
class="inline-flex items-center gap-2.5 px-8 py-3.5 rounded-full bg-brand-600 hover:bg-brand-700 text-white font-bold text-sm shadow-glow-cta active:scale-95 transition-all duration-300"
@click="consult.open()"
>
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ cta.ctaLabel }}</span>
</button>
</div>
<div class="order-1 md:order-3 flex-1 space-y-2">
<h2 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
{{ cta.titleLine1 }}
<br>
{{ cta.titleLine2 }}
</h2>
<p class="text-slate-600 dark:text-slate-300 text-sm font-normal">
{{ cta.body }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const cta = section("cta");
const consult = useConsultModal();
</script>
@@ -0,0 +1,90 @@
<template>
<section id="features" class="py-24 bg-slate-50/70 dark:bg-[#070d19]/90 relative overflow-hidden customization-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
<div class="lg:col-span-7 relative flex justify-center items-center py-6 min-h-[380px]" aria-hidden="true">
<div class="w-4/5 sm:w-2/3 bg-white/90 dark:bg-slate-900/80 rounded-2xl border border-slate-200/90 dark:border-slate-800 p-5 shadow-xl -rotate-6 transform transition-transform duration-700 hover:-rotate-3">
<div class="flex items-center gap-2 border-b border-slate-100 dark:border-slate-800 pb-2.5 mb-3">
<span class="w-2.5 h-2.5 rounded-full bg-slate-300 dark:bg-slate-700" />
<span class="w-2.5 h-2.5 rounded-full bg-slate-300 dark:bg-slate-700" />
<div class="text-[11px] text-slate-400 dark:text-slate-500 font-bold">{{ features.baseCardLabel }}</div>
</div>
<div class="space-y-3 opacity-60">
<div class="h-3.5 w-1/3 bg-slate-200 dark:bg-slate-700 rounded" />
<div class="h-16 w-full bg-slate-100 dark:bg-slate-800 rounded-lg" />
<div class="grid grid-cols-3 gap-2">
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
<div class="h-9 bg-slate-100 dark:bg-slate-800 rounded" />
</div>
</div>
</div>
<div class="absolute top-0 right-1/4 z-30 transform -translate-y-6">
<svg class="w-28 h-20 text-brand-500" viewBox="0 0 100 60" fill="none">
<path d="M10,50 Q50,-10 90,30" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 6" />
<polygon points="90,30 82,18 96,22" fill="currentColor" />
</svg>
</div>
<div class="absolute -bottom-4 -right-2 sm:right-6 w-5/6 sm:w-3/4 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-brand-200 dark:border-brand-500/40 p-6 shadow-2xl rotate-2 hover:rotate-0 transition-transform duration-700">
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3 mb-4">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-brand-600 text-white flex items-center justify-center font-bold text-xs shadow-sm">M</div>
<div class="text-xs font-bold text-slate-800 dark:text-slate-100">{{ features.customCardTitle }}</div>
</div>
<span class="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/60 px-2.5 py-1 rounded-full border border-emerald-100 dark:border-emerald-800/40">{{ features.customCardBadge }}</span>
</div>
<div class="grid grid-cols-3 gap-3 text-right">
<div class="col-span-2 space-y-2">
<div class="text-xs text-slate-400 dark:text-slate-400 font-medium">{{ features.salesLabel }}</div>
<div class="text-xl font-black text-slate-900 dark:text-white">{{ features.salesValue }} <span class="text-xs font-normal text-slate-500 dark:text-slate-400">تومان</span></div>
<div class="h-11 bg-gradient-to-l from-brand-50 to-transparent dark:from-brand-950/50 dark:to-transparent border border-brand-100 dark:border-brand-900/60 rounded-xl flex items-center px-3">
<span class="text-[10px] text-brand-700 dark:text-brand-300 font-bold">{{ features.linkNote }}</span>
</div>
</div>
<div class="space-y-1 bg-slate-50 dark:bg-slate-800/70 p-2.5 rounded-xl text-center flex flex-col justify-center border border-slate-100 dark:border-slate-800">
<div class="text-[10px] text-slate-500 dark:text-slate-400">{{ features.aiLabel }}</div>
<div class="text-brand-600 dark:text-brand-400 font-extrabold text-sm">{{ features.aiStatus }}</div>
<div class="text-[9px] text-slate-400 dark:text-slate-500">{{ features.aiNote }}</div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-5 text-right space-y-5 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ features.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">
{{ features.titleLine1 }}
<br>
<span>{{ features.titleLine2 }}</span>
</h2>
<p class="text-base text-slate-600 dark:text-slate-300 leading-relaxed font-normal">
{{ features.body }}
</p>
<div class="pt-2">
<a
:href="resolveHref(features.ctaHref)"
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 font-bold text-sm hover:border-brand-600 dark:hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-brand-50/50 dark:hover:bg-slate-800/50 transition-all duration-300"
>
<svg class="w-4 h-4 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ features.ctaLabel }}</span>
</a>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const features = section("features");
const resolveHref = useSectionHref();
</script>
@@ -0,0 +1,210 @@
<template>
<div id="hero-laptop" class="mockup-macbook-wrap relative w-full max-w-[580px] select-none cursor-pointer z-10" :style="transformStyle">
<div class="macbook-screen-chassis rounded-[22px] p-2.5 sm:p-3 relative">
<div class="mockup-glass-sheen rounded-[16px]" />
<div class="bg-[#0b0f19] rounded-[16px] overflow-hidden border border-slate-800 relative shadow-2xl">
<div class="bg-[#111827] px-3.5 py-2 flex items-center justify-between border-b border-slate-800 text-[10px] text-slate-400">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56] inline-block shadow-sm" />
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e] inline-block shadow-sm" />
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f] inline-block shadow-sm" />
</div>
<div class="w-24 h-3 bg-black rounded-b-md -mt-2 mx-auto flex items-center justify-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-slate-900 border border-slate-700" />
<span class="w-1 h-1 rounded-full bg-emerald-500/80" />
</div>
<div class="flex items-center gap-1.5 text-slate-300 font-bold">
<div class="w-3.5 h-3.5 rounded bg-brand-600 flex items-center justify-center text-[8px] text-white">M</div>
<span class="text-[9px] bg-brand-500/20 text-brand-300 px-2 py-0.5 rounded-full font-mono">{{ mockup.domain }}</span>
</div>
</div>
<div class="p-3 sm:p-4 bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 min-h-[320px] flex gap-3 text-right">
<div class="w-28 hidden sm:flex flex-col justify-between py-1 px-1 border-l border-slate-200 dark:border-slate-800 text-[11px] text-slate-600 dark:text-slate-400 font-medium">
<div class="space-y-1.5">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
:aria-pressed="heroTab === tab.id"
:class="heroTab === tab.id ? tab.activeClass : 'hover:bg-slate-100 dark:hover:bg-slate-900 text-slate-600 dark:text-slate-400'"
class="w-full text-right px-2.5 py-1.5 rounded-lg flex items-center gap-1.5 transition-all duration-200"
@click.stop="heroTab = tab.id"
>
<span class="w-1.5 h-1.5 rounded-full transition-transform duration-200" :class="heroTab === tab.id ? tab.dotClass : 'bg-slate-400'" />
{{ tab.label }}
</button>
</div>
<div class="px-2 py-1 text-[9px] text-slate-400 dark:text-slate-500 flex items-center justify-between">
<span>{{ mockup.version }}</span>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />
</div>
</div>
<div class="flex-1 space-y-2.5">
<div v-if="heroTab === 'dashboard'" class="space-y-2.5">
<div class="grid grid-cols-2 gap-2">
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm hover:border-brand-300 dark:hover:border-brand-500/50 transition-colors">
<div class="text-[10px] text-slate-400 dark:text-slate-500 font-medium">فروش کل دوره‌ای</div>
<div class="text-xs sm:text-sm md:text-base font-extrabold text-slate-800 dark:text-white mt-0.5">
{{ mockup.salesValue }} <span class="text-[9px] font-normal text-slate-500 dark:text-slate-400">تومان</span>
</div>
<div class="text-[9px] text-emerald-600 dark:text-emerald-400 mt-0.5 font-semibold flex items-center gap-1">
{{ mockup.salesGrowth }}
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm hover:border-brand-300 dark:hover:border-brand-500/50 transition-colors">
<div class="text-[10px] text-slate-400 dark:text-slate-500 font-medium">مشتریان وفادار فعال</div>
<div class="text-xs sm:text-sm md:text-base font-extrabold text-slate-800 dark:text-white mt-0.5">
{{ mockup.customersValue }} <span class="text-[9px] font-normal text-slate-500 dark:text-slate-400">نفر</span>
</div>
<div class="text-[9px] text-brand-600 dark:text-brand-400 mt-0.5 font-semibold flex items-center gap-1">
{{ mockup.satisfaction }}
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-2.5 rounded-xl border border-slate-200/80 dark:border-slate-800 shadow-sm relative group/chart">
<div class="flex items-center justify-between text-[10px] mb-1.5">
<span class="font-bold text-slate-700 dark:text-slate-300">روند عملکرد فروش و تحلیل هوش مصنوعی</span>
<span class="text-brand-600 dark:text-brand-400 font-bold text-[9px] flex items-center gap-1 bg-brand-50 dark:bg-brand-950 px-2 py-0.5 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping" />
زنده
</span>
</div>
<div class="relative">
<svg class="w-full h-20 overflow-visible" viewBox="0 0 300 90" fill="none" aria-hidden="true">
<defs>
<linearGradient id="heroChartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0062f5" stop-opacity="0.32" />
<stop offset="100%" stop-color="#0062f5" stop-opacity="0.0" />
</linearGradient>
</defs>
<path d="M0,70 Q40,55 80,35 T160,45 T240,15 T300,25 L300,90 L0,90 Z" fill="url(#heroChartGrad)" />
<path d="M0,70 Q40,55 80,35 T160,45 T240,15 T300,25" stroke="#0062f5" stroke-width="2.8" stroke-linecap="round" />
<circle cx="80" cy="35" r="4" fill="#0062f5" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[0]" />
<circle cx="160" cy="45" r="4" fill="#0062f5" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[1]" />
<circle cx="240" cy="15" r="5" fill="#0062f5" stroke="#ffffff" stroke-width="2" class="cursor-pointer" @mouseenter="chartTooltip = mockup.chartTooltips[2]" />
</svg>
<div class="absolute top-1 left-2 bg-slate-900/90 text-white text-[9px] px-2 py-0.5 rounded font-mono shadow pointer-events-none">
{{ chartTooltip }}
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'gold'" class="space-y-2.5">
<div class="bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-amber-950 dark:text-amber-200">محاسبه‌گر نرخ لحظه‌ای و مظنه طلا</span>
<span class="text-[9px] bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100 px-2 py-0.5 rounded-full font-bold">اتصال مستقیم به اتحادیه</span>
</div>
<div class="grid grid-cols-2 gap-2 text-[10px]">
<div class="bg-white dark:bg-slate-900 p-2 rounded-lg border border-amber-100 dark:border-amber-900/40">
<span class="text-slate-400 dark:text-slate-500 block">مظنه ۱۷ عیار:</span>
<span class="font-black text-slate-800 dark:text-slate-200 text-xs">۱۸,۷۵۰,۰۰۰ تومان</span>
</div>
<div class="bg-white dark:bg-slate-900 p-2 rounded-lg border border-amber-100 dark:border-amber-900/40">
<span class="text-slate-400 dark:text-slate-500 block">گرم ۱۸ عیار:</span>
<span class="font-black text-slate-800 dark:text-slate-200 text-xs">۴,۳۲۰,۰۰۰ تومان</span>
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'crm'" class="space-y-2.5">
<div class="bg-indigo-50/70 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-indigo-950 dark:text-indigo-200">قیف فروش و وضعیت سرنخ‌ها</span>
<span class="text-[9px] bg-indigo-200 dark:bg-indigo-800 text-indigo-900 dark:text-indigo-100 px-2 py-0.5 rounded-full font-bold">اتوماسیون فعال</span>
</div>
<div class="space-y-1.5 text-[10px]">
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-indigo-100 dark:border-indigo-900/40">
<span class="font-semibold text-slate-700 dark:text-slate-300">تماس‌های موفق امروز</span>
<span class="font-bold text-indigo-600 dark:text-indigo-400">۱۴۲ مورد (+۱۸٪)</span>
</div>
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-indigo-100 dark:border-indigo-900/40">
<span class="font-semibold text-slate-700 dark:text-slate-300">پیش‌فاکتورهای در انتظار تایید</span>
<span class="font-bold text-amber-600 dark:text-amber-400">۲۹ قرارداد</span>
</div>
</div>
</div>
</div>
<div v-else-if="heroTab === 'financial'" class="space-y-2.5">
<div class="bg-cyan-50/70 dark:bg-cyan-950/40 border border-cyan-200 dark:border-cyan-800/60 rounded-xl p-3 text-right">
<div class="flex items-center justify-between text-[11px] mb-2">
<span class="font-extrabold text-cyan-950 dark:text-cyan-200">تراز مالی و سامانه مودیان</span>
<span class="text-[9px] bg-emerald-100 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-300 px-2 py-0.5 rounded-full font-bold">همگام‌سازی برخط</span>
</div>
<div class="space-y-1.5 text-[10px]">
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-2 py-1.5 rounded-lg border border-cyan-100 dark:border-cyan-900/40">
<span class="text-slate-600 dark:text-slate-400">تسویه تجمیعی کارتخوان‌ها:</span>
<span class="font-bold text-slate-900 dark:text-white">۱,۲۸۰,۰۰۰,۰۰۰ تومان</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between bg-white dark:bg-slate-900 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-800 text-[10px] text-slate-600 dark:text-slate-400">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping" />
<span>آخرین تسویه حسابداری متصل به شتاب</span>
</div>
<span class="font-bold text-slate-800 dark:text-slate-200">موفق (۱ دقیقه پیش)</span>
</div>
</div>
</div>
</div>
</div>
<div class="macbook-deck-base h-4 rounded-b-[18px] mx-auto -mt-0.5 flex justify-center items-center relative">
<div class="w-24 h-1.5 bg-slate-400/80 rounded-full shadow-inner" />
</div>
</div>
</template>
<script setup lang="ts">
import type { HeroMockupContent } from "@montech/shared";
type HeroTab = "dashboard" | "gold" | "crm" | "financial";
const props = defineProps<{ mockup: HeroMockupContent; transformStyle: string }>();
const TABS: Array<{ id: HeroTab; label: string; activeClass: string; dotClass: string }> = [
{
id: "dashboard",
label: "داشبورد",
activeClass: "bg-brand-50 dark:bg-brand-950 text-brand-600 dark:text-brand-400 font-bold shadow-sm",
dotClass: "bg-brand-600 dark:bg-brand-400 scale-125",
},
{
id: "gold",
label: "انبارداری طلا",
activeClass: "bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-400 font-bold shadow-sm",
dotClass: "bg-amber-600 dark:bg-amber-400 scale-125",
},
{
id: "crm",
label: "مشتریان (CRM)",
activeClass: "bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-400 font-bold shadow-sm",
dotClass: "bg-indigo-600 dark:bg-indigo-400 scale-125",
},
{
id: "financial",
label: "گزارش مالی",
activeClass: "bg-cyan-50 dark:bg-cyan-950/60 text-cyan-700 dark:text-cyan-400 font-bold shadow-sm",
dotClass: "bg-cyan-600 dark:bg-cyan-400 scale-125",
},
];
const heroTab = ref<HeroTab>("dashboard");
const chartTooltip = ref(props.mockup.chartDefaultTooltip);
</script>
@@ -0,0 +1,89 @@
<template>
<div
id="hero-mobile"
class="mockup-iphone-wrap absolute -bottom-10 -right-2 sm:-right-4 lg:right-2 w-[225px] sm:w-[250px] h-[465px] sm:h-[515px] select-none cursor-pointer z-20"
:style="transformStyle"
@click="goldBarCount = goldBarCount === baseCount ? baseCount + 1 : baseCount"
>
<div class="iphone-titanium-bezel rounded-[46px] p-2.5 w-full h-full relative">
<div class="mockup-glass-sheen rounded-[38px]" />
<div class="bg-white dark:bg-slate-950 rounded-[38px] overflow-hidden text-right p-3.5 pt-2 text-slate-800 dark:text-slate-100 text-[10px] w-full h-full flex flex-col justify-between relative border border-slate-100 dark:border-slate-800 shadow-xl">
<div>
<div class="flex items-center justify-between px-2 text-[9px] text-slate-800 dark:text-slate-200 font-bold mb-1">
<span>۰۹:۴۱</span>
<div class="flex items-center gap-1 text-[8px]">
<span>5G</span>
<svg class="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25c-.2.2-.2.51 0 .71.2.2.51.2.71 0l.64-.64C7.26 19.46 9.49 20.1 12 20.1c4.97 0 9-4.03 9-9s-4.03-9-9-9z" /></svg>
<span class="w-4 h-2 border border-slate-800 dark:border-slate-300 rounded-sm p-0.5 flex items-center"><span class="w-full h-full bg-slate-800 dark:bg-slate-300 rounded-xs" /></span>
</div>
</div>
<div class="w-24 h-4.5 bg-black mx-auto rounded-full mb-2.5 flex items-center justify-between px-2.5 shadow-sm">
<span class="w-1.5 h-1.5 rounded-full bg-slate-900 border border-slate-800" />
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
</div>
<div class="flex items-center justify-between px-1 mb-2.5">
<div class="flex items-center gap-1.5">
<span class="w-5 h-5 rounded-lg bg-amber-500 text-white flex items-center justify-center text-[10px] font-bold">M</span>
<span class="font-black text-slate-900 dark:text-white text-xs">طلا و جواهر</span>
</div>
<span class="text-[9px] bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 font-bold px-2 py-0.5 rounded-full">انبار آنلاین</span>
</div>
<div class="bg-gradient-to-l from-amber-50 to-amber-100/70 dark:from-amber-950/30 dark:to-amber-900/20 border border-amber-200/90 dark:border-amber-800/50 rounded-2xl p-2.5 flex items-center gap-2.5 shadow-sm hover:shadow-md transition-shadow mb-2.5">
<div class="gold-bar-realistic w-12 h-12 rounded-xl flex flex-col items-center justify-center text-amber-950 font-black text-[10px] tracking-tight relative overflow-hidden flex-shrink-0">
<div class="absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-white/40 pointer-events-none" />
<span class="relative z-10 text-[9px] font-extrabold tracking-wider">AU 999</span>
<span class="relative z-10 text-[7px] opacity-90 font-mono">100g</span>
</div>
<div class="text-[9px] leading-tight flex-1">
<div class="font-extrabold text-slate-900 dark:text-white text-[10px]">شمش ۱۰۰ گرمی ۲۴ عیار</div>
<div class="text-amber-800 dark:text-amber-300 font-semibold mt-1">موجودی صندوق: {{ goldBarCount }} قطعه</div>
<div class="text-[8px] text-slate-400 dark:text-slate-500 mt-0.5">شناسه استاندارد زرین‌بانک</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-900 rounded-xl p-2 border border-slate-200/80 dark:border-slate-800 space-y-1.5 mb-2.5">
<div class="flex items-center justify-between text-[9px]">
<span class="text-slate-500 dark:text-slate-400 font-medium">مظنه ۱۷ اتحادیه:</span>
<span class="font-black text-slate-900 dark:text-white">۱۸,۷۵۰,۰۰۰ <span class="text-[8px] font-normal text-slate-400">تومان</span></span>
</div>
<div class="flex items-center justify-between text-[9px]">
<span class="text-slate-500 dark:text-slate-400 font-medium">تسویه آنی شتاب:</span>
<span class="font-black text-emerald-600 dark:text-emerald-400">۴۵۰,۳۰۰,۰۰۰ <span class="text-[8px] font-normal text-slate-400">تومان</span></span>
</div>
</div>
<div class="space-y-1.5">
<div class="text-[9px] font-bold text-slate-400 dark:text-slate-500 px-1">آخرین فاکتورهای سیستمی:</div>
<div class="bg-white dark:bg-slate-900 rounded-lg p-1.5 border border-slate-100 dark:border-slate-800 flex items-center justify-between text-[9px] shadow-2xs">
<span class="text-slate-700 dark:text-slate-300 font-medium">فاکتور ۱۸۴۲ (زرگری شمس)</span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold">تایید شد ✓</span>
</div>
<div class="bg-white dark:bg-slate-900 rounded-lg p-1.5 border border-slate-100 dark:border-slate-800 flex items-center justify-between text-[9px] shadow-2xs">
<span class="text-slate-700 dark:text-slate-300 font-medium">حواله سکه بهار آزادی</span>
<span class="text-brand-600 dark:text-brand-400 font-bold">در انتظار</span>
</div>
</div>
</div>
<div class="pt-2 border-t border-slate-100 dark:border-slate-800 flex flex-col items-center gap-1.5">
<div class="w-full flex justify-around items-center text-slate-400 dark:text-slate-500 text-xs">
<span class="text-brand-600 font-black">●</span>
<span>■</span>
<span>▲</span>
<span>⚙</span>
</div>
<div class="w-24 h-1 bg-slate-800 dark:bg-slate-400 rounded-full mt-0.5" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ baseCount: number; transformStyle: string }>();
const goldBarCount = ref(props.baseCount);
</script>
@@ -0,0 +1,113 @@
<template>
<section ref="sectionEl" class="relative pt-8 pb-16 lg:pt-14 lg:pb-24 overflow-hidden hero-parallax-section">
<div class="absolute top-1/4 right-0 w-[550px] h-[550px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="absolute bottom-10 left-10 w-[500px] h-[500px] bg-sky-100/50 dark:bg-sky-600/10 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-8 items-center">
<div class="lg:col-span-5 space-y-6 text-right">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200/70 dark:border-slate-700/70 text-xs font-semibold text-slate-500 dark:text-slate-300">
<span class="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
<span>{{ hero.eyebrow }}</span>
</div>
<h1 class="text-4xl sm:text-5xl lg:text-[54px] font-black text-slate-900 dark:text-white leading-[1.28] tracking-tight">
{{ hero.titleLine1 }}
<br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-brand-600 via-brand-500 to-sky-400">
{{ hero.titleHighlight }}
</span>
</h1>
<p class="text-base sm:text-lg text-slate-600 dark:text-slate-300 font-normal leading-relaxed max-w-xl">
{{ hero.subtitle }}
</p>
<div class="pt-2 max-w-xl">
<form class="relative group" role="search" @submit.prevent="search">
<div class="relative flex items-center bg-white dark:bg-slate-900/95 rounded-full p-2 pl-2 shadow-soft-card hover:shadow-hover-card border border-slate-200/90 dark:border-slate-700/80 focus-within:border-brand-500 focus-within:ring-4 focus-within:ring-brand-100/70 dark:focus-within:ring-brand-900/40 transition-all duration-300">
<button
type="submit"
aria-label="جستجوی هوشمند"
class="flex-shrink-0 w-12 h-12 rounded-full bg-brand-600 text-white flex items-center justify-center hover:bg-brand-700 active:scale-95 transition-all shadow-md shadow-brand-600/30"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<input
v-model="ai.query.value"
type="text"
aria-label="نیاز کسب‌وکار خود را بنویسید"
:placeholder="hero.searchPlaceholder"
class="w-full bg-transparent text-sm sm:text-base text-slate-800 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none pr-3 pl-3 font-normal text-right"
>
<div class="pr-3 pl-2 text-brand-500 flex items-center justify-center" aria-hidden="true">
<svg class="w-5 h-5 text-brand-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L14.2 8.3L20.5 10.5L14.2 12.7L12 19L9.8 12.7L3.5 10.5L9.8 8.3L12 2Z" />
</svg>
</div>
</div>
<div class="mt-3.5 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span class="font-medium text-slate-400 dark:text-slate-500">{{ hero.suggestionsLabel }}</span>
<button
v-for="item in hero.suggestions"
:key="item.label"
type="button"
class="px-3 py-1 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-brand-50 dark:hover:bg-brand-950 hover:text-brand-600 dark:hover:text-brand-300 border border-transparent hover:border-brand-200 dark:hover:border-brand-800 transition-all"
@click="suggest(item.query)"
>
{{ item.label }}
</button>
</div>
</form>
</div>
</div>
<div
class="lg:col-span-7 relative perspective-stage flex justify-center items-center py-6 min-h-[520px] sm:min-h-[580px]"
@mousemove="parallax.onMouseMove"
@mouseleave="parallax.onMouseLeave"
>
<div class="studio-botanical-leaf hidden sm:block" aria-hidden="true">
<svg viewBox="0 0 280 280" fill="none" class="w-full h-full text-emerald-950/25 dark:text-emerald-500/15">
<path d="M50,220 C35,120 100,40 220,25 C250,90 200,190 95,235 C75,240 60,235 50,220 Z" fill="currentColor" />
<path d="M75,195 C110,160 145,120 195,75" stroke="#047857" stroke-width="5" stroke-linecap="round" />
<path d="M105,170 C130,175 160,190 180,210" stroke="#047857" stroke-width="4" stroke-linecap="round" />
<path d="M130,135 C160,135 185,150 205,165" stroke="#047857" stroke-width="4" stroke-linecap="round" />
<path d="M155,100 C185,95 215,105 235,120" stroke="#047857" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<div class="device-ground-shadow" />
<HeroLaptopMockup :mockup="hero.mockup" :transform-style="parallax.laptop.value" />
<HeroPhoneMockup :base-count="hero.mockup.goldBarCount" :transform-style="parallax.mobile.value" />
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const hero = section("hero");
const ai = useAiModal();
const parallax = useHeroParallax();
const sectionEl = ref<HTMLElement | null>(null);
function search() {
if (!ai.query.value.trim()) ai.query.value = hero.value.searchDefaultQuery;
ai.open();
}
function suggest(query: string) {
ai.query.value = query;
search();
}
useHeroScrollParallax(sectionEl);
</script>
@@ -0,0 +1,42 @@
<template>
<section class="py-14 border-y border-slate-200/80 dark:border-slate-800/80 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md relative z-10" aria-label="آمار منتک">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div
v-for="item in metrics.items"
:key="item.label"
class="fade-item flex items-center justify-center sm:justify-start gap-4 p-4 rounded-2xl hover:bg-slate-50 dark:hover:bg-slate-800/60 transition-all duration-300"
>
<div class="w-13 h-13 p-3 rounded-2xl bg-sky-50 dark:bg-sky-950/60 text-sky-600 dark:text-sky-400 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path v-for="(d, i) in ICON_PATHS[item.icon]" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="d" />
</svg>
</div>
<div class="text-right">
<div v-if="item.emphasis === 'text'" class="text-xl font-extrabold text-slate-900 dark:text-white">{{ item.value }}</div>
<div v-else class="text-3xl font-black text-slate-900 dark:text-white tracking-tight">{{ item.value }}</div>
<div class="text-sm text-slate-500 dark:text-slate-400 font-medium">{{ item.label }}</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { MetricIcon } from "@montech/shared";
const ICON_PATHS: Record<MetricIcon, string[]> = {
gear: [
"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
],
users: [
"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
],
box: ["M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"],
};
const { section } = useSiteContent();
const metrics = section("metrics");
</script>
@@ -0,0 +1,100 @@
<template>
<div
role="button"
tabindex="0"
:aria-label="`${item.title} — درخواست مشاوره`"
class="spotlight-card fade-item group bg-white dark:bg-slate-900/90 rounded-3xl p-6 border border-slate-200/90 dark:border-slate-800 shadow-soft-card hover:shadow-hover-card transition-all duration-300 flex flex-col justify-between text-center cursor-pointer"
:class="accent.border"
:style="tilt.style.value"
@mousemove="tilt.onMouseMove"
@mouseleave="tilt.onMouseLeave"
@click="select"
@keydown.enter.prevent="select"
@keydown.space.prevent="select"
>
<div>
<div class="w-20 h-20 mx-auto mb-6 flex items-center justify-center group-hover:scale-110 transition-transform duration-500">
<svg class="w-16 h-16 drop-shadow-md" viewBox="0 0 64 64" fill="none" aria-hidden="true">
<template v-if="item.icon === 'cube'">
<polygon points="32,6 58,20 32,34 6,20" fill="#38b2f8" />
<polygon points="6,20 32,34 32,60 6,46" fill="#0062f5" />
<polygon points="58,20 32,34 32,60 58,46" fill="#0046b8" />
</template>
<template v-else-if="item.icon === 'bars'">
<rect x="8" y="34" width="12" height="24" rx="3" fill="#38b2f8" />
<rect x="26" y="20" width="12" height="38" rx="3" fill="#007dfc" />
<rect x="44" y="8" width="12" height="50" rx="3" fill="#0050d8" />
</template>
<template v-else-if="item.icon === 'avatars'">
<circle cx="32" cy="18" r="10" fill="#6366f1" />
<path d="M16 46C16 38 23 34 32 34C41 34 48 38 48 46V50H16V46Z" fill="#4f46e5" />
<circle cx="48" cy="22" r="7" fill="#818cf8" />
<path d="M46 36C49 36 56 39 56 46V48H46V36Z" fill="#6366f1" />
</template>
<template v-else-if="item.icon === 'cart'">
<path d="M10 12H18L24 40H48L54 20H20" stroke="#06b6d4" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="26" cy="50" r="5" fill="#0891b2" />
<circle cx="46" cy="50" r="5" fill="#0891b2" />
</template>
<template v-else>
<polygon points="12,42 22,26 44,26 34,42" fill="#fde047" />
<polygon points="34,42 44,26 52,32 42,48" fill="#d97706" />
<polygon points="12,42 34,42 42,48 20,48" fill="#f59e0b" />
<polygon points="20,24 30,8 52,8 42,24" fill="#fef08a" />
<polygon points="42,24 52,8 60,14 50,30" fill="#b45309" />
<polygon points="20,24 42,24 50,30 28,30" fill="#f59e0b" />
</template>
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white transition-colors" :class="accent.title">{{ item.title }}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2 font-normal">{{ item.subtitle }}</p>
</div>
<div class="mt-6 pt-4 flex justify-center">
<span class="w-8 h-8 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-300 group-hover:text-white flex items-center justify-center transition-all duration-300 shadow-sm" :class="accent.bubble">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { SolutionAccent, SolutionItem } from "@montech/shared";
const props = defineProps<{ item: SolutionItem }>();
const ACCENTS: Record<SolutionAccent, { border: string; title: string; bubble: string }> = {
brand: {
border: "hover:border-brand-300 dark:hover:border-brand-500/50",
title: "group-hover:text-brand-600 dark:group-hover:text-brand-400",
bubble: "group-hover:bg-brand-600",
},
sky: {
border: "hover:border-sky-300 dark:hover:border-sky-500/50",
title: "group-hover:text-sky-600 dark:group-hover:text-sky-400",
bubble: "group-hover:bg-sky-500",
},
indigo: {
border: "hover:border-indigo-300 dark:hover:border-indigo-500/50",
title: "group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
bubble: "group-hover:bg-indigo-600",
},
cyan: {
border: "hover:border-cyan-300 dark:hover:border-cyan-500/50",
title: "group-hover:text-cyan-600 dark:group-hover:text-cyan-400",
bubble: "group-hover:bg-cyan-500",
},
amber: {
border: "hover:border-amber-300 dark:hover:border-amber-500/50",
title: "group-hover:text-amber-600 dark:group-hover:text-amber-400",
bubble: "group-hover:bg-amber-500",
},
};
const accent = computed(() => ACCENTS[props.item.accent] ?? ACCENTS.brand);
const tilt = useCardTilt();
const consult = useConsultModal();
function select() {
consult.open({ type: props.item.consultType, source: "solution" });
}
</script>
@@ -0,0 +1,20 @@
<template>
<section id="solutions" class="py-24 bg-white dark:bg-[#070d19] relative solutions-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center max-w-2xl mx-auto mb-16 space-y-2 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ solutions.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white">{{ solutions.title }}</h2>
<p class="text-sm sm:text-base text-slate-500 dark:text-slate-400 font-normal">{{ solutions.subtitle }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
<SolutionCard v-for="item in solutions.items" :key="item.title" :item="item" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const solutions = section("solutions");
</script>
@@ -0,0 +1,83 @@
<template>
<section id="testimonials" class="py-24 bg-slate-50/70 dark:bg-[#070d19]/90 overflow-hidden relative">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
<div class="lg:col-span-7 relative flex justify-center items-center py-10 fade-item">
<div
v-for="(src, i) in decorAvatars"
:key="i"
class="absolute w-14 h-14 rounded-full bg-white dark:bg-slate-800 shadow-md p-1 border border-slate-200 dark:border-slate-700 opacity-60"
:class="i === 0 ? '-right-2 top-4' : '-right-4 bottom-6'"
aria-hidden="true"
>
<SafeImage :src="src" alt="" width="56" height="56" class="w-full h-full rounded-full object-cover" />
</div>
<figure class="relative z-10 w-full max-w-lg bg-white dark:bg-slate-900/95 rounded-3xl p-8 border border-slate-100 dark:border-slate-800 shadow-xl space-y-6 text-right">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/60 px-3 py-1 rounded-full border border-brand-100 dark:border-brand-900/50">
<svg class="w-4 h-4 text-brand-600 dark:text-brand-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span>{{ t.badge }}</span>
</div>
<div class="text-slate-300 dark:text-slate-700" aria-hidden="true">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
</div>
</div>
<blockquote class="text-slate-800 dark:text-slate-100 text-lg font-bold leading-relaxed min-h-[60px]">
{{ t.quote }}
</blockquote>
<figcaption class="flex items-center justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-800">
<div class="text-right">
<div class="font-bold text-slate-900 dark:text-white text-sm">{{ t.name }}</div>
<div class="text-xs text-slate-400 dark:text-slate-500 font-medium">{{ t.role }}</div>
</div>
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-brand-500 shadow-sm">
<SafeImage :src="t.avatarUrl" :alt="t.name" width="48" height="48" class="w-full h-full object-cover" />
</div>
</figcaption>
</figure>
</div>
<div class="lg:col-span-5 text-right space-y-5 fade-item">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ t.eyebrow }}</span>
<h2 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">
{{ t.titleLine1 }}
<br>
<span>{{ t.titleLine2 }}</span>
</h2>
<p class="text-base text-slate-600 dark:text-slate-300 leading-relaxed font-normal">
{{ t.body }}
</p>
<div class="pt-2">
<button
type="button"
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full bg-brand-600 text-white font-bold text-sm hover:bg-brand-700 active:scale-95 transition-all duration-300 shadow-md shadow-brand-600/25"
@click="consult.open()"
>
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>{{ t.ctaLabel }}</span>
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const t = section("testimonials");
const consult = useConsultModal();
const decorAvatars = computed(() => t.value.decorAvatars.slice(0, 2));
</script>
@@ -0,0 +1,118 @@
<template>
<footer id="contact" class="bg-white dark:bg-[#060a14] border-t border-slate-200/80 dark:border-slate-800/80 pt-16 pb-12 text-right">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-10 pb-12 border-b border-slate-100 dark:border-slate-800/80">
<div class="lg:col-span-3 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.contactTitle }}</h2>
<ul class="space-y-3 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span>{{ settings.phone }}</span>
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span>{{ settings.email }}</span>
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-brand-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span>{{ settings.address }}</span>
</li>
</ul>
</div>
<div class="lg:col-span-3 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.productsTitle }}</h2>
<ul class="space-y-2 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li v-for="link in footer.products" :key="link.label">
<a :href="resolveHref(link.href)" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ link.label }}</a>
</li>
</ul>
</div>
<div class="lg:col-span-2 space-y-3">
<h2 class="text-sm font-bold text-slate-900 dark:text-white">{{ footer.quickLinksTitle }}</h2>
<ul class="space-y-2 text-xs text-slate-600 dark:text-slate-400 font-medium">
<li v-for="link in footer.quickLinks" :key="link.label">
<a :href="resolveHref(link.href)" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ link.label }}</a>
</li>
</ul>
</div>
<div class="lg:col-span-4 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-brand-600 to-sky-400 text-white">
<svg class="w-6 h-6" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<div class="flex flex-col">
<span class="text-xl font-black text-slate-900 dark:text-white">{{ footer.brandTitle }}</span>
<span class="text-[9px] font-bold text-slate-400 tracking-wider">{{ footer.brandSubtitle }}</span>
</div>
</div>
<p class="text-xs text-slate-400 dark:text-slate-500 leading-relaxed font-normal">
<template v-for="(line, i) in footer.descriptionLines" :key="i">
<br v-if="i > 0">
{{ line }}
</template>
</p>
<div class="flex items-center gap-3 pt-2">
<a
v-for="social in socials"
:key="social.kind + social.url"
:href="social.url"
:aria-label="social.label"
:target="social.url.startsWith('http') ? '_blank' : undefined"
:rel="social.url.startsWith('http') ? 'noopener noreferrer' : undefined"
class="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-brand-600 hover:text-white flex items-center justify-center transition-colors"
>
<SocialIcon :kind="social.kind" />
</a>
</div>
</div>
</div>
<div class="pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-slate-400 dark:text-slate-500 gap-4">
<div>{{ footer.copyright }}</div>
<div class="flex items-center gap-6">
<a
v-for="link in footer.legalLinks"
:key="link.label"
:href="resolveHref(link.href)"
class="hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>{{ link.label }}</a>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
type SocialKind = "telegram" | "linkedin" | "instagram" | "link";
const DEFAULT_SOCIALS: Array<{ kind: SocialKind; label: string; url: string }> = [
{ kind: "telegram", label: "تلگرام", url: "#" },
{ kind: "linkedin", label: "لینکدین", url: "#" },
{ kind: "instagram", label: "اینستاگرام", url: "#" },
];
function detectKind(label: string, url: string): SocialKind {
const s = `${label} ${url}`.toLowerCase();
if (s.includes("t.me") || s.includes("telegram") || s.includes("تلگرام")) return "telegram";
if (s.includes("linkedin") || s.includes("لینکدین")) return "linkedin";
if (s.includes("instagram") || s.includes("اینستاگرام")) return "instagram";
return "link";
}
const { section, settings } = useSiteContent();
const footer = section("footer");
const resolveHref = useSectionHref();
const socials = computed(() => {
const list = settings.value.socials;
if (!list.length) return DEFAULT_SOCIALS;
return list.map((s) => ({ kind: detectKind(s.label, s.url), label: s.label, url: s.url }));
});
</script>
@@ -0,0 +1,94 @@
<template>
<header class="sticky top-0 z-50 transition-all duration-300 bg-white/90 dark:bg-[#0b1329]/90 backdrop-blur-xl border-b border-slate-200/70 dark:border-slate-800/80 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
<div class="flex items-center gap-3">
<ThemeToggle />
<button
type="button"
class="hidden sm:inline-flex items-center gap-2.5 px-6 py-2.5 rounded-full bg-brand-600 hover:bg-brand-700 active:scale-95 text-white text-sm font-semibold transition-all duration-300 shadow-md shadow-brand-600/25 group"
@click="consult.open()"
>
<span class="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center -mr-1 transition-transform group-hover:-translate-x-1">
<svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</span>
<span>{{ header.ctaLabel }}</span>
</button>
<button
type="button"
class="md:hidden p-2 rounded-xl text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors focus:outline-none"
:aria-expanded="isMobileMenuOpen"
aria-controls="mobile-nav"
:aria-label="isMobileMenuOpen ? 'بستن منو' : 'باز کردن منو'"
@click="isMobileMenuOpen = !isMobileMenuOpen"
>
<svg v-if="!isMobileMenuOpen" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav class="hidden md:flex items-center gap-8 text-sm font-semibold text-slate-600 dark:text-slate-300" aria-label="منوی اصلی">
<a
v-for="link in desktopNav"
:key="link.href + link.label"
:href="resolveHref(link.href)"
class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors duration-200"
>{{ link.label }}</a>
</nav>
<a :href="resolveHref('#')" class="flex items-center gap-3 group focus:outline-none" :aria-label="header.brandTitle">
<div class="flex flex-col text-right">
<span class="text-2xl font-black tracking-tight text-slate-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ header.brandTitle }}</span>
<span class="text-[9px] font-black text-slate-400 dark:text-slate-500 tracking-[0.25em] uppercase -mt-1">{{ header.brandSubtitle }}</span>
</div>
<div class="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-br from-brand-600 via-brand-500 to-sky-400 text-white shadow-md shadow-brand-600/25 transition-all duration-500 group-hover:scale-105 group-hover:shadow-brand-600/40">
<svg class="w-7 h-7" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="16" cy="22" r="2.2" fill="white" />
</svg>
</div>
</a>
</div>
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0"
>
<div v-if="isMobileMenuOpen" id="mobile-nav" class="md:hidden bg-white/95 dark:bg-[#0b1329]/95 backdrop-blur-xl border-b border-slate-200 dark:border-slate-800 px-6 py-5 space-y-4 shadow-2xl">
<a
v-for="link in header.nav"
:key="link.href + link.label"
:href="resolveHref(link.href)"
class="block text-base font-semibold text-slate-700 dark:text-slate-200 hover:text-brand-600 dark:hover:text-brand-400"
@click="isMobileMenuOpen = false"
>{{ link.label }}</a>
<div class="pt-2 flex flex-col gap-2">
<button type="button" class="w-full flex items-center justify-center gap-2 py-3 rounded-full bg-brand-600 text-white font-bold shadow-md" @click="consult.open(); isMobileMenuOpen = false">
<span>{{ header.ctaLabel }}</span>
<svg class="w-4 h-4 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button>
</div>
</div>
</transition>
</header>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const header = section("header");
const consult = useConsultModal();
const isMobileMenuOpen = ref(false);
const desktopNav = computed(() => [...header.value.nav].reverse());
const resolveHref = useSectionHref();
</script>
@@ -0,0 +1,22 @@
<template>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path :d="PATHS[kind]" />
</svg>
</template>
<script setup lang="ts">
export type SocialKind = "telegram" | "linkedin" | "instagram" | "link";
defineProps<{ kind: SocialKind }>();
const PATHS: Record<SocialKind, string> = {
telegram:
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z",
linkedin:
"M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.28 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.75M6.46 8.76a1.64 1.64 0 1 0 0-3.28 1.64 1.64 0 0 0 0 3.28m1.4 9.74v-8.37H5.06v8.37h2.8z",
instagram:
"M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
link:
"M10.59 13.41a1 1 0 0 0 1.41 0l3.54-3.54a3 3 0 0 0-4.24-4.24l-1.77 1.77a1 1 0 1 0 1.41 1.41l1.77-1.77a1 1 0 0 1 1.42 1.42l-3.54 3.54a1 1 0 0 0 0 1.41zm2.82-2.82a1 1 0 0 0-1.41 0l-3.54 3.54a3 3 0 0 0 4.24 4.24l1.77-1.77a1 1 0 1 0-1.41-1.41l-1.77 1.77a1 1 0 0 1-1.42-1.42l3.54-3.54a1 1 0 0 0 0-1.41z",
};
</script>
@@ -0,0 +1,23 @@
<template>
<button
type="button"
:title="label"
:aria-label="label"
class="p-2.5 rounded-full border border-slate-200/90 dark:border-slate-700/80 bg-slate-100/80 dark:bg-slate-800/90 text-slate-600 dark:text-amber-400 hover:text-brand-600 dark:hover:text-amber-300 transition-all duration-300 shadow-sm hover:scale-105 active:scale-95 focus:outline-none"
@click="toggle"
>
<!-- Both icons are server-rendered; the `dark` class picks one, so SSR and hydration always agree. -->
<svg class="hidden dark:block w-5 h-5 transition-transform rotate-0 hover:rotate-45" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg class="dark:hidden w-5 h-5 text-slate-700 transition-transform hover:-rotate-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
</template>
<script setup lang="ts">
const { isDark, sync, toggle } = useTheme();
const label = computed(() => (isDark.value ? "تغییر به حالت روشن" : "تغییر به حالت تاریک"));
onMounted(sync);
</script>
@@ -0,0 +1,74 @@
<template>
<transition
appear
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="ai.isOpen.value" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 dark:bg-black/75 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-modal-title"
class="bg-white dark:bg-slate-900 rounded-3xl p-6 sm:p-8 max-w-lg w-full shadow-2xl border border-slate-100 dark:border-slate-800 text-right space-y-5"
>
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-emerald-500 animate-ping" />
<span id="ai-modal-title" class="font-bold text-slate-800 dark:text-white text-sm">{{ content.title }}</span>
</div>
<button type="button" aria-label="بستن" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="ai.close()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">{{ content.queryLabel }}</div>
<div class="text-sm font-semibold text-slate-800 dark:text-slate-200 bg-slate-50 dark:bg-slate-800 p-3 rounded-xl border border-slate-200 dark:border-slate-700">
«{{ ai.query.value || content.fallbackQuery }}»
</div>
</div>
<div class="space-y-3">
<div class="text-xs font-bold text-brand-600 dark:text-brand-400">{{ content.packageLabel }}</div>
<div class="bg-brand-50/70 dark:bg-brand-950/40 border border-brand-200 dark:border-brand-800/60 rounded-2xl p-4 space-y-2">
<div class="flex items-center justify-between">
<span class="font-bold text-slate-900 dark:text-white text-sm">{{ content.packageTitle }}</span>
<span class="text-xs bg-brand-600 text-white px-2 py-0.5 rounded-full font-bold">{{ content.matchBadge }}</span>
</div>
<ul class="text-xs text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
<li v-for="item in content.items" :key="item">{{ item }}</li>
</ul>
</div>
</div>
<div class="flex gap-3 pt-2">
<button type="button" class="flex-1 py-3 rounded-xl bg-brand-600 text-white text-sm font-bold shadow-md hover:bg-brand-700 transition-colors" @click="consultThisPackage">
{{ content.primaryCta }}
</button>
<button type="button" class="px-5 py-3 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" @click="ai.close()">
{{ content.closeLabel }}
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
const { section } = useSiteContent();
const hero = section("hero");
const content = computed(() => hero.value.ai);
const ai = useAiModal();
const consult = useConsultModal();
useEscapeKey(ai.isOpen, ai.close);
function consultThisPackage() {
ai.close();
consult.open({ source: "ai", aiQuery: ai.query.value });
}
</script>
@@ -0,0 +1,140 @@
<template>
<transition
appear
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="consult.isOpen.value" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 dark:bg-black/75 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="consult-modal-title"
class="bg-white dark:bg-slate-900 rounded-3xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-100 dark:border-slate-800 text-right space-y-5"
>
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-3">
<h3 id="consult-modal-title" class="font-bold text-slate-900 dark:text-white text-base">{{ content.title }}</h3>
<button type="button" aria-label="بستن" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="consult.close()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label for="consult-name" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.nameLabel }}</label>
<input
id="consult-name"
v-model="name"
required
type="text"
autocomplete="name"
maxlength="120"
:placeholder="content.namePlaceholder"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40"
>
</div>
<div>
<label for="consult-phone" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.phoneLabel }}</label>
<input
id="consult-phone"
v-model="phone"
required
type="tel"
dir="ltr"
autocomplete="tel"
inputmode="tel"
maxlength="20"
:placeholder="content.phonePlaceholder"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40 text-left"
>
</div>
<div>
<label for="consult-type" class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">{{ content.typeLabel }}</label>
<select
id="consult-type"
v-model="consult.type.value"
class="w-full text-sm px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100 dark:focus:ring-brand-900/40"
>
<option v-for="opt in content.types" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div v-if="submitted" role="status" class="p-3 bg-emerald-50 dark:bg-emerald-950/60 text-emerald-700 dark:text-emerald-300 text-xs rounded-xl font-medium border border-emerald-200 dark:border-emerald-800/50">
{{ content.successMessage }}
</div>
<div v-else-if="failed" role="alert" class="p-3 bg-rose-50 dark:bg-rose-950/60 text-rose-700 dark:text-rose-300 text-xs rounded-xl font-medium border border-rose-200 dark:border-rose-800/50">
{{ content.errorMessage }}
</div>
<div class="pt-2">
<button type="submit" class="w-full py-3 rounded-xl bg-brand-600 text-white text-sm font-bold hover:bg-brand-700 shadow-md transition-colors">
{{ content.submitLabel }}
</button>
</div>
</form>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import type { LeadInput } from "@montech/shared";
const CLOSE_DELAY_MS = 2000;
const { section } = useSiteContent();
const content = section("consult");
const consult = useConsultModal();
const name = ref("");
const phone = ref("");
const submitted = ref(false);
const failed = ref(false);
let sending = false;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
useEscapeKey(consult.isOpen, consult.close);
watch(consult.isOpen, (open) => {
if (open) {
submitted.value = false;
failed.value = false;
}
});
async function submit() {
if (sending) return;
sending = true;
failed.value = false;
const payload: LeadInput = {
name: name.value.trim(),
phone: phone.value.trim(),
businessType: consult.type.value,
source: consult.source.value,
aiQuery: consult.aiQuery.value || undefined,
};
try {
await apiFetch("/api/public/leads", { method: "POST", body: payload });
submitted.value = true;
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
consult.close();
name.value = "";
phone.value = "";
consult.resetType();
submitted.value = false;
}, CLOSE_DELAY_MS);
} catch {
failed.value = true;
} finally {
sending = false;
}
}
onBeforeUnmount(() => clearTimeout(closeTimer));
</script>
@@ -0,0 +1,23 @@
<template>
<div class="text-center max-w-2xl mx-auto mb-16 space-y-2 fade-item">
<nav v-if="crumbs?.length" aria-label="مسیر صفحه" class="text-xs font-bold text-slate-400 dark:text-slate-500">
<ol class="inline-flex items-center gap-2">
<li v-for="(c, i) in crumbs" :key="c.to" class="inline-flex items-center gap-2">
<NuxtLink v-if="i < crumbs.length - 1" :to="c.to" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">{{ c.label }}</NuxtLink>
<span v-else aria-current="page">{{ c.label }}</span>
<span v-if="i < crumbs.length - 1" aria-hidden="true">/</span>
</li>
</ol>
</nav>
<h1 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white leading-snug">{{ title }}</h1>
<p v-if="subtitle" class="text-sm sm:text-base text-slate-500 dark:text-slate-400 font-normal">{{ subtitle }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string;
subtitle?: string;
crumbs?: Array<{ label: string; to: string }>;
}>();
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<img
v-if="src && !failed"
ref="imgEl"
:src="src"
:alt="alt"
loading="lazy"
decoding="async"
@error="failed = true"
>
</template>
<script setup lang="ts">
/** Lazy image that removes itself on load failure so a broken-image icon never shows inside the design. */
const props = defineProps<{ src?: string | null; alt: string }>();
const failed = ref(false);
const imgEl = ref<HTMLImageElement | null>(null);
watch(() => props.src, () => (failed.value = false));
onMounted(() => {
// A server-rendered image can fail before hydration attaches the error listener.
const img = imgEl.value;
if (img && img.complete && img.naturalWidth === 0) failed.value = true;
});
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { CSSProperties } from "vue";
const REST: CSSProperties = {
transform: "perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(0px) scale3d(1, 1, 1)",
};
/** Pointer-follow 3D tilt + spotlight position for a card, batched to one update per frame. */
export function useCardTilt() {
const style = ref<CSSProperties>({});
let frame = 0;
let el: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
el.style.setProperty("--mouse-x", `${x}px`);
el.style.setProperty("--mouse-y", `${y}px`);
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -7;
const rotateY = ((x - centerX) / centerX) * 7;
style.value = {
transform: `perspective(1000px) rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateY(-6px) scale3d(1.02, 1.02, 1.02)`,
};
};
function onMouseMove(e: MouseEvent) {
el = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
el = null;
style.value = REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { style, onMouseMove, onMouseLeave };
}
+18
View File
@@ -0,0 +1,18 @@
import type { Ref } from "vue";
/** Calls `handler` on Escape while `active` is true. */
export function useEscapeKey(active: Ref<boolean>, handler: () => void) {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") handler();
};
watch(
active,
(on) => {
if (!import.meta.client) return;
if (on) document.addEventListener("keydown", onKey);
else document.removeEventListener("keydown", onKey);
},
{ immediate: true },
);
onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
}
@@ -0,0 +1,55 @@
const LAPTOP_REST = "transform: rotateY(-14deg) rotateX(8deg) rotateZ(-1deg);";
const MOBILE_REST = "transform: rotateY(-10deg) rotateX(6deg) translateY(-12px);";
/**
* Mouse-driven 3D tilt for the hero devices. Pointer events are coalesced into one
* layout read + one style write per animation frame.
*/
export function useHeroParallax() {
const laptop = ref(LAPTOP_REST);
const mobile = ref(MOBILE_REST);
let frame = 0;
let stage: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!stage) return;
const rect = stage.getBoundingClientRect();
const x = (clientX - rect.left) / rect.width - 0.5;
const y = (clientY - rect.top) / rect.height - 0.5;
const laptopRotateY = -14 + x * 14;
const laptopRotateX = 8 - y * 12;
const mobileRotateY = -10 + x * 20;
const mobileRotateX = 6 - y * 15;
const mobileTransY = -12 + y * 18;
const mobileTransX = x * 15;
laptop.value = `transform: rotateY(${laptopRotateY.toFixed(2)}deg) rotateX(${laptopRotateX.toFixed(2)}deg) rotateZ(-1deg);`;
mobile.value = `transform: rotateY(${mobileRotateY.toFixed(2)}deg) rotateX(${mobileRotateX.toFixed(2)}deg) translateY(${mobileTransY.toFixed(2)}px) translateX(${mobileTransX.toFixed(2)}px);`;
};
function onMouseMove(e: MouseEvent) {
stage = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
stage = null;
laptop.value = LAPTOP_REST;
mobile.value = MOBILE_REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { laptop, mobile, onMouseMove, onMouseLeave };
}
@@ -0,0 +1,46 @@
import type { Ref } from "vue";
/**
* Scroll-scrubbed hero device parallax. GSAP is imported only on the client after mount,
* keeping it off the critical rendering path; the context is reverted on unmount.
*/
export function useHeroScrollParallax(sectionEl: Ref<HTMLElement | null>) {
let revert: (() => void) | null = null;
let disposed = false;
onMounted(async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([import("gsap"), import("gsap/ScrollTrigger")]);
const root = sectionEl.value;
if (disposed || !root) return;
gsap.registerPlugin(ScrollTrigger);
const laptop = root.querySelector<HTMLElement>("#hero-laptop");
const mobile = root.querySelector<HTMLElement>("#hero-mobile");
if (!laptop || !mobile) return;
const ctx = gsap.context(() => {
gsap.to(laptop, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.2 },
y: -40,
rotateY: -25,
rotateX: 13,
scale: 0.98,
ease: "none",
});
gsap.to(mobile, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.5 },
y: -85,
rotateY: -6,
rotateX: 4,
scale: 1.03,
ease: "none",
});
}, root);
revert = () => ctx.revert();
});
onBeforeUnmount(() => {
disposed = true;
revert?.();
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { LeadSource } from "@montech/shared";
const DEFAULT_CONSULT_TYPE = "طلا و جواهر";
/** Consultation modal state shared by the header, hero, solution cards, banners and the AI modal. */
export function useConsultModal() {
const isOpen = useState("consult-open", () => false);
const mounted = useState("consult-mounted", () => false);
const type = useState("consult-type", () => DEFAULT_CONSULT_TYPE);
const source = useState<LeadSource>("consult-source", () => "consult");
const aiQuery = useState<string>("consult-ai-query", () => "");
function open(opts: { type?: string; source?: LeadSource; aiQuery?: string } = {}) {
if (opts.type) type.value = opts.type;
source.value = opts.source ?? "consult";
aiQuery.value = opts.aiQuery ?? "";
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function resetType() {
type.value = DEFAULT_CONSULT_TYPE;
}
return { isOpen, mounted, type, source, aiQuery, open, close, resetType };
}
export function useAiModal() {
const isOpen = useState("ai-open", () => false);
const mounted = useState("ai-mounted", () => false);
const query = useState("ai-query", () => "");
function open() {
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
return { isOpen, mounted, query, open, close };
}
+8 -49
View File
@@ -1,53 +1,12 @@
export function useApiBase() {
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
/** SSR talks to the API directly; the browser uses same-origin `/api` (nginx in production, devProxy locally). */
export function useApiBase(): string {
const config = useRuntimeConfig();
if (import.meta.server) return config.apiBase as string;
return (config.public.apiBase as string) || "";
if (import.meta.server) return config.apiBase;
return config.public.apiBase || "";
}
export async function fetchPublicSite() {
const base = useApiBase();
return await $fetch(`${base}/api/public/site`);
}
export async function fetchPublicBlog() {
const base = useApiBase();
return await $fetch(`${base}/api/public/blog`);
}
export async function fetchPublicPost(slug: string) {
const base = useApiBase();
return await $fetch(`${base}/api/public/blog/${slug}`);
}
export async function fetchPublicGallery() {
const base = useApiBase();
return await $fetch(`${base}/api/public/gallery`);
}
export async function fetchPage(key: string) {
const base = useApiBase();
return await $fetch(`${base}/api/public/pages/${key}`);
}
export function applyPageSeo(seo?: {
title?: string;
description?: string;
ogImage?: string | null;
canonical?: string | null;
noIndex?: boolean;
} | null) {
if (!seo) return;
useSeoMeta({
title: seo.title,
description: seo.description,
ogTitle: seo.title,
ogDescription: seo.description,
ogImage: seo.ogImage || undefined,
robots: seo.noIndex ? "noindex,nofollow" : "index,follow",
});
if (seo.canonical) {
useHead({
link: [{ rel: "canonical", href: seo.canonical }],
});
}
export function apiFetch<T>(path: string, options?: NitroFetchOptions<NitroFetchRequest>): Promise<T> {
return $fetch<T>(`${useApiBase()}${path}`, options as never) as Promise<T>;
}
+59
View File
@@ -0,0 +1,59 @@
import type { PageSeoData } from "@montech/shared";
import type { MaybeRefOrGetter } from "vue";
const DEFAULT_TITLE = "منتک | هوش‌افزار منظر - پلتفرم راهکارهای نرم‌افزاری و هوش مصنوعی";
const DEFAULT_DESCRIPTION =
"با کمک هوش مصنوعی منتک، بهترین نرم‌افزارها و خدمات متناسب با کسب‌وکار شما پیشنهاد می‌شود.";
function absoluteUrl(siteUrl: string, pathOrUrl?: string | null) {
if (!pathOrUrl) return undefined;
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
return `${siteUrl.replace(/\/$/, "")}${pathOrUrl.startsWith("/") ? "" : "/"}${pathOrUrl}`;
}
/** Central metadata for every page: title, description, canonical, Open Graph, Twitter and robots. */
export function usePageSeo(input: MaybeRefOrGetter<Partial<PageSeoData> | null | undefined>, opts: { type?: "website" | "article" } = {}) {
const config = useRuntimeConfig();
const route = useRoute();
const siteUrl = config.public.siteUrl.replace(/\/$/, "");
const seo = computed(() => toValue(input) || {});
const title = computed(() => seo.value.title || DEFAULT_TITLE);
const description = computed(() => seo.value.description || DEFAULT_DESCRIPTION);
const canonical = computed(() => absoluteUrl(siteUrl, seo.value.canonical) || `${siteUrl}${route.path === "/" ? "/" : route.path}`);
const image = computed(() => absoluteUrl(siteUrl, seo.value.ogImage) || `${siteUrl}/brand/og-default.png`);
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogType: opts.type ?? "website",
ogUrl: canonical,
ogImage: image,
ogLocale: "fa_IR",
ogSiteName: "منتک",
twitterCard: "summary_large_image",
twitterTitle: title,
twitterDescription: description,
twitterImage: image,
robots: computed(() => (seo.value.noIndex ? "noindex,nofollow" : "index,follow,max-image-preview:large")),
});
useHead({
link: [{ rel: "canonical", href: canonical }],
});
return { canonical, siteUrl };
}
export function useJsonLd(schema: MaybeRefOrGetter<Record<string, unknown> | Record<string, unknown>[]>) {
useHead({
script: [
{
type: "application/ld+json",
innerHTML: computed(() => JSON.stringify(toValue(schema))),
},
],
});
}
@@ -0,0 +1,49 @@
import type { PublicPage } from "@montech/shared";
export interface BlogPostSummary {
id: string;
slug: string;
title: string;
excerpt: string;
coverUrl: string | null;
publishedAt: string | null;
updatedAt?: string;
}
export interface BlogPost extends BlogPostSummary {
content: string;
seoTitle: string | null;
seoDesc: string | null;
ogImage: string | null;
canonical: string | null;
createdAt?: string;
}
export interface GalleryItem {
id: string;
title: string;
imageUrl: string;
category: string | null;
}
export function usePageMeta(key: string) {
return useAsyncData(`page-${key}`, () => apiFetch<PublicPage>(`/api/public/pages/${key}`).catch(() => null));
}
export function formatFaDate(value?: string | null) {
return value ? new Date(value).toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" }) : "";
}
export function useBreadcrumbJsonLd(items: Array<{ name: string; path: string }>) {
const siteUrl = useRuntimeConfig().public.siteUrl.replace(/\/$/, "");
useJsonLd({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: `${siteUrl}${item.path}`,
})),
});
}
@@ -0,0 +1,8 @@
/** In-page anchors (`#solutions`) work on the home page; elsewhere they must point back to `/`. */
export function useSectionHref() {
const route = useRoute();
return (href: string) => {
if (!href.startsWith("#") || route.path === "/") return href;
return href === "#" ? "/" : `/${href}`;
};
}
-14
View File
@@ -1,14 +0,0 @@
export async function useSiteChrome() {
const { data } = await useAsyncData("site-chrome", () => fetchPublicSite(), {
getCachedData: () => undefined,
});
return computed(() => ({
brand: data.value?.settings?.brandName || "منتک",
logoUrl: data.value?.settings?.logoUrl || "",
timezone: data.value?.settings?.timezone || "Asia/Tehran",
tagline: data.value?.settings?.tagline || "",
email: data.value?.settings?.email || "",
phone: data.value?.settings?.phone || "",
}));
}
@@ -0,0 +1,53 @@
import {
resolveSection,
type PublicSettings,
type PublicSitePayload,
type SectionKey,
type SiteSections,
} from "@montech/shared";
const SITE_KEY = "public-site";
export const DEFAULT_SETTINGS: PublicSettings = {
brandName: "منتک",
tagline: "هوش‌افزار منظر",
email: "info@montech.ir",
phone: "۰۹۱۲ ۳۴۵ ۶۷۸۹",
address: "تهران، ایران",
timezone: "Asia/Tehran",
socials: [],
logoUrl: null,
};
/**
* Loads the public site payload once per request (called from the default layout).
* A failed API call degrades to the built-in default content instead of breaking the page.
*/
export function loadSiteContent() {
return useAsyncData(SITE_KEY, () => apiFetch<PublicSitePayload>("/api/public/site").catch(() => null));
}
export function useSiteContent() {
const { data } = useNuxtData<PublicSitePayload | null>(SITE_KEY);
const settings = computed<PublicSettings>(() => {
const s = data.value?.settings;
if (!s) return DEFAULT_SETTINGS;
return {
...DEFAULT_SETTINGS,
...Object.fromEntries(Object.entries(s).filter(([, v]) => v !== "" && v !== null && v !== undefined)),
socials: Array.isArray(s.socials) ? s.socials.filter((x) => x && x.url) : [],
logoUrl: s.logoUrl || null,
};
});
function section<K extends SectionKey>(key: K) {
return computed<SiteSections[K]>(() => resolveSection(key, data.value?.sections?.[key]));
}
function pageSeo(key: string) {
return computed(() => data.value?.pages?.find((p) => p.key === key)?.seo ?? null);
}
return { data, settings, section, pageSeo };
}
+22
View File
@@ -0,0 +1,22 @@
const STORAGE_KEY = "montech_theme";
/** The initial class is applied by the inline boot script in `nuxt.config.ts` before first paint. */
export function useTheme() {
const isDark = useState("theme-dark", () => false);
function sync() {
isDark.value = document.documentElement.classList.contains("dark");
}
function toggle() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
try {
localStorage.setItem(STORAGE_KEY, isDark.value ? "dark" : "light");
} catch {
/* storage unavailable (private mode) */
}
}
return { isDark, sync, toggle };
}
+29
View File
@@ -0,0 +1,29 @@
<template>
<NuxtLayout>
<section class="py-32 relative overflow-hidden">
<div class="absolute top-1/4 right-0 w-[550px] h-[550px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-xl mx-auto px-4 text-center space-y-5">
<div class="text-7xl font-black text-transparent bg-clip-text bg-gradient-to-r from-brand-600 via-brand-500 to-sky-400">{{ toFa(error.statusCode) }}</div>
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white">{{ is404 ? "صفحه‌ای که دنبالش بودید پیدا نشد." : "خطایی رخ داد." }}</h1>
<p class="text-slate-600 dark:text-slate-300">{{ is404 ? "ممکن است آدرس تغییر کرده یا حذف شده باشد." : "لطفاً چند لحظه دیگر دوباره تلاش کنید." }}</p>
<button
type="button"
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full bg-brand-600 text-white font-bold text-sm hover:bg-brand-700 active:scale-95 transition-all duration-300 shadow-md shadow-brand-600/25"
@click="clearError({ redirect: '/' })"
>
بازگشت به صفحه اصلی
</button>
</div>
</section>
</NuxtLayout>
</template>
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps<{ error: NuxtError }>();
const is404 = computed(() => props.error.statusCode === 404);
const toFa = (n?: number) => String(n ?? 500).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
useSeoMeta({ title: is404.value ? "صفحه پیدا نشد — منتک" : "خطا — منتک", robots: "noindex,follow" });
</script>
+31
View File
@@ -0,0 +1,31 @@
<template>
<div>
<div class="scroll-progress" aria-hidden="true" />
<div class="relative overflow-x-clip min-h-screen flex flex-col justify-between">
<SiteHeader />
<main class="flex-grow">
<slot />
</main>
<SiteFooter />
<LazyAiSuggestModal v-if="ai.mounted.value" />
<LazyConsultModal v-if="consult.mounted.value" />
</div>
</div>
</template>
<script setup lang="ts">
await loadSiteContent();
const ai = useAiModal();
const consult = useConsultModal();
onNuxtReady(() => {
// Warm the modal chunks after the page is idle so the first open animates without a network wait.
void import("~/components/modals/ConsultModal.vue");
void import("~/components/modals/AiSuggestModal.vue");
});
</script>
+47 -6
View File
@@ -1,14 +1,28 @@
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath } from "node:url";
const apiBase = process.env.NUXT_API_BASE || "http://127.0.0.1:3050";
const themeBootScript =
"(function(){try{var t=localStorage.getItem('montech_theme');" +
"var d=t==='dark'||(!t&&window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches);" +
"document.documentElement.classList.toggle('dark',d)}catch(e){}})()";
export default defineNuxtConfig({
compatibilityDate: "2025-01-01",
devtools: { enabled: false },
css: ["~/assets/css/main.css"],
vite: {
plugins: [tailwindcss()],
css: ["~/assets/css/fonts.css", "~/assets/css/main.css"],
components: [{ path: "~/components", pathPrefix: false }],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
alias: {
"@montech/shared": fileURLToPath(new URL("../../packages/shared/src/index.ts", import.meta.url)),
},
runtimeConfig: {
apiBase: process.env.NUXT_API_BASE || "http://127.0.0.1:3050",
apiBase,
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://montech.ir",
apiBase: process.env.NUXT_PUBLIC_API_BASE || "",
@@ -16,10 +30,37 @@ export default defineNuxtConfig({
},
app: {
head: {
htmlAttrs: { lang: "fa", dir: "rtl" },
htmlAttrs: { lang: "fa", dir: "rtl", class: "scroll-smooth" },
bodyAttrs: {
class: "selection:bg-brand-600 selection:text-white antialiased hero-mesh dark:bg-[#070d19] dark:text-slate-100",
},
charset: "utf-8",
viewport: "width=device-width, initial-scale=1.0",
title: "منتک | هوش‌افزار منظر - پلتفرم راهکارهای نرم‌افزاری و هوش مصنوعی",
meta: [
{ name: "theme-color", content: "#0062f5" },
{ name: "format-detection", content: "telephone=no" },
],
link: [{ rel: "icon", type: "image/svg+xml", href: "/brand/logo.svg" }],
script: [{ innerHTML: themeBootScript, tagPosition: "head" }],
},
},
routeRules: {
"/": { swr: 30 },
"/blog": { swr: 120 },
"/blog/**": { swr: 120 },
"/gallery": { swr: 120 },
"/sitemap.xml": { swr: 3600 },
"/robots.txt": { swr: 86400 },
},
nitro: {
preset: "node-server",
compressPublicAssets: true,
devProxy: {
"/api": { target: `${apiBase}/api`, changeOrigin: true },
},
},
experimental: {
payloadExtraction: false,
},
});
+6 -4
View File
@@ -11,16 +11,18 @@
"lint": "echo \"landing lint placeholder\""
},
"dependencies": {
"@fontsource/vazirmatn": "^5.3.0",
"@montech/shared": "*",
"@tailwindcss/vite": "^4.1.17",
"lenis": "^1.3.26",
"lucide-vue-next": "^0.511.0",
"gsap": "^3.12.5",
"lenis": "1.1.18",
"nuxt": "^3.16.2",
"tailwindcss": "^4.1.17",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"autoprefixer": "^10.6.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2"
}
}
+74 -33
View File
@@ -1,52 +1,93 @@
<template>
<article v-if="post" class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar
:brand="chrome.brand"
:logo-url="chrome.logoUrl"
:timezone="chrome.timezone"
/>
<div class="mx-auto max-w-[760px]">
<NuxtLink to="/blog" class="text-sm text-ash hover:text-volt">→ بازگشت به بلاگ</NuxtLink>
<h1 class="mt-8 font-display text-3xl font-extrabold leading-tight md:text-5xl">
{{ post.title }}
</h1>
<p class="mt-4 text-sm text-ash">
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("fa-IR") : "" }}
</p>
<div class="mt-10 whitespace-pre-line text-base leading-relaxed text-bone/90">{{ plainContent }}</div>
<article v-if="post" class="py-24 relative overflow-hidden">
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-right">
<nav aria-label="مسیر صفحه" class="text-xs font-bold text-slate-400 dark:text-slate-500 mb-6">
<ol class="flex flex-wrap items-center gap-2">
<li><NuxtLink to="/" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">خانه</NuxtLink></li>
<li aria-hidden="true">/</li>
<li><NuxtLink to="/blog" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">بلاگ</NuxtLink></li>
<li aria-hidden="true">/</li>
<li aria-current="page" class="text-slate-500 dark:text-slate-400">{{ post.title }}</li>
</ol>
</nav>
<header class="space-y-4 mb-10">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-black text-slate-900 dark:text-white leading-[1.35] tracking-tight">{{ post.title }}</h1>
<p v-if="post.excerpt" class="text-base sm:text-lg text-slate-600 dark:text-slate-300 leading-relaxed">{{ post.excerpt }}</p>
<time v-if="post.publishedAt" :datetime="post.publishedAt" class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200/70 dark:border-slate-700/70 text-xs font-semibold text-slate-500 dark:text-slate-300">
<span class="w-2 h-2 rounded-full bg-brand-500" />
{{ formatFaDate(post.publishedAt) }}
</time>
</header>
<div v-if="post.coverUrl" class="mb-10 rounded-3xl overflow-hidden border border-slate-200/90 dark:border-slate-800 shadow-soft-card aspect-[16/9] bg-slate-100 dark:bg-slate-900">
<img :src="post.coverUrl" :alt="post.title" width="1200" height="675" fetchpriority="high" class="w-full h-full object-cover">
</div>
<div class="bg-white dark:bg-slate-900/90 rounded-3xl p-6 sm:p-10 border border-slate-200/90 dark:border-slate-800 shadow-soft-card">
<div class="whitespace-pre-line text-base leading-loose text-slate-700 dark:text-slate-300">{{ plainContent }}</div>
</div>
<div class="pt-10">
<NuxtLink to="/blog" class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 font-bold text-sm hover:border-brand-600 dark:hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-brand-50/50 dark:hover:bg-slate-800/50 transition-all duration-300">
<svg class="w-4 h-4 text-brand-600 dark:text-brand-400 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
<span>بازگشت به بلاگ</span>
</NuxtLink>
</div>
</div>
</article>
</template>
<script setup lang="ts">
const chrome = await useSiteChrome();
const route = useRoute();
const slug = String(route.params.slug);
const { data: post } = await useAsyncData(`blog-${slug}`, () => fetchPublicPost(slug), {
getCachedData: () => undefined,
});
const { data: post } = await useAsyncData(`blog-${slug}`, () => apiFetch<BlogPost>(`/api/public/blog/${encodeURIComponent(slug)}`).catch(() => null));
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: "پست پیدا نشد" });
throw createError({ statusCode: 404, statusMessage: "پست پیدا نشد", fatal: true });
}
const plainContent = computed(() => {
const raw = post.value?.content || "";
return raw
const plainContent = computed(() =>
(post.value?.content || "")
.replace(/<\/p>/gi, "\n\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.trim();
});
.trim(),
);
const { canonical, siteUrl } = usePageSeo(
() => ({
title: post.value?.seoTitle || post.value?.title,
description: post.value?.seoDesc || post.value?.excerpt,
ogImage: post.value?.ogImage || post.value?.coverUrl,
canonical: post.value?.canonical,
}),
{ type: "article" },
);
useSeoMeta({
title: post.value.seoTitle || post.value.title,
description: post.value.seoDesc || post.value.excerpt,
ogTitle: post.value.seoTitle || post.value.title,
ogDescription: post.value.seoDesc || post.value.excerpt,
ogImage: post.value.ogImage || post.value.coverUrl || undefined,
articlePublishedTime: () => post.value?.publishedAt || undefined,
articleModifiedTime: () => post.value?.updatedAt || undefined,
});
if (post.value.canonical) {
useHead({ link: [{ rel: "canonical", href: post.value.canonical }] });
}
useBreadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "بلاگ", path: "/blog" },
{ name: post.value.title, path: `/blog/${slug}` },
]);
useJsonLd(() => ({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.value?.title,
description: post.value?.seoDesc || post.value?.excerpt,
datePublished: post.value?.publishedAt || undefined,
dateModified: post.value?.updatedAt || post.value?.publishedAt || undefined,
inLanguage: "fa-IR",
mainEntityOfPage: canonical.value,
...(post.value?.coverUrl ? { image: new URL(post.value.coverUrl, siteUrl).toString() } : {}),
author: { "@id": `${siteUrl}/#organization` },
publisher: { "@id": `${siteUrl}/#organization` },
}));
</script>
+49 -32
View File
@@ -1,38 +1,55 @@
<template>
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar
:brand="chrome.brand"
:logo-url="chrome.logoUrl"
:timezone="chrome.timezone"
/>
<div class="mx-auto max-w-[1100px]">
<h1 class="font-display text-4xl font-extrabold md:text-6xl">بلاگ</h1>
<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]">
<li v-for="post in posts" :key="post.id" class="py-8">
<NuxtLink :to="`/blog/${post.slug}`" class="group block">
<p class="text-sm text-ash">
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("fa-IR") : "" }}
</p>
<h2 class="mt-2 font-display text-2xl font-bold group-hover:text-volt md:text-3xl">
{{ post.title }}
</h2>
<p class="mt-3 max-w-2xl text-sm text-ash">{{ post.excerpt }}</p>
</NuxtLink>
</li>
</ul>
<p v-if="!posts?.length" class="mt-10 text-ash">هنوز پستی منتشر نشده است.</p>
<section class="py-24 relative overflow-hidden">
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<PageHeading
:title="page?.title || 'بلاگ'"
:subtitle="page?.seo?.description || 'یادداشت‌هایی درباره محصول، مهندسی و ساخت نرم‌افزار.'"
:crumbs="[{ label: 'خانه', to: '/' }, { label: page?.title || 'بلاگ', to: '/blog' }]"
/>
<div v-if="posts?.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<NuxtLink
v-for="post in posts"
:key="post.id"
:to="`/blog/${post.slug}`"
class="fade-item group bg-white dark:bg-slate-900/90 rounded-3xl border border-slate-200/90 dark:border-slate-800 shadow-soft-card hover:shadow-hover-card hover:border-brand-300 dark:hover:border-brand-500/50 transition-all duration-300 overflow-hidden flex flex-col text-right"
>
<div class="aspect-[16/9] bg-gradient-to-br from-brand-50 via-sky-50 to-blue-100/70 dark:from-slate-900 dark:via-[#0c1e3f] dark:to-slate-900 overflow-hidden">
<SafeImage
v-if="post.coverUrl"
:src="post.coverUrl"
:alt="post.title"
width="640"
height="360"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div class="p-6 space-y-2 flex-1 flex flex-col">
<time v-if="post.publishedAt" :datetime="post.publishedAt" class="text-xs font-bold text-slate-400 dark:text-slate-500">{{ formatFaDate(post.publishedAt) }}</time>
<h2 class="text-lg font-bold text-slate-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ post.title }}</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed font-normal flex-1">{{ post.excerpt }}</p>
<span class="pt-3 inline-flex items-center gap-2 text-sm font-bold text-brand-600 dark:text-brand-400">
<svg class="w-4 h-4 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
ادامه مطلب
</span>
</div>
</NuxtLink>
</div>
<p v-else class="text-center text-slate-500 dark:text-slate-400">هنوز پستی منتشر نشده است.</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const chrome = await useSiteChrome();
const { data: posts } = await useAsyncData("blog-list", () => fetchPublicBlog(), {
getCachedData: () => undefined,
});
const { data: page } = await useAsyncData("page-blog", () => fetchPage("blog"), {
getCachedData: () => undefined,
});
applyPageSeo(page.value?.seo);
const [{ data: posts }, { data: page }] = await Promise.all([
useAsyncData("blog-list", () => apiFetch<BlogPostSummary[]>("/api/public/blog").catch(() => [])),
usePageMeta("blog"),
]);
usePageSeo(() => page.value?.seo ?? { title: "بلاگ — منتک" });
useBreadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: page.value?.title || "بلاگ", path: "/blog" },
]);
</script>
+33 -28
View File
@@ -1,37 +1,42 @@
<template>
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
<SiteNavbar
:brand="chrome.brand"
:logo-url="chrome.logoUrl"
:timezone="chrome.timezone"
/>
<div class="mx-auto max-w-[1500px]">
<h1 class="font-display text-4xl font-extrabold md:text-6xl">گالری</h1>
<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">
<figure v-for="item in items" :key="item.id" class="overflow-hidden border border-white/[0.07] bg-coal">
<div
class="aspect-[4/3] bg-cover bg-center"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
/>
<figcaption class="p-4">
<p class="font-display text-lg font-bold">{{ item.title }}</p>
<p v-if="item.category" class="mt-1 text-xs text-ash">{{ item.category }}</p>
<section class="py-24 relative overflow-hidden">
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-100/50 dark:bg-brand-600/15 rounded-full blur-[140px] pointer-events-none -z-10" />
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<PageHeading
:title="page?.title || 'گالری'"
:subtitle="page?.seo?.description || 'تصاویر منتخب از پروژه‌ها و فضای استودیو منتک.'"
:crumbs="[{ label: 'خانه', to: '/' }, { label: page?.title || 'گالری', to: '/gallery' }]"
/>
<div v-if="items?.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<figure
v-for="item in items"
:key="item.id"
class="fade-item group bg-white dark:bg-slate-900/90 rounded-3xl border border-slate-200/90 dark:border-slate-800 shadow-soft-card hover:shadow-hover-card hover:border-brand-300 dark:hover:border-brand-500/50 transition-all duration-300 overflow-hidden text-right"
>
<div class="aspect-[4/3] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<SafeImage :src="item.imageUrl" :alt="item.title" width="640" height="480" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
</div>
<figcaption class="p-5">
<p class="text-base font-bold text-slate-900 dark:text-white">{{ item.title }}</p>
<p v-if="item.category" class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ item.category }}</p>
</figcaption>
</figure>
</div>
<p v-if="!items?.length" class="mt-10 text-ash">گالری خالی است.</p>
<p v-else class="text-center text-slate-500 dark:text-slate-400">گالری خالی است.</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const chrome = await useSiteChrome();
const { data: items } = await useAsyncData("gallery", () => fetchPublicGallery(), {
getCachedData: () => undefined,
});
const { data: page } = await useAsyncData("page-gallery", () => fetchPage("gallery"), {
getCachedData: () => undefined,
});
applyPageSeo(page.value?.seo);
const [{ data: items }, { data: page }] = await Promise.all([
useAsyncData("gallery", () => apiFetch<GalleryItem[]>("/api/public/gallery").catch(() => [])),
usePageMeta("gallery"),
]);
usePageSeo(() => page.value?.seo ?? { title: "گالری — منتک" });
useBreadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: page.value?.title || "گالری", path: "/gallery" },
]);
</script>
+48 -50
View File
@@ -1,58 +1,56 @@
<template>
<div v-if="site">
<SiteNavbar
:brand="site.settings.brandName"
:logo-url="site.settings.logoUrl"
:timezone="site.settings.timezone"
/>
<main>
<HeroSection :data="site.sections.hero" />
<section class="relative overflow-hidden py-16 md:py-24">
<div class="w-[110%] -translate-x-[5%] -rotate-2 bg-volt py-4 text-ink md:py-5 rtl:translate-x-[5%]">
<SiteMarquee
:items="site.sections.marquee.items"
:duration="26"
class-name="font-display text-xl font-bold tracking-[-0.01em] md:text-3xl"
item-class-name="mx-4 md:mx-6"
separator-class-name="text-ink/50"
/>
</div>
</section>
<ManifestoSection
:data="site.sections.manifesto"
:intro="{ body: site.sections.hero.body, cta: site.sections.hero.cta }"
/>
<ServicesSection :data="site.sections.services" />
<WorkSection :data="site.sections.work" />
<ProcessSection :data="site.sections.process" />
<StatsSection :data="site.sections.stats" />
<TeamSection :data="site.sections.team" />
</main>
<SiteFooter
:brand="site.settings.brandName"
:email="site.settings.email"
:phone="site.settings.phone"
:address="site.settings.address"
:socials="site.settings.socials"
:data="site.sections.footer"
/>
</div>
<div v-else class="grid min-h-screen place-items-center text-ash">
Unable to load site content. Is the API running?
<div>
<HeroSection />
<MetricsBar />
<FeaturesSection />
<SolutionsSection />
<TestimonialsSection />
<CtaBanner />
</div>
</template>
<script setup lang="ts">
const { data: site } = await useAsyncData("public-site", () => fetchPublicSite(), {
// always refetch so admin edits show up after refresh
getCachedData: () => undefined,
});
const homePage = computed(() => site.value?.pages?.find((p: any) => p.key === "home"));
applyPageSeo(homePage.value?.seo);
const { settings, pageSeo, section } = useSiteContent();
const { siteUrl } = usePageSeo(pageSeo("home"));
const hero = section("hero");
const loaderTitle = useState("intro-loader-title", () => "هوش‌افزار منتک");
watchEffect(() => {
const hero = site.value?.sections?.hero as { loaderTitle?: string } | undefined;
if (hero?.loaderTitle) loaderTitle.value = hero.loaderTitle;
useJsonLd(() => {
const s = settings.value;
const sameAs = s.socials.map((x) => x.url).filter((u) => /^https?:\/\//.test(u));
return [
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: s.brandName,
alternateName: "Montech",
url: `${siteUrl}/`,
logo: s.logoUrl ? new URL(s.logoUrl, siteUrl).toString() : `${siteUrl}/brand/logo.svg`,
email: s.email,
telephone: s.phone,
address: { "@type": "PostalAddress", addressLocality: s.address, addressCountry: "IR" },
...(sameAs.length ? { sameAs } : {}),
},
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: `${siteUrl}/`,
name: s.brandName,
inLanguage: "fa-IR",
publisher: { "@id": `${siteUrl}/#organization` },
},
{
"@context": "https://schema.org",
"@type": "WebPage",
"@id": `${siteUrl}/#webpage`,
url: `${siteUrl}/`,
name: `${hero.value.titleLine1} ${hero.value.titleHighlight}`,
description: hero.value.subtitle,
inLanguage: "fa-IR",
isPartOf: { "@id": `${siteUrl}/#website` },
about: { "@id": `${siteUrl}/#organization` },
},
];
});
</script>
+68 -10
View File
@@ -1,19 +1,77 @@
import Lenis from "lenis";
const REVEAL_SELECTOR = ".fade-item:not(.revealed)";
const REVEAL_SCROLL_RATIO = 0.88;
const REVEAL_INITIAL_RATIO = 0.92;
const INITIAL_REVEAL_DELAY_MS = 150;
/**
* Inertial scrolling plus the two scroll-driven effects that depend on it:
* the top progress bar and the `.fade-item` reveal.
*/
export default defineNuxtPlugin((nuxtApp) => {
if (!import.meta.client) return;
const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
let raf = 0;
const lenis = new Lenis({
duration: 1.2,
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: "vertical",
smoothWheel: true,
wheelMultiplier: 1.05,
touchMultiplier: 1.8,
});
let pending: HTMLElement[] = [];
let progressEl: HTMLElement | null = null;
const collect = () => {
pending = Array.from(document.querySelectorAll<HTMLElement>(REVEAL_SELECTOR));
progressEl = document.querySelector<HTMLElement>(".scroll-progress");
};
const reveal = (ratio: number) => {
if (!pending.length) return;
const limit = window.innerHeight * ratio;
const hits: HTMLElement[] = [];
pending = pending.filter((el) => {
if (!el.isConnected) return false;
if (el.getBoundingClientRect().top <= limit) {
hits.push(el);
return false;
}
return true;
});
for (const el of hits) el.classList.add("revealed");
};
lenis.on("scroll", (e: Lenis) => {
if (progressEl) {
const total = document.documentElement.scrollHeight - window.innerHeight;
const progress = total > 0 ? e.scroll / total : 0;
progressEl.style.transform = `scaleX(${progress})`;
}
reveal(REVEAL_SCROLL_RATIO);
});
let rafId = 0;
const loop = (time: number) => {
lenis.raf(time);
raf = requestAnimationFrame(loop);
rafId = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
nuxtApp.hook("app:unmounted", () => {
cancelAnimationFrame(raf);
rafId = requestAnimationFrame(loop);
const initialReveal = () => {
collect();
window.setTimeout(() => {
collect();
reveal(REVEAL_INITIAL_RATIO);
}, INITIAL_REVEAL_DELAY_MS);
};
nuxtApp.hook("app:mounted", initialReveal);
nuxtApp.hook("page:finish", initialReveal);
nuxtApp.vueApp.onUnmount(() => {
cancelAnimationFrame(rafId);
lenis.destroy();
});
return {
provide: { lenis },
};
return { provide: { lenis } };
});
+13 -4
View File
@@ -1,5 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0A0A0B"/>
<circle cx="32" cy="32" r="17" fill="none" stroke="#C8FF2E" stroke-width="7"/>
<text x="32" y="38" text-anchor="middle" fill="#F2F2EE" font-family="Arial,sans-serif" font-size="14" font-weight="700">M</text>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="44" y2="44" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0062f5"/>
<stop offset="0.5" stop-color="#007dfc"/>
<stop offset="1" stop-color="#38bdf8"/>
</linearGradient>
</defs>
<rect width="44" height="44" rx="16" fill="url(#g)"/>
<g transform="translate(8.25 8.25) scale(0.859375)">
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="22" r="2.2" fill="white"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

+1 -1
View File
@@ -2,5 +2,5 @@ export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const site = config.public.siteUrl || "https://montech.ir";
setHeader(event, "Content-Type", "text/plain");
return `User-agent: *\nAllow: /\nDisallow: /adm\nSitemap: ${site}/sitemap.xml\n`;
return `User-agent: *\nAllow: /\nDisallow: /adm\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`;
});
+43
View File
@@ -0,0 +1,43 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: [
"./app.vue",
"./error.vue",
"./nuxt.config.ts",
"./components/**/*.vue",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./composables/**/*.ts",
],
theme: {
extend: {
fontFamily: {
sans: ["Vazirmatn", "system-ui", "-apple-system", "sans-serif"],
},
colors: {
brand: {
50: "#f0f6ff",
100: "#e0effe",
200: "#bae2fd",
300: "#7ccbfd",
400: "#38b2f8",
500: "#007dfc",
600: "#0062f5",
700: "#0050d8",
800: "#0642ad",
900: "#0b3987",
},
deepNavy: "#0a192f",
},
boxShadow: {
"soft-card": "0 12px 36px -8px rgba(15, 23, 42, 0.06), 0 4px 12px -2px rgba(15, 23, 42, 0.03)",
"hover-card": "0 24px 50px -10px rgba(0, 98, 245, 0.15), 0 8px 24px -4px rgba(15, 23, 42, 0.06)",
"device-3d": "0 45px 95px -20px rgba(15, 23, 42, 0.38), 0 20px 45px -15px rgba(0, 98, 245, 0.18)",
"phone-3d": "0 35px 75px -15px rgba(15, 23, 42, 0.45), 0 15px 30px -10px rgba(0, 98, 245, 0.22)",
"glow-cta": "0 18px 40px -8px rgba(0, 98, 245, 0.35)",
},
},
},
} satisfies Config;
-5
View File
@@ -1,5 +0,0 @@
/** Resolve media/logo URLs for browser and SSR (relative /api/uploads/... stay as-is). */
export function mediaUrl(url?: string | null) {
if (!url) return "";
return url;
}
-32
View File
@@ -1,32 +0,0 @@
const FALLBACK_TZ = "Asia/Tehran";
const TZ_ALIASES: Record<string, string> = {
"Asia/Esfahan": FALLBACK_TZ,
"Asia/Isfahan": FALLBACK_TZ,
"Asia/Mashhad": FALLBACK_TZ,
"Asia/Shiraz": FALLBACK_TZ,
"Asia/Tabriz": FALLBACK_TZ,
Iran: FALLBACK_TZ,
};
export function resolveTimezone(input?: string | null): string {
const raw = (input || FALLBACK_TZ).trim() || FALLBACK_TZ;
const candidate = TZ_ALIASES[raw] || raw;
try {
Intl.DateTimeFormat("en-US", { timeZone: candidate });
return candidate;
} catch {
return FALLBACK_TZ;
}
}
export function formatClock(date: Date, timezone?: string | null, locale = "fa-IR") {
const tz = resolveTimezone(timezone);
return new Intl.DateTimeFormat(locale, {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}