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