feat(blog): real bilingual blog with covers, list & article pages

- Add blog content module (posts.ts): 4 platform-focused articles (deploy,
  managed databases, custom domain + SSL, replicas & cost) in fa/en with
  tag, date, reading time, excerpt and structured body.
- Self-contained gradient cover art (BlogCover) — no external images, themed
  per topic with the brand cloud motif.
- Landing blog section now shows the 3 latest posts with covers, linking to
  article pages; "view all" links to /blog.
- Dedicated /blog list page and /blog/[slug] article page over the shared
  cloud-sky shell (BlogShell), with a not-found state.
- Remove the now-redundant "Learn" header nav item (blog covers getting
  started) and drop its orphan section anchor.
- i18n: blog minRead/notFound/backToBlog strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-23 13:35:02 +03:30
parent f89c3de826
commit bd14eb2daa
10 changed files with 626 additions and 28 deletions
@@ -0,0 +1,105 @@
'use client';
import { useParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Link } from '@/i18n/Link';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { getPost, type BlogBlock } from '@/components/landing/blog/posts';
import { BlogCover } from '@/components/landing/blog/BlogCover';
import { BlogShell } from '@/components/landing/blog/BlogShell';
function Block({ block }: { block: BlogBlock }) {
if (block.type === 'h2') {
return <h2 className="abrban-ink mt-8 text-2xl font-bold text-white">{block.text}</h2>;
}
if (block.type === 'ul') {
return (
<ul className="mt-4 space-y-2">
{block.items.map((item) => (
<li key={item} className="flex gap-2 text-white/85">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-300" />
<span className="leading-8">{item}</span>
</li>
))}
</ul>
);
}
return <p className="mt-4 leading-9 text-white/85">{block.text}</p>;
}
export default function BlogPostPage() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug[0] : (params.slug ?? '');
const t = useT();
const b = t.landing.blog;
const locale = useLocale();
const post = getPost(slug);
if (!post) {
return (
<BlogShell>
<div className="abrban-panel mx-auto max-w-xl rounded-3xl p-10 text-center">
<p className="abrban-ink text-lg text-white/85">{b.notFound}</p>
<Link
href="/blog"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-primary-600 px-6 py-3 font-bold text-white transition hover:bg-primary-500"
>
{b.backToBlog}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</Link>
</div>
</BlogShell>
);
}
const c = post.content[locale];
return (
<BlogShell>
<article className="mx-auto max-w-3xl">
<Link
href="/blog"
className="mb-6 inline-flex items-center gap-1 text-sm font-semibold text-white/80 transition hover:text-white"
>
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
{b.backToBlog}
</Link>
<div className="abrban-panel overflow-hidden rounded-[1.75rem]">
<BlogCover coverKey={post.cover} className="aspect-[21/9] w-full" />
<div className="p-6 sm:p-10">
<div className="flex flex-wrap items-center gap-2 text-xs text-white/70">
<span className="rounded-full bg-primary-500/25 px-3 py-1 font-bold text-primary-100 ring-1 ring-primary-400/40">
{c.tag}
</span>
<span>{new Date(post.date).toLocaleDateString(locale)}</span>
<span>·</span>
<span>{b.minRead.replace('{n}', post.readingMin.toLocaleString(locale))}</span>
</div>
<h1 className="abrban-ink mt-4 text-3xl font-black leading-tight text-white sm:text-4xl">
{c.title}
</h1>
<p className="abrban-ink mt-4 text-lg leading-9 text-white/85">{c.excerpt}</p>
<div className="mt-6 border-t border-white/10 pt-2">
{c.body.map((block, i) => (
<Block key={i} block={block} />
))}
</div>
</div>
</div>
<div className="mt-10 flex justify-center">
<Link
href="/register"
className="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-8 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
>
{t.landing.hero.ctaPrimary}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</Link>
</div>
</article>
</BlogShell>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { ArrowLeft } from 'lucide-react';
import { Link } from '@/i18n/Link';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { BLOG_POSTS } from '@/components/landing/blog/posts';
import { BlogCover } from '@/components/landing/blog/BlogCover';
import { BlogShell } from '@/components/landing/blog/BlogShell';
export default function BlogIndexPage() {
const b = useT().landing.blog;
const locale = useLocale();
const posts = [...BLOG_POSTS].sort((a, z) => z.date.localeCompare(a.date));
return (
<BlogShell>
<div className="mb-10 text-center">
<h1 className="abrban-ink text-4xl font-black tracking-tight sm:text-5xl">{b.title}</h1>
<p className="abrban-ink mx-auto mt-3 max-w-2xl text-white/85">{b.subtitle}</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => {
const c = post.content[locale];
return (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="abrban-panel group flex h-full flex-col overflow-hidden rounded-2xl transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50"
>
<BlogCover coverKey={post.cover} className="aspect-[16/9] w-full" />
<div className="flex flex-1 flex-col p-6">
<div className="flex items-center gap-2 text-xs text-white/70">
<span className="rounded-full bg-primary-500/25 px-3 py-1 font-bold text-primary-100 ring-1 ring-primary-400/40">
{c.tag}
</span>
<span>{new Date(post.date).toLocaleDateString(locale)}</span>
<span>·</span>
<span>{b.minRead.replace('{n}', post.readingMin.toLocaleString(locale))}</span>
</div>
<h2 className="abrban-ink mt-4 text-lg font-bold leading-8 text-white">{c.title}</h2>
<p className="mt-2 flex-1 text-sm leading-7 text-white/80">{c.excerpt}</p>
<span className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-primary-200 transition group-hover:gap-2">
{b.readMore}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</span>
</div>
</Link>
);
})}
</div>
</BlogShell>
);
}
@@ -0,0 +1,29 @@
import { Rocket, Database, ShieldCheck, Layers, ScrollText, type LucideIcon } from 'lucide-react';
import type { CoverKey } from './posts';
// Self-contained cover art: a branded gradient with soft "cloud" blobs and a
// topic glyph. No external images, so blog cards stay fast and resilient.
const COVERS: Record<CoverKey, { gradient: string; Icon: LucideIcon }> = {
deploy: { gradient: 'from-sky-500 via-blue-600 to-indigo-700', Icon: Rocket },
database: { gradient: 'from-emerald-500 via-teal-600 to-cyan-700', Icon: Database },
domain: { gradient: 'from-cyan-500 via-sky-600 to-blue-700', Icon: ShieldCheck },
scale: { gradient: 'from-violet-500 via-purple-600 to-fuchsia-700', Icon: Layers },
logs: { gradient: 'from-slate-600 via-slate-700 to-blue-900', Icon: ScrollText },
};
export function BlogCover({ coverKey, className = '' }: { coverKey: CoverKey; className?: string }) {
const cover = COVERS[coverKey] ?? COVERS.deploy;
const Icon = cover.Icon;
return (
<div className={`relative overflow-hidden bg-gradient-to-br ${cover.gradient} ${className}`}>
{/* Soft cloud blobs */}
<div className="absolute -left-6 -top-8 h-28 w-28 rounded-full bg-white/20 blur-2xl" />
<div className="absolute right-6 top-6 h-16 w-32 rounded-full bg-white/15 blur-xl" />
<div className="absolute -bottom-10 left-1/3 h-32 w-44 rounded-full bg-white/10 blur-2xl" />
{/* Topic glyph */}
<div className="absolute inset-0 flex items-center justify-center">
<Icon className="h-14 w-14 text-white/95 drop-shadow-[0_4px_16px_rgba(2,6,23,0.45)]" />
</div>
</div>
);
}
@@ -0,0 +1,36 @@
'use client';
import type { ReactNode } from 'react';
import { AuthSky } from '@/components/auth/AuthSky';
import { Logo } from '@/components/landing/Logo';
import { Link } from '@/i18n/Link';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { useT } from '@/i18n/I18nProvider';
// Blog pages reuse the auth screens' lightweight cloud sky (no WebGL) so they
// stay visually tied to the landing without the heavy scroll-driven scene.
export function BlogShell({ children }: { children: ReactNode }) {
const t = useT();
return (
<div className="relative min-h-screen w-full overflow-hidden bg-[#9fb4d4] text-white">
<AuthSky />
<header className="relative z-10 mx-auto flex max-w-5xl items-center justify-between px-6 py-5">
<Link href="/" aria-label={t.common.appName} className="transition hover:opacity-90">
<Logo className="abrban-ink" />
</Link>
<div className="flex items-center gap-1">
<LanguageSwitcher className="text-white/85 hover:text-white" />
<Link
href="/"
className="rounded-lg px-3 py-2 text-sm font-medium text-white/85 transition hover:text-white"
>
{t.auth.backHome}
</Link>
</div>
</header>
<main className="relative z-10 mx-auto max-w-5xl px-6 pb-24">{children}</main>
</div>
);
}
@@ -0,0 +1,354 @@
import type { Locale } from '@/i18n/config';
// Cover illustrations are rendered by BlogCover (self-contained, no external
// images so the page stays fast and works behind Iran-network constraints).
export type CoverKey = 'deploy' | 'database' | 'domain' | 'scale' | 'logs';
export type BlogBlock =
| { type: 'p'; text: string }
| { type: 'h2'; text: string }
| { type: 'ul'; items: string[] };
export interface BlogPostContent {
tag: string;
title: string;
excerpt: string;
body: BlogBlock[];
}
export interface BlogPost {
slug: string;
cover: CoverKey;
/** ISO date — formatted per locale at render time. */
date: string;
readingMin: number;
content: Record<Locale, BlogPostContent>;
}
// Articles are written for the Abrban product context: a self-service PaaS on
// Kubernetes for the Iranian market (git deploy, managed databases, custom
// domains + automatic SSL, replicas, live logs, transparent prepaid billing).
export const BLOG_POSTS: BlogPost[] = [
{
slug: 'deploy-your-first-app',
cover: 'deploy',
date: '2026-06-10',
readingMin: 5,
content: {
'fa-IR': {
tag: 'شروع کار',
title: 'اولین اپلیکیشن خود را در چند دقیقه روی ابربان منتشر کن',
excerpt:
'از اتصال ریپازیتوری گیت تا یک آدرس زندهٔ امن — مسیر کامل انتشار اپ روی کوبرنتیز، بدون درگیریِ سرور و DevOps.',
body: [
{
type: 'p',
text: 'ابربان یک پلتفرم ابریِ خودسرویس روی کوبرنتیز است؛ هدفش این است که فاصلهٔ بین «کدی که نوشته‌ای» تا «اپلیکیشنی که زنده است» را به چند دقیقه برساند. در این راهنما قدم‌به‌قدم اولین انتشار را با هم انجام می‌دهیم.',
},
{ type: 'h2', text: '۱) ساخت حساب و اتصال ریپازیتوری' },
{
type: 'p',
text: 'بعد از ثبت‌نام، یک اپلیکیشن جدید بساز و ریپازیتوری گیت خود را وصل کن. ابربان نوع پروژه (مثل Node.js، لاراول یا وردپرس) را تشخیص می‌دهد و فرایند بیلد را به‌صورت خودکار آماده می‌کند.',
},
{ type: 'h2', text: '۲) انتخاب منابع' },
{
type: 'p',
text: 'میزان پردازنده (CPU)، حافظه (RAM) و فضای ذخیره‌سازی را بر اساس نیازت انتخاب کن. اگر مطمئن نیستی، با مقادیر کم شروع کن؛ هر زمان خواستی می‌توانی بدون توقف سرویس، منابع را ارتقا بدهی.',
},
{ type: 'h2', text: '۳) بیلد و انتشار خودکار' },
{
type: 'p',
text: 'با یک کلیک، ابربان ایمیج داکر را می‌سازد و روی کوبرنتیز منتشر می‌کند. لاگ بیلد را همان لحظه و به‌صورت زنده می‌بینی و دقیقاً می‌دانی در هر مرحله چه اتفاقی می‌افتد.',
},
{ type: 'h2', text: '۴) آنلاین شدن' },
{
type: 'p',
text: 'به‌محض اتمام انتشار، یک Preview URL پایدار روی TLS تحویل می‌گیری. می‌توانی دامنهٔ اختصاصی‌ات را هم وصل کنی تا گواهی SSL به‌صورت خودکار صادر و تمدید شود.',
},
{
type: 'ul',
items: [
'بیلد و انتشار خودکار از روی گیت',
'لاگ زندهٔ بیلد و اجرا',
'ارتقای منابع بدون قطعی',
'دامنهٔ اختصاصی با SSL خودکار',
],
},
{
type: 'p',
text: 'همین حالا حسابت را بساز و اولین دیپلوی را تجربه کن — بقیهٔ پیچیدگی‌ها با ابربان است.',
},
],
},
'en-US': {
tag: 'Getting started',
title: 'Ship your first app on Abrban in minutes',
excerpt:
'From connecting a git repository to a live, secure URL — the full path to deploying on Kubernetes without server or DevOps hassle.',
body: [
{
type: 'p',
text: 'Abrban is a self-service cloud platform on Kubernetes. Its goal is to shrink the gap between “the code you wrote” and “the app that is live” to a few minutes. In this guide we walk through your first deploy together.',
},
{ type: 'h2', text: '1) Create an account and connect a repo' },
{
type: 'p',
text: 'After signing up, create a new application and connect your git repository. Abrban detects the project type (such as Node.js, Laravel or WordPress) and prepares the build automatically.',
},
{ type: 'h2', text: '2) Pick your resources' },
{
type: 'p',
text: 'Choose CPU, memory and storage based on your needs. If unsure, start small — you can upgrade resources later without taking the service down.',
},
{ type: 'h2', text: '3) Automatic build & release' },
{
type: 'p',
text: 'With one click, Abrban builds the Docker image and deploys it to Kubernetes. You watch the build logs live and know exactly what happens at each step.',
},
{ type: 'h2', text: '4) Go live' },
{
type: 'p',
text: 'As soon as the deploy finishes you get a stable TLS Preview URL. Connect your own domain and the SSL certificate is issued and renewed automatically.',
},
{
type: 'ul',
items: [
'Automatic build & release from git',
'Live build and runtime logs',
'Zero-downtime resource upgrades',
'Custom domain with automatic SSL',
],
},
{
type: 'p',
text: 'Create your account now and experience your first deploy — Abrban handles the rest of the complexity.',
},
],
},
},
},
{
slug: 'managed-databases',
cover: 'database',
date: '2026-06-14',
readingMin: 4,
content: {
'fa-IR': {
tag: 'دیتابیس',
title: 'دیتابیس مدیریت‌شده: از PostgreSQL تا Redis، آماده در چند ثانیه',
excerpt:
'به‌جای نصب و نگه‌داری دستیِ دیتابیس، یکی را انتخاب کن و منابعش را به‌اندازهٔ نیازت تنظیم کن — پشتیبان‌گیری و پایداری با ما.',
body: [
{
type: 'p',
text: 'راه‌اندازی و نگه‌داری دیتابیس معمولاً یکی از زمان‌برترین بخش‌های کار است. ابربان این لایه را برایت مدیریت می‌کند تا فقط روی دادهٔ اپلیکیشنت تمرکز کنی.',
},
{ type: 'h2', text: 'دیتابیس‌های پشتیبانی‌شده' },
{
type: 'p',
text: 'می‌توانی PostgreSQL، MySQL، MariaDB یا MongoDB را به اپت متصل کنی و در کنارش از سرویس‌های جانبی مثل Redis و RabbitMQ هم استفاده کنی.',
},
{ type: 'h2', text: 'قیمت‌گذاری بر اساس منابع' },
{
type: 'p',
text: 'درست مثل اپلیکیشن، هزینهٔ دیتابیس هم بر پایهٔ منابعی است که خودت انتخاب می‌کنی: پردازنده، حافظه و فضای ذخیره‌سازی. این یعنی فقط بابت چیزی که واقعاً نیاز داری پرداخت می‌کنی.',
},
{ type: 'h2', text: 'اسنپ‌شات و بازگردانی' },
{
type: 'p',
text: 'هر زمان خواستی از وضعیت دیتابیس اسنپ‌شات بگیر و در صورت نیاز به همان نقطه بازگرد. خیال‌ات از بابت داده‌ها راحت است.',
},
{
type: 'ul',
items: [
'PostgreSQL، MySQL، MariaDB، MongoDB',
'سرویس‌های جانبی Redis و RabbitMQ',
'تنظیم منابع به‌اندازهٔ نیاز',
'اسنپ‌شات و بازگردانی',
],
},
],
},
'en-US': {
tag: 'Databases',
title: 'Managed databases: from PostgreSQL to Redis, ready in seconds',
excerpt:
'Instead of installing and maintaining a database by hand, pick one and size its resources to your needs — backups and reliability are on us.',
body: [
{
type: 'p',
text: 'Setting up and maintaining a database is often one of the most time-consuming parts of a project. Abrban manages this layer for you so you can focus on your applications data.',
},
{ type: 'h2', text: 'Supported databases' },
{
type: 'p',
text: 'You can attach PostgreSQL, MySQL, MariaDB or MongoDB to your app, and use add-on services like Redis and RabbitMQ alongside it.',
},
{ type: 'h2', text: 'Resource-based pricing' },
{
type: 'p',
text: 'Just like applications, a database is priced by the resources you choose: CPU, memory and storage. That means you only pay for what you actually need.',
},
{ type: 'h2', text: 'Snapshots & restore' },
{
type: 'p',
text: 'Snapshot your database state whenever you want and restore to that point if needed. Your data stays safe.',
},
{
type: 'ul',
items: [
'PostgreSQL, MySQL, MariaDB, MongoDB',
'Redis and RabbitMQ add-ons',
'Size resources to your need',
'Snapshots and restore',
],
},
],
},
},
},
{
slug: 'custom-domain-and-ssl',
cover: 'domain',
date: '2026-06-18',
readingMin: 3,
content: {
'fa-IR': {
tag: 'امنیت',
title: 'دامنهٔ اختصاصی و گواهی SSL خودکار',
excerpt:
'دامنه‌ات را وصل کن و بگذار ابربان گواهی SSL را به‌صورت خودکار صادر و تمدید کند — بدون پیکربندی دستی.',
body: [
{
type: 'p',
text: 'حضور حرفه‌ای روی وب یعنی دامنهٔ اختصاصی و ارتباط امن. ابربان هر دو را ساده می‌کند.',
},
{ type: 'h2', text: 'اتصال دامنه' },
{
type: 'p',
text: 'کافی است رکورد DNS دامنه‌ات را به اپلیکیشن اشاره بدهی. بعد از تأیید، دامنه به سرویس تو متصل می‌شود.',
},
{ type: 'h2', text: 'SSL خودکار' },
{
type: 'p',
text: 'گواهی SSL به‌صورت خودکار صادر می‌شود و پیش از انقضا هم خودکار تمدید می‌شود؛ پس هیچ‌وقت نگران قطع‌شدن HTTPS نیستی.',
},
{
type: 'ul',
items: [
'اتصال دامنهٔ اختصاصی',
'صدور و تمدید خودکار گواهی',
'HTTPS پیش‌فرض و همیشگی',
],
},
],
},
'en-US': {
tag: 'Security',
title: 'Custom domains with automatic SSL',
excerpt:
'Connect your domain and let Abrban issue and renew the SSL certificate automatically — no manual configuration.',
body: [
{
type: 'p',
text: 'A professional web presence means a custom domain and a secure connection. Abrban makes both simple.',
},
{ type: 'h2', text: 'Connecting a domain' },
{
type: 'p',
text: 'Just point your domains DNS record at your application. After verification, the domain is attached to your service.',
},
{ type: 'h2', text: 'Automatic SSL' },
{
type: 'p',
text: 'The SSL certificate is issued automatically and renewed before expiry, so you never have to worry about HTTPS going down.',
},
{
type: 'ul',
items: [
'Attach a custom domain',
'Automatic certificate issuance and renewal',
'HTTPS by default, always',
],
},
],
},
},
},
{
slug: 'replicas-and-cost',
cover: 'scale',
date: '2026-06-21',
readingMin: 5,
content: {
'fa-IR': {
tag: 'مقیاس‌پذیری',
title: 'رپلیکا و بهینه‌سازی هزینه: درست مقیاس بده، کمتر هزینه کن',
excerpt:
'با رپلیکا اپت را پایدارتر و پرظرفیت‌تر کن و با انتخاب درست منابع، هزینه را زیر کنترل نگه دار.',
body: [
{
type: 'p',
text: 'مقیاس‌پذیری یعنی اپت بتواند ترافیک بیشتر را بدون افت کیفیت پاسخ بدهد. در ابربان این کار با «رپلیکا» انجام می‌شود؛ یعنی چند نمونهٔ هم‌زمان از اپت که بار را بین خود تقسیم می‌کنند.',
},
{ type: 'h2', text: 'رپلیکا چطور حساب می‌شود' },
{
type: 'p',
text: 'هزینهٔ هر اپ بر اساس منابعی که انتخاب کرده‌ای محاسبه می‌شود و این مقدار در تعداد رپلیکا ضرب می‌شود. یعنی دو رپلیکا تقریباً دو برابر منابع یک نمونه را مصرف و هزینه می‌کند. این مدل شفاف کمک می‌کند دقیقاً بدانی بابت چه چیزی پرداخت می‌کنی.',
},
{ type: 'h2', text: 'ماشین‌حساب تخمین هزینه' },
{
type: 'p',
text: 'در صفحهٔ اصلی ابربان یک ماشین‌حساب هست که با انتخاب رانتایم، منابع، دیتابیس و تعداد رپلیکا، برآورد هزینهٔ ماهانه را همان لحظه نشان می‌دهد. قبل از انتشار می‌توانی سناریوهای مختلف را مقایسه کنی.',
},
{ type: 'h2', text: 'چند توصیهٔ بهینه‌سازی' },
{
type: 'ul',
items: [
'با منابع کم شروع کن و بر اساس مصرف واقعی ارتقا بده',
'برای پایداری بیشتر در ترافیک بالا از رپلیکا استفاده کن',
'فضای ذخیره‌سازی را واقع‌بینانه انتخاب کن',
'از تخفیف‌های پلتفرم برای کاهش هزینه بهره ببر',
],
},
],
},
'en-US': {
tag: 'Scaling',
title: 'Replicas and cost: scale right, pay less',
excerpt:
'Use replicas to make your app more resilient and higher-capacity, and keep cost under control by sizing resources well.',
body: [
{
type: 'p',
text: 'Scaling means your app can serve more traffic without dropping quality. On Abrban this is done with “replicas” — several concurrent copies of your app that share the load.',
},
{ type: 'h2', text: 'How replicas are billed' },
{
type: 'p',
text: 'Each app is priced by the resources you chose, multiplied by the replica count. So two replicas use and cost roughly twice the resources of one. This transparent model helps you know exactly what you pay for.',
},
{ type: 'h2', text: 'The cost estimator' },
{
type: 'p',
text: 'Abrbans homepage has an estimator that instantly shows a monthly estimate as you choose runtime, resources, database and replica count. You can compare scenarios before deploying.',
},
{ type: 'h2', text: 'A few optimization tips' },
{
type: 'ul',
items: [
'Start small and upgrade based on real usage',
'Use replicas for resilience under heavy traffic',
'Pick storage realistically',
'Take advantage of platform discounts to cut cost',
],
},
],
},
},
},
];
export function getPost(slug: string): BlogPost | undefined {
return BLOG_POSTS.find((p) => p.slug === slug);
}
@@ -1,11 +1,18 @@
'use client';
import { ArrowLeft, BookOpen } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
import { Link } from '@/i18n/Link';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { Reveal } from '../Reveal';
import { BLOG_POSTS } from '../blog/posts';
import { BlogCover } from '../blog/BlogCover';
export function Blog() {
const b = useT().landing.blog;
const locale = useLocale();
// Show the three most recent posts on the landing.
const posts = [...BLOG_POSTS].sort((a, z) => z.date.localeCompare(a.date)).slice(0, 3);
return (
<section id="blog" className="relative px-6 py-28">
<div className="mx-auto max-w-6xl">
@@ -20,35 +27,43 @@ export function Blog() {
</Reveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{b.posts.map((post, i) => (
<Reveal key={post.title} delay={(i % 3) * 0.07}>
<article className="abrban-panel group flex h-full flex-col rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
<div className="flex items-center gap-2">
<span className="inline-flex rounded-full bg-primary-500/25 px-3 py-1 text-xs font-bold text-primary-100 ring-1 ring-primary-400/40">
{post.tag}
</span>
<span className="inline-flex rounded-full bg-amber-400/20 px-2.5 py-1 text-xs font-bold text-amber-100 ring-1 ring-amber-300/40">
{b.comingSoon}
</span>
</div>
<h3 className="abrban-ink mt-5 text-lg font-bold leading-8 text-white">{post.title}</h3>
<p className="mt-2 flex-1 text-sm leading-7 text-white/80">{post.desc}</p>
<span className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-primary-200/80">
{b.readMore}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</span>
</article>
</Reveal>
))}
{posts.map((post, i) => {
const c = post.content[locale];
return (
<Reveal key={post.slug} delay={(i % 3) * 0.07}>
<Link
href={`/blog/${post.slug}`}
className="abrban-panel group flex h-full flex-col overflow-hidden rounded-2xl transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50"
>
<BlogCover coverKey={post.cover} className="aspect-[16/9] w-full" />
<div className="flex flex-1 flex-col p-6">
<div className="flex items-center gap-2 text-xs text-white/70">
<span className="rounded-full bg-primary-500/25 px-3 py-1 font-bold text-primary-100 ring-1 ring-primary-400/40">
{c.tag}
</span>
<span>{b.minRead.replace('{n}', post.readingMin.toLocaleString(locale))}</span>
</div>
<h3 className="abrban-ink mt-4 text-lg font-bold leading-8 text-white">{c.title}</h3>
<p className="mt-2 flex-1 text-sm leading-7 text-white/80">{c.excerpt}</p>
<span className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-primary-200 transition group-hover:gap-2">
{b.readMore}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</span>
</div>
</Link>
</Reveal>
);
})}
</div>
<Reveal className="mt-10 flex justify-center">
<span className="inline-flex items-center gap-2 rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 font-bold text-white/90 backdrop-blur-md">
<Link
href="/blog"
className="inline-flex items-center gap-2 rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 font-bold text-white/90 backdrop-blur-md transition hover:bg-white/10"
>
{b.cta}
<span className="rounded-full bg-amber-400/20 px-2.5 py-0.5 text-xs font-bold text-amber-100 ring-1 ring-amber-300/40">
{b.comingSoon}
</span>
</span>
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</Link>
</Reveal>
</div>
</section>
@@ -6,7 +6,7 @@ import { useT } from '@/i18n/I18nProvider';
export function HowItWorks() {
const how = useT().landing.how;
return (
<section id="learn" className="relative px-6 py-28">
<section className="relative px-6 py-28">
<div className="mx-auto max-w-5xl">
<Reveal className="mb-16 flex justify-center">
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-4xl font-bold text-white">
@@ -15,7 +15,6 @@ const NAV_ITEMS = [
{ id: 'services', key: 'services' },
{ id: 'pricing', key: 'pricing' },
{ id: 'estimator', key: 'estimator' },
{ id: 'learn', key: 'learn' },
{ id: 'blog', key: 'blog' },
] as const;
+3
View File
@@ -190,6 +190,9 @@ const en: Dictionary = {
comingSoon: 'Coming soon',
cta: 'View all articles',
readMore: 'Read more',
minRead: '{n} min read',
notFound: 'This article was not found.',
backToBlog: 'Back to the blog',
posts: [
{ tag: 'Getting started', title: 'Ship your first app in minutes', desc: 'From connecting a repo to a domain and automatic SSL — step by step.' },
{ tag: 'Cost optimization', title: 'Pick the right resources and pay less', desc: 'Tune CPU, memory, replicas and add-on services wisely.' },
+3
View File
@@ -189,6 +189,9 @@ const fa = {
comingSoon: 'به‌زودی',
cta: 'مشاهدهٔ همهٔ مقالات',
readMore: 'ادامهٔ مطلب',
minRead: '{n} دقیقه مطالعه',
notFound: 'این مقاله پیدا نشد.',
backToBlog: 'بازگشت به بلاگ',
posts: [
{ tag: 'شروع کار', title: 'اولین اپلیکیشن خود را در چند دقیقه منتشر کن', desc: 'از اتصال ریپازیتوری تا دامنه و SSL خودکار — قدم‌به‌قدم.' },
{ tag: 'بهینه‌سازی هزینه', title: 'منابع را درست انتخاب کن و کمتر هزینه بده', desc: 'CPU، حافظه، رپلیکا و سرویس‌های جانبی را هوشمندانه تنظیم کن.' },