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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 20:36:06 +03:30
co-authored by Cursor
parent 5147c661f0
commit b0088c35d1
130 changed files with 8024 additions and 2801 deletions
+54
View File
@@ -0,0 +1,54 @@
import type { CSSProperties } from "vue";
const REST: CSSProperties = {
transform: "perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(0px) scale3d(1, 1, 1)",
};
/** Pointer-follow 3D tilt + spotlight position for a card, batched to one update per frame. */
export function useCardTilt() {
const style = ref<CSSProperties>({});
let frame = 0;
let el: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
el.style.setProperty("--mouse-x", `${x}px`);
el.style.setProperty("--mouse-y", `${y}px`);
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -7;
const rotateY = ((x - centerX) / centerX) * 7;
style.value = {
transform: `perspective(1000px) rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateY(-6px) scale3d(1.02, 1.02, 1.02)`,
};
};
function onMouseMove(e: MouseEvent) {
el = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
el = null;
style.value = REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { style, onMouseMove, onMouseLeave };
}
+18
View File
@@ -0,0 +1,18 @@
import type { Ref } from "vue";
/** Calls `handler` on Escape while `active` is true. */
export function useEscapeKey(active: Ref<boolean>, handler: () => void) {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") handler();
};
watch(
active,
(on) => {
if (!import.meta.client) return;
if (on) document.addEventListener("keydown", onKey);
else document.removeEventListener("keydown", onKey);
},
{ immediate: true },
);
onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
}
@@ -0,0 +1,55 @@
const LAPTOP_REST = "transform: rotateY(-14deg) rotateX(8deg) rotateZ(-1deg);";
const MOBILE_REST = "transform: rotateY(-10deg) rotateX(6deg) translateY(-12px);";
/**
* Mouse-driven 3D tilt for the hero devices. Pointer events are coalesced into one
* layout read + one style write per animation frame.
*/
export function useHeroParallax() {
const laptop = ref(LAPTOP_REST);
const mobile = ref(MOBILE_REST);
let frame = 0;
let stage: HTMLElement | null = null;
let clientX = 0;
let clientY = 0;
const apply = () => {
frame = 0;
if (!stage) return;
const rect = stage.getBoundingClientRect();
const x = (clientX - rect.left) / rect.width - 0.5;
const y = (clientY - rect.top) / rect.height - 0.5;
const laptopRotateY = -14 + x * 14;
const laptopRotateX = 8 - y * 12;
const mobileRotateY = -10 + x * 20;
const mobileRotateX = 6 - y * 15;
const mobileTransY = -12 + y * 18;
const mobileTransX = x * 15;
laptop.value = `transform: rotateY(${laptopRotateY.toFixed(2)}deg) rotateX(${laptopRotateX.toFixed(2)}deg) rotateZ(-1deg);`;
mobile.value = `transform: rotateY(${mobileRotateY.toFixed(2)}deg) rotateX(${mobileRotateX.toFixed(2)}deg) translateY(${mobileTransY.toFixed(2)}px) translateX(${mobileTransX.toFixed(2)}px);`;
};
function onMouseMove(e: MouseEvent) {
stage = e.currentTarget as HTMLElement;
clientX = e.clientX;
clientY = e.clientY;
if (!frame) frame = requestAnimationFrame(apply);
}
function onMouseLeave() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
stage = null;
laptop.value = LAPTOP_REST;
mobile.value = MOBILE_REST;
}
onBeforeUnmount(() => {
if (frame) cancelAnimationFrame(frame);
});
return { laptop, mobile, onMouseMove, onMouseLeave };
}
@@ -0,0 +1,46 @@
import type { Ref } from "vue";
/**
* Scroll-scrubbed hero device parallax. GSAP is imported only on the client after mount,
* keeping it off the critical rendering path; the context is reverted on unmount.
*/
export function useHeroScrollParallax(sectionEl: Ref<HTMLElement | null>) {
let revert: (() => void) | null = null;
let disposed = false;
onMounted(async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([import("gsap"), import("gsap/ScrollTrigger")]);
const root = sectionEl.value;
if (disposed || !root) return;
gsap.registerPlugin(ScrollTrigger);
const laptop = root.querySelector<HTMLElement>("#hero-laptop");
const mobile = root.querySelector<HTMLElement>("#hero-mobile");
if (!laptop || !mobile) return;
const ctx = gsap.context(() => {
gsap.to(laptop, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.2 },
y: -40,
rotateY: -25,
rotateX: 13,
scale: 0.98,
ease: "none",
});
gsap.to(mobile, {
scrollTrigger: { trigger: root, start: "top top", end: "bottom top", scrub: 1.5 },
y: -85,
rotateY: -6,
rotateX: 4,
scale: 1.03,
ease: "none",
});
}, root);
revert = () => ctx.revert();
});
onBeforeUnmount(() => {
disposed = true;
revert?.();
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { LeadSource } from "@montech/shared";
const DEFAULT_CONSULT_TYPE = "طلا و جواهر";
/** Consultation modal state shared by the header, hero, solution cards, banners and the AI modal. */
export function useConsultModal() {
const isOpen = useState("consult-open", () => false);
const mounted = useState("consult-mounted", () => false);
const type = useState("consult-type", () => DEFAULT_CONSULT_TYPE);
const source = useState<LeadSource>("consult-source", () => "consult");
const aiQuery = useState<string>("consult-ai-query", () => "");
function open(opts: { type?: string; source?: LeadSource; aiQuery?: string } = {}) {
if (opts.type) type.value = opts.type;
source.value = opts.source ?? "consult";
aiQuery.value = opts.aiQuery ?? "";
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function resetType() {
type.value = DEFAULT_CONSULT_TYPE;
}
return { isOpen, mounted, type, source, aiQuery, open, close, resetType };
}
export function useAiModal() {
const isOpen = useState("ai-open", () => false);
const mounted = useState("ai-mounted", () => false);
const query = useState("ai-query", () => "");
function open() {
mounted.value = true;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
return { isOpen, mounted, query, open, close };
}
+8 -49
View File
@@ -1,53 +1,12 @@
export function useApiBase() {
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
/** SSR talks to the API directly; the browser uses same-origin `/api` (nginx in production, devProxy locally). */
export function useApiBase(): string {
const config = useRuntimeConfig();
if (import.meta.server) return config.apiBase as string;
return (config.public.apiBase as string) || "";
if (import.meta.server) return config.apiBase;
return config.public.apiBase || "";
}
export async function fetchPublicSite() {
const base = useApiBase();
return await $fetch(`${base}/api/public/site`);
}
export async function fetchPublicBlog() {
const base = useApiBase();
return await $fetch(`${base}/api/public/blog`);
}
export async function fetchPublicPost(slug: string) {
const base = useApiBase();
return await $fetch(`${base}/api/public/blog/${slug}`);
}
export async function fetchPublicGallery() {
const base = useApiBase();
return await $fetch(`${base}/api/public/gallery`);
}
export async function fetchPage(key: string) {
const base = useApiBase();
return await $fetch(`${base}/api/public/pages/${key}`);
}
export function applyPageSeo(seo?: {
title?: string;
description?: string;
ogImage?: string | null;
canonical?: string | null;
noIndex?: boolean;
} | null) {
if (!seo) return;
useSeoMeta({
title: seo.title,
description: seo.description,
ogTitle: seo.title,
ogDescription: seo.description,
ogImage: seo.ogImage || undefined,
robots: seo.noIndex ? "noindex,nofollow" : "index,follow",
});
if (seo.canonical) {
useHead({
link: [{ rel: "canonical", href: seo.canonical }],
});
}
export function apiFetch<T>(path: string, options?: NitroFetchOptions<NitroFetchRequest>): Promise<T> {
return $fetch<T>(`${useApiBase()}${path}`, options as never) as Promise<T>;
}
+59
View File
@@ -0,0 +1,59 @@
import type { PageSeoData } from "@montech/shared";
import type { MaybeRefOrGetter } from "vue";
const DEFAULT_TITLE = "منتک | هوش‌افزار منظر - پلتفرم راهکارهای نرم‌افزاری و هوش مصنوعی";
const DEFAULT_DESCRIPTION =
"با کمک هوش مصنوعی منتک، بهترین نرم‌افزارها و خدمات متناسب با کسب‌وکار شما پیشنهاد می‌شود.";
function absoluteUrl(siteUrl: string, pathOrUrl?: string | null) {
if (!pathOrUrl) return undefined;
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
return `${siteUrl.replace(/\/$/, "")}${pathOrUrl.startsWith("/") ? "" : "/"}${pathOrUrl}`;
}
/** Central metadata for every page: title, description, canonical, Open Graph, Twitter and robots. */
export function usePageSeo(input: MaybeRefOrGetter<Partial<PageSeoData> | null | undefined>, opts: { type?: "website" | "article" } = {}) {
const config = useRuntimeConfig();
const route = useRoute();
const siteUrl = config.public.siteUrl.replace(/\/$/, "");
const seo = computed(() => toValue(input) || {});
const title = computed(() => seo.value.title || DEFAULT_TITLE);
const description = computed(() => seo.value.description || DEFAULT_DESCRIPTION);
const canonical = computed(() => absoluteUrl(siteUrl, seo.value.canonical) || `${siteUrl}${route.path === "/" ? "/" : route.path}`);
const image = computed(() => absoluteUrl(siteUrl, seo.value.ogImage) || `${siteUrl}/brand/og-default.png`);
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogType: opts.type ?? "website",
ogUrl: canonical,
ogImage: image,
ogLocale: "fa_IR",
ogSiteName: "منتک",
twitterCard: "summary_large_image",
twitterTitle: title,
twitterDescription: description,
twitterImage: image,
robots: computed(() => (seo.value.noIndex ? "noindex,nofollow" : "index,follow,max-image-preview:large")),
});
useHead({
link: [{ rel: "canonical", href: canonical }],
});
return { canonical, siteUrl };
}
export function useJsonLd(schema: MaybeRefOrGetter<Record<string, unknown> | Record<string, unknown>[]>) {
useHead({
script: [
{
type: "application/ld+json",
innerHTML: computed(() => JSON.stringify(toValue(schema))),
},
],
});
}
@@ -0,0 +1,49 @@
import type { PublicPage } from "@montech/shared";
export interface BlogPostSummary {
id: string;
slug: string;
title: string;
excerpt: string;
coverUrl: string | null;
publishedAt: string | null;
updatedAt?: string;
}
export interface BlogPost extends BlogPostSummary {
content: string;
seoTitle: string | null;
seoDesc: string | null;
ogImage: string | null;
canonical: string | null;
createdAt?: string;
}
export interface GalleryItem {
id: string;
title: string;
imageUrl: string;
category: string | null;
}
export function usePageMeta(key: string) {
return useAsyncData(`page-${key}`, () => apiFetch<PublicPage>(`/api/public/pages/${key}`).catch(() => null));
}
export function formatFaDate(value?: string | null) {
return value ? new Date(value).toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" }) : "";
}
export function useBreadcrumbJsonLd(items: Array<{ name: string; path: string }>) {
const siteUrl = useRuntimeConfig().public.siteUrl.replace(/\/$/, "");
useJsonLd({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: `${siteUrl}${item.path}`,
})),
});
}
@@ -0,0 +1,8 @@
/** In-page anchors (`#solutions`) work on the home page; elsewhere they must point back to `/`. */
export function useSectionHref() {
const route = useRoute();
return (href: string) => {
if (!href.startsWith("#") || route.path === "/") return href;
return href === "#" ? "/" : `/${href}`;
};
}
-14
View File
@@ -1,14 +0,0 @@
export async function useSiteChrome() {
const { data } = await useAsyncData("site-chrome", () => fetchPublicSite(), {
getCachedData: () => undefined,
});
return computed(() => ({
brand: data.value?.settings?.brandName || "منتک",
logoUrl: data.value?.settings?.logoUrl || "",
timezone: data.value?.settings?.timezone || "Asia/Tehran",
tagline: data.value?.settings?.tagline || "",
email: data.value?.settings?.email || "",
phone: data.value?.settings?.phone || "",
}));
}
@@ -0,0 +1,53 @@
import {
resolveSection,
type PublicSettings,
type PublicSitePayload,
type SectionKey,
type SiteSections,
} from "@montech/shared";
const SITE_KEY = "public-site";
export const DEFAULT_SETTINGS: PublicSettings = {
brandName: "منتک",
tagline: "هوش‌افزار منظر",
email: "info@montech.ir",
phone: "۰۹۱۲ ۳۴۵ ۶۷۸۹",
address: "تهران، ایران",
timezone: "Asia/Tehran",
socials: [],
logoUrl: null,
};
/**
* Loads the public site payload once per request (called from the default layout).
* A failed API call degrades to the built-in default content instead of breaking the page.
*/
export function loadSiteContent() {
return useAsyncData(SITE_KEY, () => apiFetch<PublicSitePayload>("/api/public/site").catch(() => null));
}
export function useSiteContent() {
const { data } = useNuxtData<PublicSitePayload | null>(SITE_KEY);
const settings = computed<PublicSettings>(() => {
const s = data.value?.settings;
if (!s) return DEFAULT_SETTINGS;
return {
...DEFAULT_SETTINGS,
...Object.fromEntries(Object.entries(s).filter(([, v]) => v !== "" && v !== null && v !== undefined)),
socials: Array.isArray(s.socials) ? s.socials.filter((x) => x && x.url) : [],
logoUrl: s.logoUrl || null,
};
});
function section<K extends SectionKey>(key: K) {
return computed<SiteSections[K]>(() => resolveSection(key, data.value?.sections?.[key]));
}
function pageSeo(key: string) {
return computed(() => data.value?.pages?.find((p) => p.key === key)?.seo ?? null);
}
return { data, settings, section, pageSeo };
}
+22
View File
@@ -0,0 +1,22 @@
const STORAGE_KEY = "montech_theme";
/** The initial class is applied by the inline boot script in `nuxt.config.ts` before first paint. */
export function useTheme() {
const isDark = useState("theme-dark", () => false);
function sync() {
isDark.value = document.documentElement.classList.contains("dark");
}
function toggle() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
try {
localStorage.setItem(STORAGE_KEY, isDark.value ? "dark" : "light");
} catch {
/* storage unavailable (private mode) */
}
}
return { isDark, sync, toggle };
}