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:
+2
-10
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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?.();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
|
||||
@@ -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 |
@@ -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`;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user