بلاک‌ها · پشتیبانی و محتوا

معرفیِ وسط‌چین، مقالهٔ ویژهٔ افقی، فیلترِ دسته با جست‌وجو، گریدِ کارتِ مقاله با نویسنده و تاریخ، حالتِ خالی و خبرنامهٔ رنگی.

۱. نصب کامپوننت‌ها

این بلاک از ۱۲ آیتم دیگ استفاده می‌کند: article-cardavatarbadgebuttoncontrol-sizeempty-stateiconsinputpersiansearch-fieldtoasttoggle-group

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/article-card.json https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/article-card.json https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/article-card.json https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/article-card.json https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

blog.tsx
"use client"

import * as React from "react"

import { AvatarGenderIcon } from "@/components/icons"
import {
  ArticleCard,
  ArticleCardBody,
  ArticleCardCategory,
  ArticleCardExcerpt,
  ArticleCardGrid,
  ArticleCardMedia,
  ArticleCardMeta,
  ArticleCardTitle,
} from "@/components/ui/article-card"
import { Avatar } from "@/components/ui/avatar"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { EmptyState, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
import { SearchField } from "@/components/ui/search-field"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatJalali, isValidEmail, toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Post = {
  id: string
  category: string
  title: string
  excerpt: string
  author: string
  gender: "male" | "female"
  date: string
  minutes: number
  cover: StockImageName
}

// عکسِ هر نویسنده از کتابخانهٔ تصویر؛ آیکونِ جنسیت فقط وقتی عکس نرسید.
const AUTHOR_PHOTOS: Record<string, StockImageName> = {
  "سارا محمدی": "personWoman2",
  "آرش نوری": "personMan1",
  "مهسا کریمی": "personWoman1",
  "نیما رضایی": "personMan2",
  "لیلا احمدی": "personWoman3",
}

const CATEGORIES = ["همه", "محصول", "مالی شخصی", "اخبار", "تجربهٔ مشتری"]

const FEATURED: Post = {
  id: "f",
  category: "محصول",
  title: "تازه‌های حساب‌یار: ابزارهای هوشمندتر برای پولِ هوشمندتر",
  excerpt: "گزارشِ خودکارِ ماهانه، دسته‌بندی هوشمندِ هزینه‌ها و یادآورِ قسط؛ همه در یک به‌روزرسانی. این‌جا توضیح داده‌ایم هر کدام چطور وقتتان را آزاد می‌کند.",
  author: "سارا محمدی",
  gender: "female",
  date: "2026-09-28",
  minutes: 7,
  cover: "coverMinimalRoom",
}

const POSTS: Post[] = [
  { id: "1", category: "مالی شخصی", title: "قانونِ ۵۰/۳۰/۲۰ برای بودجه‌بندی ماهانه", excerpt: "یک روشِ ساده که بدونِ اکسل هم کار می‌کند؛ از حقوقِ این ماه شروع کنید.", author: "آرش نوری", gender: "male", date: "2026-09-24", minutes: 5, cover: "coverCalculator" },
  { id: "2", category: "محصول", title: "دسته‌بندی خودکارِ هزینه‌ها چطور کار می‌کند؟", excerpt: "پشتِ صحنهٔ قابلیتی که هر تراکنش را خودش در دستهٔ درست می‌گذارد.", author: "مهسا کریمی", gender: "female", date: "2026-09-22", minutes: 4, cover: "coverWorkspace" },
  { id: "3", category: "اخبار", title: "مالیاتِ کسب‌وکارهای آنلاین در سالِ جدید", excerpt: "هر چه دربارهٔ تغییراتِ تازه باید بدانید، به زبانِ ساده و با مثال.", author: "نیما رضایی", gender: "male", date: "2026-09-20", minutes: 8, cover: "coverOfficeSupplies" },
  { id: "4", category: "تجربهٔ مشتری", title: "کافه رست چطور هزینه‌هایش را ۲۰٪ کم کرد", excerpt: "روایتِ شش ماه استفاده از گزارش‌ها در یک کافهٔ زنجیره‌ای.", author: "لیلا احمدی", gender: "female", date: "2026-09-18", minutes: 6, cover: "coverNotebook" },
  { id: "5", category: "مالی شخصی", title: "پس‌اندازِ اضطراری: چقدر کافی است؟", excerpt: "عددِ درست برای شما به سه چیز بستگی دارد؛ با این حساب پیدایش کنید.", author: "آرش نوری", gender: "male", date: "2026-09-15", minutes: 5, cover: "coverFinanceDesk" },
  { id: "6", category: "اخبار", title: "کارمزدِ جدیدِ کارت‌به‌کارت از مهر", excerpt: "جدولِ کارمزدها و راه‌هایی که کمتر بپردازید.", author: "نیما رضایی", gender: "male", date: "2026-09-12", minutes: 3, cover: "coverMinimalRoom" },
]

function PostMeta({ post }: { post: Post }) {
  return (
    <ArticleCardMeta>
      <span className="flex items-center gap-2">
        <Avatar size="sm" alt={post.author} src={AUTHOR_PHOTOS[post.author] && stockImages[AUTHOR_PHOTOS[post.author]].src} icon={<AvatarGenderIcon gender={post.gender} />} />
        <span className="font-medium text-foreground">{post.author}</span>
      </span>
      <span>
        {formatJalali(new Date(post.date), "short")} · {toFaDigits(post.minutes)} دقیقه
      </span>
    </ArticleCardMeta>
  )
}

/** صفحهٔ بلاگ: معرفی، مقالهٔ ویژه، فیلترِ دسته و جست‌وجو، گریدِ مقاله‌ها و خبرنامه. */
export default function BlogBlock() {
  const [category, setCategory] = React.useState("همه")
  const [query, setQuery] = React.useState("")
  const [email, setEmail] = React.useState("")
  const [emailError, setEmailError] = React.useState("")

  const visible = POSTS.filter(
    (post) => (category === "همه" || post.category === category) && toSearchKey(post.title + post.excerpt).includes(toSearchKey(query))
  )

  const subscribe = (event: React.FormEvent) => {
    event.preventDefault()
    if (!isValidEmail(email)) {
      setEmailError("یک ایمیلِ درست وارد کنید.")
      return
    }
    setEmailError("")
    setEmail("")
    toast.success("عضو خبرنامه شدید؛ اولین نامه پنجشنبه می‌رسد.")
  }

  return (
    <ControlSizeProvider size="xl" asChild>
    <main className="mx-auto flex w-full max-w-6xl flex-col gap-24 px-4 py-16 sm:px-6">
      {/* معرفی: برچسب، تیتر و توضیح، وسط‌چین. */}
      <header className="mx-auto flex max-w-2xl flex-col items-center gap-4 text-center">
        <Badge variant="soft">مجلهٔ حساب‌یار</Badge>
        <h1 className="text-4xl leading-snug font-black text-balance sm:text-5xl sm:leading-snug">ایده‌ها و روندهایی که آیندهٔ پول را می‌سازند</h1>
        <p className="text-lg leading-8 text-muted-foreground">تازه‌های محصول، راهنمای مالی شخصی و تجربهٔ کسب‌وکارهایی که با ما رشد کرده‌اند.</p>
      </header>

      <ArticleCard orientation="horizontal" size="lg">
        <ArticleCardMedia className="sm:w-1/2">
          {/* eslint-disable-next-line @next/next/no-img-element -- کاورِ تزئینی ثابت */}
          <img src={stockImages[FEATURED.cover].src} alt="" />
        </ArticleCardMedia>
        <ArticleCardBody className="gap-3">
          <ArticleCardCategory>{FEATURED.category}</ArticleCardCategory>
          <ArticleCardTitle href="#">{FEATURED.title}</ArticleCardTitle>
          <ArticleCardExcerpt className="line-clamp-3">{FEATURED.excerpt}</ArticleCardExcerpt>
          <PostMeta post={FEATURED} />
        </ArticleCardBody>
      </ArticleCard>

      <section aria-labelledby="blog-latest" className="flex flex-col gap-8">
        <div className="flex flex-col items-center gap-3 text-center">
          <Badge variant="soft">مقاله‌ها</Badge>
          <h2 id="blog-latest" className="text-3xl font-black sm:text-4xl">
            تازه‌ترین مقاله‌ها
          </h2>
        </div>

        {/* نوارِ ابزار بیرون از گرید؛ فیلتر و جست‌وجو هم‌ردیف، با فاصلهٔ ۳۲ تا کارت‌ها. */}
        <div className="flex flex-wrap items-center justify-between gap-3">
          <div className="max-w-full overflow-x-auto">
            <ToggleGroup type="single" variant="segmented" value={category} onValueChange={(value) => value && setCategory(value as string)} aria-label="دسته‌بندی">
              {CATEGORIES.map((item) => (
                <ToggleGroupItem key={item} value={item} className="whitespace-nowrap">
                  {item}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </div>
          <SearchField aria-label="جست‌وجو در مقاله‌ها" placeholder="جست‌وجوی مقاله یا کلیدواژه" value={query} onValueChange={setQuery} className="w-full sm:max-w-xs" />
        </div>

        {visible.length === 0 ? (
          <EmptyState variant="bordered">
            <EmptyStateTitle>مقاله‌ای پیدا نشد</EmptyStateTitle>
            <EmptyStateDescription>کلیدواژهٔ دیگری امتحان کنید یا دسته را روی «همه» بگذارید.</EmptyStateDescription>
          </EmptyState>
        ) : (
          <ArticleCardGrid>
            {visible.map((post) => (
              <ArticleCard key={post.id}>
                <ArticleCardMedia>
                  {/* eslint-disable-next-line @next/next/no-img-element -- کاورِ تزئینی ثابت */}
                  <img src={stockImages[post.cover].src} alt="" loading="lazy" />
                </ArticleCardMedia>
                <ArticleCardBody>
                  <ArticleCardCategory>{post.category}</ArticleCardCategory>
                  <ArticleCardTitle href="#">{post.title}</ArticleCardTitle>
                  <ArticleCardExcerpt>{post.excerpt}</ArticleCardExcerpt>
                  <PostMeta post={post} />
                </ArticleCardBody>
              </ArticleCard>
            ))}
          </ArticleCardGrid>
        )}

        <Button variant="outline" className="mt-4 self-center">
          مقاله‌های بیشتر
        </Button>
      </section>

      {/* خبرنامه: کارتِ رنگی جسور با فرم؛ رنگ‌ها با بازتعریفِ متغیرهای پایه. */}
      <section
        aria-labelledby="blog-newsletter"
        className="relative overflow-hidden rounded-xl bg-[var(--dig-violet-700)] p-8 text-[var(--dig-white)] sm:p-14 [--muted-foreground:var(--dig-violet-100)] [--primary-foreground:var(--dig-violet-800)] [--primary:var(--dig-white)]"
      >
        <span aria-hidden className="absolute -end-24 -bottom-24 size-80 rounded-full bg-[var(--dig-violet-600)]" />
        <span aria-hidden className="absolute -end-4 -bottom-40 size-80 rounded-full bg-[var(--dig-violet-500)]" />
        <div className="relative flex max-w-xl flex-col gap-4">
          <h2 id="blog-newsletter" className="text-3xl leading-snug font-black sm:text-4xl">
            هر پنجشنبه، یک نامهٔ کوتاه
          </h2>
          <p className="leading-8 text-muted-foreground">بهترین مقاله‌های هفته و یک نکتهٔ مالی؛ بدون تبلیغ، هر وقت خواستید لغو کنید.</p>
          <form onSubmit={subscribe} noValidate className="mt-4 flex flex-col gap-2">
            <div className="flex flex-col gap-3 sm:flex-row">
              <Input
                type="email"
                dir="ltr"
                aria-label="ایمیل"
                placeholder="name@example.com"
                value={email}
                onChange={(event) => setEmail(event.target.value)}
                aria-invalid={emailError ? true : undefined}
                aria-describedby={emailError ? "blog-newsletter-error" : undefined}
                className="flex-1 bg-[var(--dig-white)] text-[var(--dig-graphite-900)]"
              />
              <Button type="submit">عضویت</Button>
            </div>
            {emailError && (
              <p id="blog-newsletter-error" role="alert" className="text-sm font-medium text-[var(--dig-white)]">
                {emailError}
              </p>
            )}
          </form>
        </div>
      </section>
    </main>
    </ControlSizeProvider>
  )
}