بلاک‌ها · موبایل

سلام و جست‌وجو و اعلان، دسته‌ها با چیپ‌های اسکرول‌خور، خبرِ ویژه با عکسِ تمام‌عرض و تیترِ سفید (اسکرولِ قفل‌شونده با نشانگر)، تازه‌ترین‌ها در ردیف‌های جمع‌وجور با ذخیره، بحثِ روز و ناوبریِ پایین.

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

این بلاک از ۷ آیتم دیگ استفاده می‌کند: avatarbottom-navigationbuttoncontrol-sizeiconspersiantoggle-group

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

۲. کد صفحه

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

mobile-news.tsx
"use client"

import * as React from "react"

import { BellIcon, ChatIcon, ClockIcon, DashboardIcon, HeartIcon, SearchIcon, ShareIcon, UserIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { BottomNavigation, BottomNavigationItem } from "@/components/ui/bottom-navigation"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

const CATEGORIES = ["برای تو", "اقتصاد", "فناوری", "سلامت", "ورزش", "فرهنگ"]

const FEATURED: { id: string; category: string; title: string; author: string; photo: string; minutes: number; image: StockImageName }[] = [
  { id: "f1", category: "اقتصاد", title: "بازارِ طلا در هفته‌ای که دلار آرام گرفت؛ حالا چه کنیم؟", author: "نگار احمدی", photo: stockImages.personWoman3.src, minutes: 4, image: "coverFinanceDesk" },
  { id: "f2", category: "فناوری", title: "اپ‌های بانکی چطور بی‌صدا امن‌تر شدند", author: "امیر رضایی", photo: stockImages.personMan1.src, minutes: 6, image: "projectMobileApp" },
  { id: "f3", category: "سلامت", title: "پیاده‌رویِ روزانهٔ سی دقیقه‌ای، از دیدِ پزشکان", author: "سارا محمدی", photo: stockImages.personWoman2.src, minutes: 3, image: "gym" },
]

const LATEST: { id: string; category: string; title: string; author: string; photo: string; hours: number; minutes: number; image: StockImageName }[] = [
  { id: "n1", category: "اقتصاد", title: "قسطِ وام‌های خرد از مهر کم می‌شود", author: "علی کاظمی", photo: stockImages.personMan2.src, hours: 1, minutes: 3, image: "coverCalculator" },
  { id: "n2", category: "فرهنگ", title: "کافه‌هایی که کتاب‌فروشی هم شدند", author: "نگار احمدی", photo: stockImages.personWoman3.src, hours: 3, minutes: 5, image: "restaurant" },
  { id: "n3", category: "فناوری", title: "داشبوردهای ساده‌تر، تصمیم‌های سریع‌تر", author: "امیر رضایی", photo: stockImages.personMan1.src, hours: 5, minutes: 4, image: "projectDashboard" },
  { id: "n4", category: "سلامت", title: "خوابِ کافی و تمرکز سرِ کار؛ پژوهشِ تازه", author: "سارا محمدی", photo: stockImages.personWoman2.src, hours: 8, minutes: 6, image: "studentStudy" },
]

/**
 * خانهٔ اپِ خبری: سلام و دکمه‌های جست‌وجو و اعلان، دسته‌ها با چیپ‌های اسکرول‌خور، خبرِ ویژه
 * با عکسِ تمام‌عرض و تیترِ سفید روی سایه‌روشن (اسکرولِ افقیِ قفل‌شونده با نشانگر)، و
 * «تازه‌ترین‌ها» در ردیف‌های جمع‌وجور با عکسِ کوچک، نویسنده، زمان و ذخیره. ناوبریِ پایین.
 */
export default function MobileNewsBlock() {
  const [category, setCategory] = React.useState(CATEGORIES[0])
  const [slide, setSlide] = React.useState(0)
  const [saved, setSaved] = React.useState<string[]>(["n2"])
  const [tab, setTab] = React.useState("home")
  const latest = category === CATEGORIES[0] ? LATEST : LATEST.filter((item) => item.category === category)

  return (
    <ControlSizeProvider size="xl" asChild>
      <div className="mx-auto flex min-h-svh w-full max-w-md flex-col bg-background">
        <header className="flex items-center gap-3 px-4 pt-4 pb-2">
          <Avatar alt="آرمیتا رستگار" src={stockImages.personWoman1.src} className="size-11" />
          <span className="flex flex-1 flex-col">
            <span className="text-xs text-muted-foreground">صبح بخیر</span>
            <span className="font-bold">آرمیتا رستگار</span>
          </span>
          <Button variant="outline" size="icon" aria-label="جست‌وجو" className="rounded-full">
            <SearchIcon />
          </Button>
          <Button variant="outline" size="icon" aria-label="اعلان‌ها، ۳ خوانده‌نشده" className="relative rounded-full">
            <BellIcon />
            <span aria-hidden className="absolute end-2.5 top-2.5 size-2 rounded-full bg-[var(--dig-orange-500)] ring-2 ring-background" />
          </Button>
        </header>

        <main className="flex flex-1 flex-col gap-6 pb-6">
          <h1 className="px-4 text-2xl leading-snug font-black">تازه‌ترین خبرها</h1>

          {/* دسته‌ها: یک انتخاب، اسکرولِ افقی؛ تا لبهٔ صفحه می‌رود تا ادامه‌دار بودن پیدا باشد. */}
          <div className="overflow-x-auto px-4 [scrollbar-width:none]">
            <ToggleGroup type="single" variant="outline" separated value={category} onValueChange={(value) => value && setCategory(value as string)} aria-label="دسته‌ها" className="w-max gap-2">
              {CATEGORIES.map((item) => (
                <ToggleGroupItem key={item} value={item} className="rounded-full! border-transparent bg-muted px-4 font-semibold shadow-none data-[state=on]:bg-foreground data-[state=on]:text-background">
                  {item}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </div>

          {/* خبرِ ویژه: کارت‌های تمام‌عرض با اسکرولِ قفل‌شونده؛ لبهٔ کارتِ بعدی پیداست. */}
          <section aria-label="خبرهای ویژه" className="flex flex-col gap-3">
            <div
              className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-4 px-4 [scrollbar-width:none]"
              onScroll={(event) => {
                const element = event.currentTarget
                setSlide(Math.round(Math.abs(element.scrollLeft) / (element.clientWidth * 0.86)))
              }}
            >
              {FEATURED.map((item) => (
                <article key={item.id} className="relative aspect-[4/5] w-[86%] shrink-0 snap-start overflow-hidden rounded-xl bg-muted">
                  {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
                  <img src={stockImages[item.image].src} alt="" className="absolute inset-0 size-full object-cover" />
                  <span aria-hidden className="absolute inset-0 bg-linear-to-t from-[var(--dig-slate-950)] via-[color-mix(in_oklab,var(--dig-slate-950)_40%,transparent)] via-45% to-transparent" />
                  <span className="absolute start-4 top-4 rounded-full bg-[var(--dig-white)] px-3 py-1 text-xs font-bold text-[var(--dig-slate-900)]">{item.category}</span>
                  <div className="absolute inset-x-0 bottom-0 flex flex-col gap-3 p-4 text-[var(--dig-white)]">
                    <h2 className="text-xl leading-snug font-bold text-balance">
                      <a href="#" className="after:absolute after:inset-0">
                        {item.title}
                      </a>
                    </h2>
                    <span className="flex items-center gap-2 text-xs text-[var(--dig-slate-200)]">
                      <Avatar size="sm" alt="" src={item.photo} />
                      {item.author}
                      <span aria-hidden>·</span>
                      {toFaDigits(item.minutes)} دقیقه مطالعه
                    </span>
                  </div>
                </article>
              ))}
            </div>
            <div aria-hidden className="flex justify-center gap-1.5">
              {FEATURED.map((item, index) => (
                <span key={item.id} className={`h-1.5 rounded-full transition-all ${index === slide ? "w-5 bg-foreground" : "w-1.5 bg-input"}`} />
              ))}
            </div>
          </section>

          <section aria-labelledby="latest-title" className="flex flex-col gap-2 px-4">
            <div className="flex items-center justify-between">
              <h2 id="latest-title" className="text-lg font-bold">
                تازه‌ترین‌ها
              </h2>
              <Button variant="ghost" className="-me-3 text-sm">
                همه
              </Button>
            </div>
            {latest.length === 0 ? (
              <p className="rounded-xl bg-subtle p-6 text-center text-sm text-muted-foreground">در این دسته فعلاً خبرِ تازه‌ای نیست.</p>
            ) : (
              <ul className="flex flex-col divide-y divide-border">
                {latest.map((item) => {
                  const isSaved = saved.includes(item.id)
                  return (
                    <li key={item.id} className="relative flex gap-3 py-3">
                      {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
                      <img src={stockImages[item.image].src} alt="" loading="lazy" className="size-20 shrink-0 rounded-lg object-cover" />
                      <div className="flex min-w-0 flex-1 flex-col gap-1">
                        <span className="text-xs font-semibold text-brand-secondary">{item.category}</span>
                        <h3 className="line-clamp-2 text-[0.9375rem] leading-6 font-bold">
                          <a href="#" className="after:absolute after:inset-0">
                            {item.title}
                          </a>
                        </h3>
                        <span className="flex items-center gap-1.5 text-xs text-muted-foreground [&>svg]:size-3.5">
                          <ClockIcon aria-hidden />
                          {toFaDigits(item.hours)} ساعت پیش
                          <span aria-hidden>·</span>
                          {toFaDigits(item.minutes)} دقیقه
                        </span>
                      </div>
                      <ControlSizeProvider size="lg" asChild>
                        <Button
                          variant="ghost"
                          size="icon"
                          aria-pressed={isSaved}
                          aria-label={isSaved ? `برداشتنِ «${item.title}» از ذخیره‌ها` : `ذخیرهٔ «${item.title}»`}
                          onClick={() => setSaved((list) => (isSaved ? list.filter((id) => id !== item.id) : [...list, item.id]))}
                          className={`relative z-10 -me-2 shrink-0 self-start ${isSaved ? "text-[var(--dig-pink-600)] hover:text-[var(--dig-pink-600)]" : ""}`}
                        >
                          <HeartIcon filled={isSaved} />
                        </Button>
                      </ControlSizeProvider>
                    </li>
                  )
                })}
              </ul>
            )}
          </section>

          {/* بحثِ روز: نقل‌قول با شمارِ واکنش‌ها، تنوعی میانِ فهرستِ عکس‌دار. */}
          <section aria-label="بحثِ روز" className="mx-4 flex flex-col gap-3 rounded-xl bg-[color-mix(in_oklab,var(--dig-violet-500)_8%,var(--card))] p-4">
            <span className="text-xs font-bold text-[var(--dig-violet-700)] dark:text-[var(--dig-violet-300)]">بحثِ روز</span>
            <p className="leading-7 font-semibold">«پس‌اندازِ کوچک ولی منظم، از سرمایه‌گذاریِ بزرگِ یک‌باره بهتر جواب می‌دهد.»</p>
            <div className="flex items-center gap-4 text-sm text-muted-foreground [&_svg]:size-4">
              <span className="flex items-center gap-1.5">
                <HeartIcon aria-hidden />
                {toFaDigits("۱٫۲")} هزار
              </span>
              <span className="flex items-center gap-1.5">
                <ChatIcon aria-hidden />
                {toFaDigits(86)} نظر
              </span>
              <Button variant="ghost" size="icon" aria-label="اشتراک‌گذاری" className="ms-auto -me-2">
                <ShareIcon />
              </Button>
            </div>
          </section>
        </main>

        <BottomNavigation value={tab} onValueChange={setTab} indicator="pill" fixed={false} className="sticky bottom-0">
          <BottomNavigationItem value="home" icon={<DashboardIcon />} label="خانه" />
          <BottomNavigationItem value="explore" icon={<SearchIcon />} label="کاوش" />
          <BottomNavigationItem value="saved" icon={<HeartIcon />} label="ذخیره‌ها" />
          <BottomNavigationItem value="profile" icon={<UserIcon />} label="پروفایل" />
        </BottomNavigation>
      </div>
    </ControlSizeProvider>
  )
}