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,84 @@
import { NextRequest, NextResponse } from "next/server";
const API_URL = process.env.MEEZI_API_URL ?? "http://localhost:5001";
export async function GET(
_req: NextRequest,
{ params }: { params: { slug: string } }
) {
const { slug } = await Promise.resolve(params);
try {
const res = await fetch(
`${API_URL}/api/public/website/posts/${encodeURIComponent(slug)}/comments`,
{ next: { revalidate: 60 } }
);
if (!res.ok) {
return NextResponse.json({ comments: [] });
}
const json = await res.json();
// Unwrap ApiResponse<T>: { success, data } → normalize to { comments: [...] }
const payload = json?.data ?? json;
const comments = Array.isArray(payload) ? payload : [];
return NextResponse.json({ comments });
} catch {
// API not available — return empty list gracefully
return NextResponse.json({ comments: [] });
}
}
export async function POST(
req: NextRequest,
{ params }: { params: { slug: string } }
) {
const { slug } = await Promise.resolve(params);
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { authorName, authorEmail, content } = body as {
authorName?: string;
authorEmail?: string;
content?: string;
};
if (!authorName || !content) {
return NextResponse.json(
{ error: "authorName and content are required" },
{ status: 400 }
);
}
try {
const res = await fetch(
`${API_URL}/api/public/website/posts/${encodeURIComponent(slug)}/comments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ authorName, authorEmail, content }),
}
);
if (!res.ok) {
const text = await res.text();
return NextResponse.json(
{ error: text || "Upstream error" },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data, { status: 201 });
} catch {
return NextResponse.json(
{ error: "Failed to reach API" },
{ status: 502 }
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
interface DemoRequest {
name: string;
business: string;
phone: string;
email?: string;
branches: string;
message?: string;
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as DemoRequest;
// Validate required fields
if (!body.name?.trim() || !body.business?.trim() || !body.phone?.trim()) {
return NextResponse.json(
{ success: false, error: "Missing required fields" },
{ status: 400 }
);
}
// Basic phone validation (Iranian format)
const phoneClean = body.phone.replace(/\s|-/g, "");
if (phoneClean.length < 10) {
return NextResponse.json(
{ success: false, error: "Invalid phone number" },
{ status: 400 }
);
}
// In production: send email via SMTP / save to DB / forward to CRM
// For now: forward to Meezi API
const apiBase = process.env.MEEZI_API_URL ?? "https://api.meezi.ir";
try {
await fetch(`${apiBase}/api/public/demo-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contactName: body.name.trim(),
businessName: body.business.trim(),
phone: phoneClean,
email: body.email?.trim() || null,
branchCount: body.branches,
notes: body.message?.trim() || null,
source: "website",
}),
});
} catch {
// Don't fail the request if backend is unreachable — log and continue
console.error("[demo] Failed to forward to API");
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ success: false, error: "Internal server error" },
{ status: 500 }
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
export const runtime = "edge";
/** Strip non-Latin characters to avoid satori font shaping issues with Arabic/Persian. */
function safeText(str: string, fallback: string): string {
const cleaned = str.replace(/[^ -ɏ\s]/g, "").trim();
return cleaned.length > 2 ? cleaned : fallback;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const rawTitle = searchParams.get("t") ?? "Meezi";
const rawSub = searchParams.get("s") ?? "Smart Cafe & Restaurant Management";
const page = searchParams.get("page") ?? "meezi.ir";
const title = safeText(rawTitle, "Meezi");
const sub = safeText(rawSub, "Smart Cafe & Restaurant Management");
const tag = safeText(page, "meezi.ir");
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
background: "linear-gradient(140deg, #052e16 0%, #14532d 55%, #166534 100%)",
padding: "72px 80px",
fontFamily: "system-ui, -apple-system, sans-serif",
position: "relative",
}}
>
{/* Top bar */}
<div style={{ display: "flex", alignItems: "center", gap: "14px" }}>
{/* Logo mark */}
<div
style={{
width: "52px",
height: "52px",
borderRadius: "14px",
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.18)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "26px",
fontWeight: "900",
color: "white",
}}
>
M
</div>
<span
style={{
fontSize: "26px",
fontWeight: "700",
color: "rgba(255,255,255,0.9)",
letterSpacing: "-0.3px",
}}
>
Meezi
</span>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Right badge */}
<div
style={{
background: "rgba(134,239,172,0.15)",
border: "1px solid rgba(134,239,172,0.3)",
borderRadius: "999px",
padding: "6px 16px",
fontSize: "13px",
color: "#86efac",
fontWeight: "600",
display: "flex",
alignItems: "center",
}}
>
{tag}
</div>
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Main content */}
<div style={{ display: "flex", flexDirection: "column" }}>
{/* Tag line */}
<div
style={{
display: "flex",
marginBottom: "24px",
}}
>
<div
style={{
background: "rgba(255,255,255,0.08)",
border: "1px solid rgba(255,255,255,0.14)",
borderRadius: "999px",
padding: "6px 16px",
fontSize: "14px",
color: "rgba(255,255,255,0.55)",
fontWeight: "500",
display: "flex",
alignItems: "center",
}}
>
Cafe &amp; Restaurant Management Platform
</div>
</div>
<div
style={{
fontSize: title.length > 40 ? "52px" : title.length > 28 ? "62px" : "70px",
fontWeight: "900",
color: "white",
lineHeight: 1.15,
marginBottom: "20px",
letterSpacing: "-1px",
display: "flex",
}}
>
{title}
</div>
<div
style={{
fontSize: "22px",
color: "rgba(255,255,255,0.55)",
lineHeight: 1.5,
maxWidth: "740px",
display: "flex",
}}
>
{sub}
</div>
</div>
{/* Bottom accent bar */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "5px",
background: "linear-gradient(90deg, #22c55e 0%, #16a34a 50%, #15803d 100%)",
}}
/>
</div>
),
{ width: 1200, height: 630 }
);
}