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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user