This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/app/[locale]/error.tsx
Normal file
48
src/app/[locale]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
36
src/app/[locale]/not-found.tsx
Normal file
36
src/app/[locale]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/app/[locale]/resume/page.tsx
Normal file
187
src/app/[locale]/resume/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/app/[locale]/shop/page.tsx
Normal file
154
src/app/[locale]/shop/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/app/[locale]/uses/page.tsx
Normal file
104
src/app/[locale]/uses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/api/contact/route.ts
Normal file
36
src/app/api/contact/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/components/about/FocusAreas.tsx
Normal file
38
src/components/about/FocusAreas.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
"فناوری و توسعه نرمافزار": "⚡",
|
||||
"بازاریابی دیجیتال": "📢",
|
||||
"برندینگ و هویت بصری": "🎨",
|
||||
"استراتژی محصول": "🧭",
|
||||
"محتوا و تولید محتوا": "✍️",
|
||||
"متنباز": "🔓",
|
||||
"Technology & Software Development": "⚡",
|
||||
"Digital Marketing": "📢",
|
||||
"Branding & Visual Identity": "🎨",
|
||||
"Product Strategy": "🧭",
|
||||
"Content & Content Creation": "✍️",
|
||||
"Open Source": "🔓",
|
||||
};
|
||||
|
||||
export function FocusAreas({ areas }: { areas: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{areas.map((area, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-surface px-4 py-2 text-sm text-text-secondary hover:border-accent/30 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<span>{icons[area] ?? "●"}</span>
|
||||
{area}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/about/Timeline.tsx
Normal file
74
src/components/about/Timeline.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TimelineEvent {
|
||||
title: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: "career" | "product" | "personal";
|
||||
}
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
career: "text-accent border-accent/40 bg-accent-muted",
|
||||
product: "text-blue-400 border-blue-400/40 bg-blue-400/10",
|
||||
personal: "text-purple-400 border-purple-400/40 bg-purple-400/10",
|
||||
};
|
||||
|
||||
// Static fallback timeline — will be overridden by Sanity data when available
|
||||
const fallbackEvents: TimelineEvent[] = [
|
||||
{ title: "تأسیس NODE-Group", date: "۱۴۰۱", icon: "🏢", category: "career", description: "راهاندازی شرکت فناوری NODE-Group برای ساختن محصولات نوآورانه" },
|
||||
{ title: "راهاندازی HyperAccount", date: "۱۴۰۲", icon: "⚡", category: "product", description: "عرضه زیرساخت هویت و حساب کاربری برای وب نسل بعدی" },
|
||||
{ title: "ساخت Khanehban", date: "۱۴۰۲", icon: "🏠", category: "product", description: "مدیریت هوشمند املاک برای موجران و مستأجران ایرانی" },
|
||||
{ title: "NODE-AUTH (Dezhban)", date: "۱۴۰۳", icon: "🔐", category: "product", description: "لایه احراز هویت و مجوزدهی برای محصولات ایرانی" },
|
||||
{ title: "MBA", date: "۱۴۰۰", icon: "🎓", category: "personal", description: "فارغالتحصیلی از دوره MBA" },
|
||||
];
|
||||
|
||||
interface TimelineProps {
|
||||
events?: TimelineEvent[];
|
||||
}
|
||||
|
||||
export function Timeline({ events }: TimelineProps) {
|
||||
const items = events && events.length > 0 ? events : fallbackEvents;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute start-5 top-0 bottom-0 w-px bg-border" />
|
||||
|
||||
<div className="space-y-8">
|
||||
{items.map((event, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
className="relative flex gap-6 ps-14"
|
||||
>
|
||||
{/* Icon dot */}
|
||||
<div className="absolute start-0 flex h-10 w-10 items-center justify-center rounded-full border border-border bg-surface text-lg">
|
||||
{event.icon ?? "●"}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-2">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary">{event.title}</h3>
|
||||
{event.category && (
|
||||
<span className={cn("rounded-full border px-2 py-0.5 font-mono text-xs", categoryColor[event.category])}>
|
||||
{event.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/about/Values.tsx
Normal file
28
src/components/about/Values.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface Value {
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export function Values({ values }: { values: Value[] }) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{values.map((v, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{ duration: 0.4, delay: i * 0.07 }}
|
||||
className="rounded-lg border border-border bg-surface p-5 hover:border-accent/20 transition-colors"
|
||||
>
|
||||
<div className="mb-1 font-semibold text-text-primary">{v.title}</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{v.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ const navLinks = [
|
||||
{ href: "/writing", labelKey: "writing" },
|
||||
{ href: "/work", labelKey: "work" },
|
||||
{ href: "/about", labelKey: "about" },
|
||||
{ href: "/resume", labelKey: "resume" },
|
||||
{ href: "/shop", labelKey: "shop" },
|
||||
{ href: "/uses", labelKey: "uses" },
|
||||
{ href: "/contact", labelKey: "contact" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Link } from "@/lib/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
const navKeys = ["home", "writing", "work", "about", "contact"] as const;
|
||||
const navKeys = ["home", "writing", "work", "about", "shop", "uses", "resume", "contact"] as const;
|
||||
const navHrefs: Record<string, string> = {
|
||||
home: "/",
|
||||
writing: "/writing",
|
||||
work: "/work",
|
||||
about: "/about",
|
||||
shop: "/shop",
|
||||
uses: "/uses",
|
||||
resume: "/resume",
|
||||
contact: "/contact",
|
||||
};
|
||||
|
||||
|
||||
90
src/components/shared/ContactForm.tsx
Normal file
90
src/components/shared/ContactForm.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Send, Check, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function ContactForm() {
|
||||
const t = useTranslations("contact");
|
||||
const locale = useLocale();
|
||||
const isRtl = locale === "fa";
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
|
||||
|
||||
function set(field: keyof typeof form) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setStatus(res.ok ? "success" : "error");
|
||||
if (res.ok) setForm({ name: "", email: "", subject: "", message: "" });
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface px-4 py-3 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none transition-colors";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("name")}</label>
|
||||
<input required value={form.name} onChange={set("name")} className={inputClass} placeholder={isRtl ? "علی تقوی" : "Your name"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("email")}</label>
|
||||
<input required type="email" value={form.email} onChange={set("email")} className={inputClass} placeholder="hello@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("subject")}</label>
|
||||
<input value={form.subject} onChange={set("subject")} className={inputClass} placeholder={isRtl ? "موضوع پیام (اختیاری)" : "Subject (optional)"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("message")}</label>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
value={form.message}
|
||||
onChange={set("message")}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder={isRtl ? "پیامت را اینجا بنویس..." : "Your message..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "success" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">
|
||||
<Check size={15} />
|
||||
{t("success")}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||
<AlertCircle size={15} />
|
||||
{t("error")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" size="lg" disabled={status === "loading"} className="w-full sm:w-auto">
|
||||
<Send size={15} />
|
||||
{status === "loading" ? t("sending") : t("send")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,23 @@ export const siteSettingsQuery = groq`
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Products ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const allProductsQuery = groq`
|
||||
*[_type == "product" && locale == $locale] | order(_createdAt desc) {
|
||||
_id, title, slug, description, price, currency, productType, purchaseUrl, featured,
|
||||
"coverImage": coverImage { asset->, alt }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Uses items ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const allUsesItemsQuery = groq`
|
||||
*[_type == "usesItem"] | order(category asc, title asc) {
|
||||
_id, title, description, category, url, "image": image { asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
|
||||
|
||||
export const rssPostsQuery = groq`
|
||||
|
||||
@@ -79,5 +79,88 @@
|
||||
"outcome": "Outcome",
|
||||
"tech_stack": "Tech Stack",
|
||||
"tools": "Tools Used"
|
||||
},
|
||||
"about": {
|
||||
"title": "About Ali Taghavi",
|
||||
"subtitle": "A builder from Tehran operating at the intersection of technology, branding, and product",
|
||||
"bio_heading": "Who I Am",
|
||||
"bio": "I'm Ali Taghavi — CEO of NODE-Group and a builder who creates from Tehran for the world. For over a decade I've been building products, brands, and systems — from software to marketing strategy and visual identity.\n\nMy work sits at the intersection of technology, digital marketing, and branding. I believe the best products come from combining systematic thinking with deep human understanding.",
|
||||
"working_on": "What I'm Working On",
|
||||
"journey": "Journey",
|
||||
"values_heading": "What I Believe In",
|
||||
"focus_heading": "Areas of Focus",
|
||||
"values": [
|
||||
{ "title": "Transparency", "desc": "In work and communication, I speak clearly and honestly" },
|
||||
{ "title": "Systems Thinking", "desc": "I see problems as systems, not isolated events" },
|
||||
{ "title": "Building for Iran", "desc": "I build products truly designed for Persian-speaking users" },
|
||||
{ "title": "Long-term Quality", "desc": "I prefer to build less but build it right and lasting" }
|
||||
],
|
||||
"focus_areas": [
|
||||
"Technology & Software Development",
|
||||
"Digital Marketing",
|
||||
"Branding & Visual Identity",
|
||||
"Product Strategy",
|
||||
"Content & Content Creation",
|
||||
"Open Source"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"title": "Resume",
|
||||
"download_pdf": "Download PDF",
|
||||
"experience": "Experience",
|
||||
"education": "Education",
|
||||
"skills": "Skills",
|
||||
"certifications": "Certifications",
|
||||
"present": "Present"
|
||||
},
|
||||
"shop": {
|
||||
"title": "Shop",
|
||||
"subtitle": "Courses, templates, and resources I've built",
|
||||
"buy": "Buy",
|
||||
"free": "Free",
|
||||
"digital": "Digital",
|
||||
"node_product": "NODE Product"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact",
|
||||
"subtitle": "For collaboration, consulting, or any question",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"subject": "Subject",
|
||||
"message": "Message",
|
||||
"send": "Send Message",
|
||||
"sending": "Sending...",
|
||||
"success": "Message received! I'll get back to you soon.",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"open_to": "Open to",
|
||||
"open_to_items": ["Consulting", "Project Collaboration", "Speaking"],
|
||||
"based_in": "Based in Tehran, Iran"
|
||||
},
|
||||
"uses": {
|
||||
"title": "Uses",
|
||||
"subtitle": "Tools, software, and hardware I use daily",
|
||||
"categories": {
|
||||
"development": "Development",
|
||||
"design": "Design",
|
||||
"marketing": "Marketing",
|
||||
"productivity": "Productivity",
|
||||
"hardware": "Hardware"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"label": "not-found",
|
||||
"title": "Page not found",
|
||||
"description": "The page you're looking for doesn't exist or has been moved.",
|
||||
"home": "Back to home",
|
||||
"writing": "Browse writing"
|
||||
},
|
||||
"error": {
|
||||
"label": "server-error",
|
||||
"title": "Something went wrong",
|
||||
"description": "An error occurred on the server. Please try again.",
|
||||
"retry": "Try again",
|
||||
"home": "Back to home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,5 +79,88 @@
|
||||
"outcome": "نتیجه",
|
||||
"tech_stack": "تکنولوژیها",
|
||||
"tools": "ابزارها"
|
||||
},
|
||||
"about": {
|
||||
"title": "درباره علی تقوی",
|
||||
"subtitle": "سازندهای از تهران که در تقاطع فناوری، برندینگ و محصول کار میکند",
|
||||
"bio_heading": "چه کسی هستم",
|
||||
"bio": "علی تقوی هستم — مدیرعامل NODE-Group و یک سازنده که از تهران برای دنیا میسازد. بیش از یک دهه است که محصول، برند و سیستم میسازم؛ از نرمافزار گرفته تا استراتژی بازاریابی و هویت بصری.\n\nکارم در تقاطع فناوری، بازاریابی دیجیتال و برندینگ قرار دارد. باور دارم که بهترین محصولات از ترکیب تفکر سیستماتیک با درک عمیق انسانی به وجود میآیند.",
|
||||
"working_on": "الان روی چه کار میکنم",
|
||||
"journey": "مسیر",
|
||||
"values_heading": "چه چیزهایی برایم مهم است",
|
||||
"focus_heading": "حوزههای تمرکز",
|
||||
"values": [
|
||||
{ "title": "شفافیت", "desc": "در کار و ارتباط، صادقانه و روشن حرف میزنم" },
|
||||
{ "title": "تفکر سیستمی", "desc": "مسائل را در قالب سیستمها میبینم، نه رویدادهای مجزا" },
|
||||
{ "title": "ساختن برای ایران", "desc": "محصولاتی میسازم که واقعاً برای کاربر فارسیزبان طراحی شدهاند" },
|
||||
{ "title": "کیفیت بلندمدت", "desc": "ترجیح میدهم کمتر بسازم اما درستتر و ماندگارتر" }
|
||||
],
|
||||
"focus_areas": [
|
||||
"فناوری و توسعه نرمافزار",
|
||||
"بازاریابی دیجیتال",
|
||||
"برندینگ و هویت بصری",
|
||||
"استراتژی محصول",
|
||||
"محتوا و تولید محتوا",
|
||||
"متنباز"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"title": "رزومه",
|
||||
"download_pdf": "دانلود PDF",
|
||||
"experience": "تجربه کاری",
|
||||
"education": "تحصیلات",
|
||||
"skills": "مهارتها",
|
||||
"certifications": "گواهینامهها",
|
||||
"present": "اکنون"
|
||||
},
|
||||
"shop": {
|
||||
"title": "فروشگاه",
|
||||
"subtitle": "دورهها، قالبها و منابعی که ساختهام",
|
||||
"buy": "خرید",
|
||||
"free": "رایگان",
|
||||
"digital": "دیجیتال",
|
||||
"node_product": "محصول NODE"
|
||||
},
|
||||
"contact": {
|
||||
"title": "تماس",
|
||||
"subtitle": "برای همکاری، مشاوره یا هر سوالی پیام بده",
|
||||
"name": "نام",
|
||||
"email": "ایمیل",
|
||||
"subject": "موضوع",
|
||||
"message": "پیام",
|
||||
"send": "ارسال پیام",
|
||||
"sending": "در حال ارسال...",
|
||||
"success": "پیامت دریافت شد! بهزودی جواب میدم.",
|
||||
"error": "مشکلی پیش آمد. دوباره تلاش کن.",
|
||||
"open_to": "آماده همکاری در",
|
||||
"open_to_items": ["مشاوره", "همکاری در پروژه", "سخنرانی"],
|
||||
"based_in": "مستقر در تهران، ایران"
|
||||
},
|
||||
"uses": {
|
||||
"title": "ابزارها",
|
||||
"subtitle": "ابزارها، نرمافزارها و سختافزارهایی که ازشان استفاده میکنم",
|
||||
"categories": {
|
||||
"development": "توسعه",
|
||||
"design": "طراحی",
|
||||
"marketing": "بازاریابی",
|
||||
"productivity": "بهرهوری",
|
||||
"hardware": "سختافزار"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"label": "not-found",
|
||||
"title": "صفحه پیدا نشد",
|
||||
"description": "صفحهای که دنبالش میگردی وجود ندارد یا منتقل شده.",
|
||||
"home": "برگشت به خانه",
|
||||
"writing": "مشاهده نوشتهها"
|
||||
},
|
||||
"error": {
|
||||
"label": "server-error",
|
||||
"title": "مشکلی پیش آمد",
|
||||
"description": "خطایی در سرور رخ داد. لطفاً دوباره تلاش کن.",
|
||||
"retry": "تلاش دوباره",
|
||||
"home": "برگشت به خانه"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user