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