phase3-4 done
Some checks failed
Deploy to VPS / deploy (push) Has been cancelled

This commit is contained in:
Ali Taghavi
2026-04-24 11:28:48 +03:30
parent 95a546df11
commit f08d3b1fde
24 changed files with 1500 additions and 16 deletions

View File

@@ -1,9 +1,115 @@
export default function AboutPage() {
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Github, Linkedin, Send, Twitter } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { Timeline } from "@/components/about/Timeline";
import { Values } from "@/components/about/Values";
import { FocusAreas } from "@/components/about/FocusAreas";
import { sanityFetch } from "@/lib/sanity/client";
import { siteSettingsQuery } from "@/lib/sanity/queries";
import type { SiteSettings } from "@/lib/sanity/types";
import { groq } from "next-sanity";
interface Props {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "about" });
return { title: t("title"), description: t("subtitle") };
}
const timelineQuery = groq`
*[_type == "timelineEvent"] | order(date desc) {
title, date, description, icon, category
}
`;
const socialLinks = [
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
{ icon: Twitter, href: "https://twitter.com/alitaghavi", label: "Twitter/X" },
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
];
export default async function AboutPage({ params }: Props) {
const { locale } = await params;
const [t, settings, timelineEvents] = await Promise.all([
getTranslations({ locale, namespace: "about" }),
sanityFetch<SiteSettings>(siteSettingsQuery),
sanityFetch<unknown[]>(timelineQuery),
]);
const values = t.raw("values") as { title: string; desc: string }[];
const focusAreas = t.raw("focus_areas") as string[];
return (
<div className="mx-auto max-w-6xl px-6 py-32">
<span className="font-mono text-xs text-accent">// about</span>
<h1 className="mt-4 text-4xl font-bold text-text-primary">درباره علی تقوی</h1>
<p className="mt-4 text-text-secondary">در حال آمادهسازی Phase 3</p>
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="about" />
{/* Hero */}
<AnimatedSection className="mb-20">
<div className="grid gap-12 lg:grid-cols-[1fr_auto]">
<div>
<h1 className="mb-6 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
<div className="space-y-4 max-w-2xl">
{t("bio").split("\n\n").map((paragraph, i) => (
<p key={i} className="text-base text-text-secondary leading-relaxed">
{paragraph}
</p>
))}
</div>
<div className="mt-8 flex items-center gap-3">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={href}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="flex h-10 w-10 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
>
<Icon size={16} />
</a>
))}
</div>
</div>
{/* Current status card */}
{settings?.currentStatus && (
<div className="lg:w-72">
<div className="rounded-xl border border-accent/20 bg-surface p-6">
<div className="mb-3 font-mono text-xs text-accent">// {t("working_on")}</div>
<p className="text-text-primary leading-relaxed">{settings.currentStatus}</p>
<div className="mt-4 flex items-center gap-2">
<span className="h-2 w-2 animate-pulse rounded-full bg-success" />
<span className="font-mono text-xs text-success">active</span>
</div>
</div>
</div>
)}
</div>
</AnimatedSection>
{/* Values */}
<AnimatedSection className="mb-20">
<SectionHeader label={t("values_heading")} />
<Values values={values} />
</AnimatedSection>
{/* Focus Areas */}
<AnimatedSection className="mb-20">
<SectionHeader label={t("focus_heading")} />
<FocusAreas areas={focusAreas} />
</AnimatedSection>
{/* Timeline */}
<AnimatedSection>
<SectionHeader label={t("journey")} />
<Timeline events={timelineEvents as Parameters<typeof Timeline>[0]["events"]} />
</AnimatedSection>
</div>
);
}

View File

@@ -1,9 +1,106 @@
export default function ContactPage() {
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Github, Linkedin, Send, Twitter, Instagram, MapPin } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { ContactForm } from "@/components/shared/ContactForm";
import { Button } from "@/components/ui/button";
interface Props {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "contact" });
return { title: t("title"), description: t("subtitle") };
}
const socialLinks = [
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
{ icon: Twitter, href: "https://twitter.com/alitaghavi", label: "Twitter/X" },
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
{ icon: Instagram, href: "https://instagram.com/alitaghavi", label: "Instagram" },
];
export default async function ContactPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "contact" });
const openToItems = t.raw("open_to_items") as string[];
return (
<div className="mx-auto max-w-6xl px-6 py-32">
<span className="font-mono text-xs text-accent">// contact</span>
<h1 className="mt-4 text-4xl font-bold text-text-primary">تماس</h1>
<p className="mt-4 text-text-secondary">در حال آمادهسازی Phase 3</p>
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="contact" />
<div className="grid gap-12 lg:grid-cols-[1fr_380px]">
{/* Form */}
<AnimatedSection>
<h1 className="mb-2 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
<p className="mb-8 text-text-secondary">{t("subtitle")}</p>
<ContactForm />
</AnimatedSection>
{/* Sidebar info */}
<AnimatedSection delay={0.1} className="space-y-8">
{/* Open to */}
<div className="rounded-xl border border-border bg-surface p-6">
<div className="mb-4 font-mono text-xs text-accent">// {t("open_to")}</div>
<ul className="space-y-2">
{openToItems.map((item, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-text-secondary">
<span className="h-1.5 w-1.5 rounded-full bg-accent shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Location */}
<div className="flex items-center gap-2 font-mono text-sm text-text-secondary">
<MapPin size={14} className="text-accent shrink-0" />
{t("based_in")}
</div>
{/* Social */}
<div>
<div className="mb-4 font-mono text-xs text-accent">// social</div>
<div className="flex flex-wrap gap-3">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={href}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="flex h-10 w-10 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
>
<Icon size={16} />
</a>
))}
</div>
</div>
{/* Telegram CTA */}
<div className="relative overflow-hidden rounded-xl border border-accent/20 bg-surface p-6">
<div className="absolute -right-8 -top-8 h-24 w-24 rounded-full bg-accent/5 blur-2xl" />
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-accent-muted">
<Send size={18} className="text-accent" />
</div>
<p className="mb-4 text-sm text-text-secondary leading-relaxed">
{locale === "fa"
? "برای یادداشت‌های روزانه، ایده‌ها و آپدیت‌ها، کانال تلگرامم را دنبال کن"
: "For daily notes, ideas, and updates, follow my Telegram channel"}
</p>
<Button asChild size="sm" variant="outline">
<a href="https://t.me/alitaghavi_channel" target="_blank" rel="noopener noreferrer">
<Send size={13} />
{locale === "fa" ? "عضو کانال" : "Join Channel"}
</a>
</Button>
</div>
</AnimatedSection>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import { RefreshCw, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("errors");
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="relative z-10 flex min-h-[70vh] flex-col items-center justify-center px-6 py-24 text-center">
<AnimatedSection>
<div className="mb-6 font-mono text-8xl font-bold text-danger/20 select-none">500</div>
<div className="mb-3 font-mono text-sm text-danger">// {t("error.label")}</div>
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{t("error.title")}</h1>
<p className="mb-8 max-w-md text-text-secondary">{t("error.description")}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button onClick={reset}>
<RefreshCw size={15} />
{t("error.retry")}
</Button>
<Button asChild variant="outline">
<Link href="/">
<Home size={15} />
{t("error.home")}
</Link>
</Button>
</div>
{error.digest && (
<p className="mt-6 font-mono text-xs text-text-secondary/50">digest: {error.digest}</p>
)}
</AnimatedSection>
</div>
);
}

View File

@@ -85,9 +85,15 @@ export default async function LocaleLayout({
<link rel="alternate" type="application/rss+xml" title="علی تقوی — نوشته‌ها" href="/api/rss" />
</head>
<body className="relative flex min-h-full flex-col bg-background text-text-primary">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-100 focus:rounded-md focus:bg-accent focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-background focus:outline-none"
>
{dir === "rtl" ? "رفتن به محتوای اصلی" : "Skip to main content"}
</a>
<NextIntlClientProvider messages={messages}>
<Header />
<main className="flex-1 relative z-10">{children}</main>
<main id="main-content" className="flex-1 relative z-10">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>

View File

@@ -0,0 +1,36 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import { Home, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
export default function NotFound() {
const t = useTranslations("errors");
return (
<div className="relative z-10 flex min-h-[70vh] flex-col items-center justify-center px-6 py-24 text-center">
<AnimatedSection>
<div className="mb-6 font-mono text-8xl font-bold text-accent/20 select-none">404</div>
<div className="mb-3 font-mono text-sm text-accent">// {t("notFound.label")}</div>
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{t("notFound.title")}</h1>
<p className="mb-8 max-w-md text-text-secondary">{t("notFound.description")}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button asChild>
<Link href="/">
<Home size={15} />
{t("notFound.home")}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/writing">
<ArrowRight size={15} />
{t("notFound.writing")}
</Link>
</Button>
</div>
</AnimatedSection>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Download, Calendar, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
interface Props {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "resume" });
return { title: t("title") };
}
const experience = [
{
role: "مدیرعامل (CEO)",
roleEn: "CEO & Founder",
company: "NODE-Group",
period: "۱۴۰۱",
periodEn: "2022",
presentKey: true,
location: "تهران، ایران",
locationEn: "Tehran, Iran",
bullets: [
"راه‌اندازی و رهبری شرکت فناوری با محصولات HyperAccount، Khanehban و NODE-AUTH",
"طراحی استراتژی محصول، برند و بازاریابی دیجیتال",
"مدیریت تیم توسعه و عملیات",
],
bulletsEn: [
"Founded and lead technology company with products HyperAccount, Khanehban, and NODE-AUTH",
"Designed product, brand, and digital marketing strategy",
"Managed development and operations teams",
],
},
];
const education = [
{
degree: "کارشناسی ارشد مدیریت کسب‌وکار (MBA)",
degreeEn: "Master of Business Administration (MBA)",
school: "دانشگاه",
schoolEn: "University",
year: "۱۴۰۰",
yearEn: "2021",
},
];
const skillGroups = [
{
category: "فناوری",
categoryEn: "Technology",
skills: ["Next.js", "TypeScript", "React", "Node.js", "Docker", "PostgreSQL", "Sanity CMS"],
},
{
category: "بازاریابی و برند",
categoryEn: "Marketing & Brand",
skills: ["دیجیتال مارکتینگ", "برندینگ", "SEO", "محتوا مارکتینگ", "استراتژی رسانه‌های اجتماعی"],
},
{
category: "استراتژی محصول",
categoryEn: "Product Strategy",
skills: ["Product Management", "UX Research", "A/B Testing", "Roadmapping", "Customer Discovery"],
},
{
category: "ابزارها",
categoryEn: "Tools",
skills: ["Figma", "Linear", "Notion", "VS Code", "GitHub Actions"],
},
];
export default async function ResumePage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "resume" });
const isRtl = locale === "fa";
return (
<div className="relative z-10 mx-auto max-w-4xl px-6 py-24 pt-32">
{/* Header */}
<AnimatedSection>
<div className="mb-12 flex items-start justify-between gap-4">
<div>
<SectionHeader label="resume" />
<h1 className="text-4xl font-bold text-text-primary">
{isRtl ? "علی تقوی" : "Ali Taghavi"}
</h1>
<p className="mt-2 text-text-secondary">
{isRtl ? "مدیرعامل NODE-Group · سازنده · استراتژیست" : "CEO @ NODE-Group · Builder · Strategist"}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-text-secondary">
<span className="flex items-center gap-1"><MapPin size={13} /> {isRtl ? "تهران، ایران" : "Tehran, Iran"}</span>
<span className="font-mono">biztaghavi.com</span>
</div>
</div>
<Button asChild variant="secondary" size="sm" className="shrink-0">
<a href="/resume.pdf" download>
<Download size={14} />
{t("download_pdf")}
</a>
</Button>
</div>
</AnimatedSection>
{/* Experience */}
<AnimatedSection className="mb-12">
<SectionHeader label={t("experience")} />
<div className="space-y-8">
{experience.map((job, i) => (
<div key={i} className="rounded-xl border border-border bg-surface p-6">
<div className="mb-4 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="font-semibold text-text-primary">
{isRtl ? job.role : job.roleEn}
</h3>
<p className="font-mono text-sm text-accent">{job.company}</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end shrink-0">
<div className="flex items-center gap-1 font-mono text-xs text-text-secondary">
<Calendar size={11} />
{isRtl ? job.period : job.periodEn} {t("present")}
</div>
<span className="font-mono text-xs text-text-secondary">
<MapPin size={11} className="inline me-1" />
{isRtl ? job.location : job.locationEn}
</span>
</div>
</div>
<ul className="space-y-2">
{(isRtl ? job.bullets : job.bulletsEn).map((b, j) => (
<li key={j} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
{b}
</li>
))}
</ul>
</div>
))}
</div>
</AnimatedSection>
{/* Education */}
<AnimatedSection className="mb-12">
<SectionHeader label={t("education")} />
<div className="space-y-4">
{education.map((edu, i) => (
<div key={i} className="rounded-xl border border-border bg-surface p-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="font-semibold text-text-primary">
{isRtl ? edu.degree : edu.degreeEn}
</h3>
<p className="text-sm text-text-secondary">{isRtl ? edu.school : edu.schoolEn}</p>
</div>
<span className="font-mono text-xs text-text-secondary shrink-0">
{isRtl ? edu.year : edu.yearEn}
</span>
</div>
</div>
))}
</div>
</AnimatedSection>
{/* Skills */}
<AnimatedSection>
<SectionHeader label={t("skills")} />
<div className="grid gap-6 sm:grid-cols-2">
{skillGroups.map((group, i) => (
<div key={i} className="rounded-xl border border-border bg-surface p-5">
<h3 className="mb-3 font-mono text-xs text-accent">
// {isRtl ? group.category : group.categoryEn}
</h3>
<div className="flex flex-wrap gap-2">
{group.skills.map((skill) => (
<Badge key={skill} variant="secondary">{skill}</Badge>
))}
</div>
</div>
))}
</div>
</AnimatedSection>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import type { Metadata } from "next";
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import { ExternalLink } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { sanityFetch } from "@/lib/sanity/client";
import { allProductsQuery } from "@/lib/sanity/queries";
import { urlFor } from "@/lib/sanity/image";
import type { SanityImage } from "@/lib/sanity/types";
interface Props {
params: Promise<{ locale: string }>;
}
interface Product {
_id: string;
title: string;
slug: { current: string };
description?: string;
price?: number;
currency?: string;
productType?: string;
purchaseUrl?: string;
featured?: boolean;
coverImage?: SanityImage;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "shop" });
return { title: t("title"), description: t("subtitle") };
}
// Placeholder products shown when Sanity has no data yet
const placeholders: Product[] = [
{
_id: "p1",
title: "دوره بازاریابی دیجیتال برای استارتاپ‌های ایرانی",
slug: { current: "digital-marketing-course" },
description: "آموزش جامع استراتژی بازاریابی دیجیتال متناسب با بازار ایران — از صفر تا اجرا",
price: 490000,
currency: "IRR",
productType: "digital",
},
{
_id: "p2",
title: "قالب استراتژی برند",
slug: { current: "brand-strategy-template" },
description: "قالب آماده برای طراحی هویت برند و استراتژی بصری کسب‌وکار",
price: 0,
currency: "IRR",
productType: "digital",
},
{
_id: "p3",
title: "HyperAccount",
slug: { current: "hyperaccount" },
description: "زیرساخت هویت و حساب کاربری برای محصولات دیجیتال ایرانی",
productType: "node-product",
purchaseUrl: "https://hyperaccount.ir",
},
];
export default async function ShopPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "shop" });
const isRtl = locale === "fa";
const sanityProducts = await sanityFetch<Product[]>(allProductsQuery, { locale });
const products = sanityProducts && sanityProducts.length > 0 ? sanityProducts : placeholders;
function formatPrice(price: number, currency: string) {
if (price === 0) return isRtl ? t("free") : "Free";
if (currency === "IRR") {
return new Intl.NumberFormat("fa-IR").format(price) + " تومان";
}
return `$${price}`;
}
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="shop" />
<AnimatedSection className="mb-12">
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
</AnimatedSection>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product, i) => {
const imageUrl = product.coverImage?.asset
? urlFor(product.coverImage).width(600).height(340).url()
: null;
return (
<AnimatedSection key={product._id} delay={i * 0.07}>
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
{/* Image or placeholder */}
{imageUrl ? (
<div className="relative h-44 overflow-hidden">
<Image src={imageUrl} alt={product.title} fill className="object-cover" />
</div>
) : (
<div className="flex h-44 items-center justify-center bg-surface-hover">
<span className="font-mono text-3xl text-accent opacity-20">//</span>
</div>
)}
<div className="flex flex-1 flex-col p-5">
<div className="mb-3 flex items-center gap-2">
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
{product.productType === "node-product" ? t("node_product") : t("digital")}
</Badge>
{product.featured && (
<span className="font-mono text-xs text-accent"></span>
)}
</div>
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
{product.description && (
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
{product.description}
</p>
)}
<div className="mt-auto flex items-center justify-between gap-3">
{product.price !== undefined && (
<span className="font-mono text-sm font-semibold text-accent">
{formatPrice(product.price, product.currency ?? "IRR")}
</span>
)}
{product.purchaseUrl ? (
<Button asChild size="sm" className="ms-auto">
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink size={13} />
{t("buy")}
</a>
</Button>
) : (
<Button size="sm" className="ms-auto">{t("buy")}</Button>
)}
</div>
</div>
</div>
</AnimatedSection>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ExternalLink } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { sanityFetch } from "@/lib/sanity/client";
import { allUsesItemsQuery } from "@/lib/sanity/queries";
interface Props {
params: Promise<{ locale: string }>;
}
type UsesCategory = "development" | "design" | "marketing" | "productivity" | "hardware";
interface UsesItem {
_id: string;
title: string;
description?: string;
category: UsesCategory;
url?: string;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "uses" });
return { title: t("title"), description: t("subtitle") };
}
// Static fallback — shown until Sanity has data
const fallbackItems: UsesItem[] = [
{ _id: "1", title: "VS Code", description: "اصلی‌ترین ابزار کدنویسی‌ام", category: "development", url: "https://code.visualstudio.com" },
{ _id: "2", title: "Next.js", description: "فریم‌ورک React برای وب اپلیکیشن‌ها", category: "development", url: "https://nextjs.org" },
{ _id: "3", title: "Sanity", description: "هدلس CMS برای مدیریت محتوا", category: "development", url: "https://sanity.io" },
{ _id: "4", title: "Docker", description: "کانتینرایزیشن برای دپلوی", category: "development" },
{ _id: "5", title: "Figma", description: "طراحی UI/UX و هویت بصری", category: "design", url: "https://figma.com" },
{ _id: "6", title: "Linear", description: "مدیریت پروژه و تسک‌ها", category: "productivity", url: "https://linear.app" },
{ _id: "7", title: "Notion", description: "مستندسازی و یادداشت‌برداری", category: "productivity", url: "https://notion.so" },
{ _id: "8", title: "macOS", description: "سیستم‌عامل اصلی", category: "hardware" },
{ _id: "9", title: "Google Analytics + Umami", description: "آنالیتیکس سایت — هر دو برای مقایسه", category: "marketing" },
];
const categoryOrder: UsesCategory[] = ["development", "design", "marketing", "productivity", "hardware"];
export default async function UsesPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "uses" });
const tCat = await getTranslations({ locale, namespace: "uses.categories" });
const sanityItems = await sanityFetch<UsesItem[]>(allUsesItemsQuery);
const items = sanityItems && sanityItems.length > 0 ? sanityItems : fallbackItems;
const grouped = categoryOrder.reduce<Record<string, UsesItem[]>>((acc, cat) => {
const catItems = items.filter((i) => i.category === cat);
if (catItems.length > 0) acc[cat] = catItems;
return acc;
}, {});
return (
<div className="relative z-10 mx-auto max-w-4xl px-6 py-24 pt-32">
<SectionHeader label="uses" />
<AnimatedSection className="mb-12">
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
<p className="mt-4 text-text-secondary">{t("subtitle")}</p>
</AnimatedSection>
<div className="space-y-14">
{Object.entries(grouped).map(([cat, catItems], groupIdx) => (
<AnimatedSection key={cat} delay={groupIdx * 0.05}>
<h2 className="mb-6 flex items-center gap-3">
<span className="font-mono text-xs text-accent">// {tCat(cat as UsesCategory)}</span>
<span className="h-px flex-1 bg-border" />
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{catItems.map((item) => (
<div
key={item._id}
className="group flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4 transition-all hover:border-accent/20"
>
<div className="flex-1 min-w-0">
<div className="font-medium text-text-primary">{item.title}</div>
{item.description && (
<p className="mt-0.5 text-sm text-text-secondary">{item.description}</p>
)}
</div>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 shrink-0 text-text-secondary opacity-0 transition-opacity group-hover:opacity-100 hover:text-accent"
aria-label={`Visit ${item.title}`}
>
<ExternalLink size={14} />
</a>
)}
</div>
))}
</div>
</AnimatedSection>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Resend } from "resend";
import type { NextRequest } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
try {
const { name, email, subject, message } = await req.json();
if (!name || !email || !message) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
await resend.emails.send({
from: "biztaghavi.com <noreply@biztaghavi.com>",
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
replyTo: email,
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
html: `
<div style="font-family: sans-serif; max-width: 600px;">
<p><strong>نام:</strong> ${name}</p>
<p><strong>ایمیل:</strong> ${email}</p>
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
<hr />
<p style="white-space: pre-wrap;">${message}</p>
</div>
`,
});
return Response.json({ success: true });
} catch (err) {
console.error("[contact]", err);
return Response.json({ error: "Failed to send" }, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Script from "next/script";
import "./globals.css";
export const metadata: Metadata = {
@@ -10,10 +11,26 @@ export const metadata: Metadata = {
metadataBase: new URL("https://biztaghavi.com"),
};
const umamiSrc = process.env.NEXT_PUBLIC_UMAMI_URL;
const umamiId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
return (
<>
{umamiSrc && umamiId && (
<Script
async
defer
src={umamiSrc}
data-website-id={umamiId}
strategy="afterInteractive"
/>
)}
{children}
</>
);
}