feat(website): Next.js 16 marketing website with RTL/Farsi

Marketing website for Meezi platform:
- Server-side rendered pages: home, demo, blog, pricing
- RTL/Farsi layout with Vazirmatn font
- SEO metadata and Open Graph tags
- proxy.ts for Next.js 16 middleware convention
- MEEZI_API_URL internal Docker network routing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:32 +03:30
parent 131ecdbbe6
commit d62bb8d3ad
84 changed files with 16985 additions and 0 deletions
@@ -0,0 +1,52 @@
import { useLocale, useTranslations } from "next-intl";
import { Clock, ArrowLeft, ArrowRight } from "lucide-react";
import type { BlogPost } from "@/lib/blog";
export function BlogCard({ post }: { post: BlogPost }) {
const locale = useLocale();
const t = useTranslations("blog");
const isRtl = locale === "fa";
const Arrow = isRtl ? ArrowLeft : ArrowRight;
const base = `/${locale}`;
return (
<article className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition-all duration-200 hover:-translate-y-1 hover:shadow-md">
{/* Cover */}
<div className="flex h-44 items-center justify-center bg-gradient-to-br from-brand-50 to-brand-100">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-200/60">
<svg viewBox="0 0 24 24" className="h-8 w-8 fill-brand-700/50" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 1.5L18.5 9H13V3.5zM6 20V4h5v7h7v9H6z" />
</svg>
</div>
</div>
<div className="flex flex-1 flex-col p-5">
{/* Category + Reading time */}
<div className="mb-3 flex items-center justify-between">
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-xs font-semibold text-brand-700">
{post.category}
</span>
<span className="flex items-center gap-1 text-xs text-gray-400">
<Clock className="h-3 w-3" />
{post.readingTime}
</span>
</div>
<h3 className="mb-2 text-base font-semibold leading-snug text-gray-900 group-hover:text-brand-700 transition-colors">
{post.title}
</h3>
<p className="flex-1 text-sm leading-relaxed text-gray-500 line-clamp-3">
{post.excerpt}
</p>
<a
href={`${base}/blog/${post.slug}`}
className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand-700 hover:text-brand-800"
>
{t("readMore")}
<Arrow className="h-3.5 w-3.5" />
</a>
</div>
</article>
);
}
@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { MessageSquare, Send, CheckCircle2, AlertCircle } from "lucide-react";
type FormState = "idle" | "submitting" | "success" | "error";
interface CommentFormProps {
slug: string;
locale: string;
}
export function CommentForm({ slug, locale }: CommentFormProps) {
const isEn = locale === "en";
const [state, setState] = useState<FormState>("idle");
const [form, setForm] = useState({ name: "", email: "", content: "" });
const [errors, setErrors] = useState<Partial<typeof form>>({});
function validate() {
const e: Partial<typeof form> = {};
if (!form.name.trim())
e.name = isEn ? "Name is required" : "نام الزامی است";
if (!form.content.trim() || form.content.trim().length < 10)
e.content = isEn
? "Comment must be at least 10 characters"
: "نظر باید حداقل ۱۰ کاراکتر باشد";
return e;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setErrors({});
setState("submitting");
try {
const res = await fetch(`/api/blog/${slug}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
authorName: form.name.trim(),
authorEmail: form.email.trim() || undefined,
content: form.content.trim(),
}),
});
if (!res.ok) throw new Error("Failed");
setState("success");
setForm({ name: "", email: "", content: "" });
} catch {
setState("error");
}
}
if (state === "success") {
return (
<div className="rounded-2xl border border-green-100 bg-green-50 p-6 text-center">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-green-500" />
<h3 className="mb-1 text-base font-semibold text-gray-900">
{isEn ? "Comment submitted!" : "نظر ثبت شد!"}
</h3>
<p className="text-sm text-gray-500">
{isEn
? "Your comment is awaiting moderation and will appear shortly."
: "نظر شما بررسی می‌شود و به‌زودی نمایش داده می‌شود."}
</p>
<button
onClick={() => setState("idle")}
className="mt-4 text-sm font-medium text-brand-700 hover:underline"
>
{isEn ? "Leave another comment" : "ثبت نظر دیگر"}
</button>
</div>
);
}
return (
<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-5 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand-50">
<MessageSquare className="h-4 w-4 text-brand-700" />
</div>
<h3 className="text-base font-semibold text-gray-900">
{isEn ? "Leave a comment" : "ثبت نظر"}
</h3>
</div>
{state === "error" && (
<div className="mb-4 flex items-center gap-2 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{isEn
? "Failed to submit. Please try again."
: "ارسال ناموفق بود. دوباره تلاش کنید."}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
{isEn ? "Name" : "نام"} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder={isEn ? "Your name" : "نام شما"}
className={`w-full rounded-xl border px-3 py-2.5 text-sm outline-none transition focus:ring-2 focus:ring-brand-500/30 ${
errors.name
? "border-red-300 bg-red-50"
: "border-gray-200 bg-gray-50 focus:border-brand-400"
}`}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600">{errors.name}</p>
)}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
{isEn ? "Email" : "ایمیل"}{" "}
<span className="text-xs text-gray-400">
({isEn ? "optional" : "اختیاری"})
</span>
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
placeholder={isEn ? "your@email.com" : "ایمیل شما (نمایش داده نمی‌شود)"}
className="w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-500/30"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
{isEn ? "Comment" : "نظر"} <span className="text-red-500">*</span>
</label>
<textarea
rows={4}
value={form.content}
onChange={(e) =>
setForm((f) => ({ ...f, content: e.target.value }))
}
placeholder={
isEn
? "Share your thoughts..."
: "نظر خود را بنویسید..."
}
className={`w-full resize-none rounded-xl border px-3 py-2.5 text-sm outline-none transition focus:ring-2 focus:ring-brand-500/30 ${
errors.content
? "border-red-300 bg-red-50"
: "border-gray-200 bg-gray-50 focus:border-brand-400"
}`}
/>
{errors.content && (
<p className="mt-1 text-xs text-red-600">{errors.content}</p>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400">
{isEn
? "Comments are reviewed before publishing."
: "نظرات قبل از انتشار بررسی می‌شوند."}
</p>
<button
type="submit"
disabled={state === "submitting"}
className="inline-flex items-center gap-2 rounded-xl bg-brand-700 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-800 disabled:opacity-60"
>
{state === "submitting" ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{isEn ? "Submitting..." : "در حال ارسال..."}
</>
) : (
<>
<Send className="h-4 w-4" />
{isEn ? "Submit comment" : "ثبت نظر"}
</>
)}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,80 @@
import { MessageSquare, User } from "lucide-react";
interface Comment {
id: string;
authorName: string;
content: string;
createdAt: string;
}
interface CommentsListProps {
comments: Comment[];
locale: string;
}
function formatDate(dateStr: string, locale: string) {
try {
const date = new Date(dateStr);
return date.toLocaleDateString(locale === "fa" ? "fa-IR" : "en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return dateStr;
}
}
export function CommentsList({ comments, locale }: CommentsListProps) {
const isEn = locale === "en";
if (comments.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50/50 py-10 text-center">
<MessageSquare className="mx-auto mb-3 h-8 w-8 text-gray-300" />
<p className="text-sm text-gray-400">
{isEn
? "No comments yet. Be the first to share your thoughts!"
: "هنوز نظری ثبت نشده. اولین نفر باشید!"}
</p>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<MessageSquare className="h-4 w-4 text-brand-600" />
{isEn
? `${comments.length} comment${comments.length !== 1 ? "s" : ""}`
: `${comments.length} نظر`}
</h3>
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"
>
<div className="mb-3 flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50">
<User className="h-4 w-4 text-brand-700" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900">
{comment.authorName}
</p>
<p className="text-xs text-gray-400">
{formatDate(comment.createdAt, locale)}
</p>
</div>
</div>
<p className="text-sm leading-relaxed text-gray-600">
{comment.content}
</p>
</div>
))}
</div>
</div>
);
}