بلاک‌ها · پنل مدیریت

تقویم رویدادها

پیش‌نمایش تمام‌صفحه

ماهِ شمسی (شنبه‌اول) با رویدادهای رنگیِ هر روز و «+n»، امروز و روزِ انتخاب‌شده متمایز، جابه‌جاییِ ماه، برنامهٔ روزِ انتخاب‌شده با عکسِ افراد و فهرستِ رویدادهای پیشِ رو.

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

این بلاک از ۶ آیتم دیگ استفاده می‌کند: avatarbuttoncardcontrol-sizeiconspersian

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

۲. کد صفحه

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

calendar-events.tsx
"use client"

import * as React from "react"

import { ChevronLeftIcon, ChevronRightIcon, ClockIcon, PlusIcon } from "@/components/icons"
import { Avatar, AvatarGroup } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { formatJalali, toFaDigits } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"

type Tone = "violet" | "blue" | "amber" | "green" | "pink"
type CalendarEvent = { id: string; dayOffset: number; title: string; time: string; tone: Tone; people?: (keyof typeof stockImages)[] }

/** رویدادها نسبت به امروز تعریف شده‌اند تا نمونه همیشه پر و واقعی دیده شود. */
const EVENTS: CalendarEvent[] = [
  { id: "e1", dayOffset: 0, title: "جلسهٔ هفتگی تیم", time: "۰۹:۳۰", tone: "violet", people: ["personMan1", "personWoman1", "personMan2"] },
  { id: "e2", dayOffset: 0, title: "دموی مشتری", time: "۱۴:۰۰", tone: "green", people: ["personWoman2"] },
  { id: "e3", dayOffset: 1, title: "بازبینی طراحی", time: "۱۱:۰۰", tone: "blue", people: ["personWoman1"] },
  { id: "e4", dayOffset: 3, title: "تحویلِ نسخهٔ ۲", time: "۱۷:۰۰", tone: "amber" },
  { id: "e5", dayOffset: 3, title: "مصاحبهٔ استخدام", time: "۱۰:۰۰", tone: "pink", people: ["personMan2"] },
  { id: "e6", dayOffset: 3, title: "تماس با سرمایه‌گذار", time: "۱۵:۳۰", tone: "violet" },
  { id: "e7", dayOffset: 6, title: "کارگاهِ پژوهشِ کاربر", time: "۰۹:۰۰", tone: "blue", people: ["personWoman3", "personMan1"] },
  { id: "e8", dayOffset: -2, title: "جمع‌بندی اسپرینت", time: "۱۶:۰۰", tone: "green" },
  { id: "e9", dayOffset: 9, title: "جشنِ تولدِ تیم", time: "۱۸:۰۰", tone: "pink" },
]

const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"]
const parts = new Intl.DateTimeFormat("en-u-ca-persian", { year: "numeric", month: "numeric", day: "numeric" })

/** سال، ماه و روزِ شمسی یک تاریخ (با ارقامِ لاتین برای محاسبه). */
function jalali(date: Date) {
  const values = Object.fromEntries(parts.formatToParts(date).map((part) => [part.type, Number(part.value)]))
  return { year: values.year, month: values.month, day: values.day }
}

function addDays(date: Date, days: number) {
  const next = new Date(date)
  next.setDate(next.getDate() + days)
  return next
}

/** روزهای ماهِ شمسی شاملِ تاریخ، با خانه‌های خالی شروعِ هفته (شنبه‌اول). */
function monthGrid(anchor: Date) {
  let start = new Date(anchor)
  while (jalali(start).day !== 1) start = addDays(start, -1)
  const month = jalali(start).month
  const days: Date[] = []
  for (let day = start; jalali(day).month === month; day = addDays(day, 1)) days.push(day)
  const lead = (start.getDay() + 1) % 7
  return { start, days, lead }
}

const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString()

/**
 * تقویمِ رویدادها: ماهِ شمسی با رویدادهای رنگی هر روز و «+n» برای بیشتر، امروز و
 * روزِ انتخاب‌شده متمایز؛ برنامهٔ روزِ انتخاب‌شده و رویدادهای پیشِ رو در ستونِ کنار.
 */
export default function CalendarEventsBlock() {
  const today = React.useMemo(() => {
    const now = new Date()
    now.setHours(0, 0, 0, 0)
    return now
  }, [])
  const [anchor, setAnchor] = React.useState(today)
  const [selected, setSelected] = React.useState(today)
  const { start, days, lead } = monthGrid(anchor)

  const eventsOn = (date: Date) => EVENTS.filter((event) => sameDay(addDays(today, event.dayOffset), date))
  const selectedEvents = eventsOn(selected)
  const upcoming = EVENTS.filter((event) => event.dayOffset > 0).sort((a, b) => a.dayOffset - b.dayOffset).slice(0, 4)
  const chip = (tone: Tone) => ({ background: `color-mix(in oklab, var(--dig-${tone}-500) 14%, var(--card))`, color: `var(--dig-${tone}-700)` })

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6">
        <header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
          <div className="flex items-center gap-2">
            <h1 className="min-w-40 text-2xl font-bold">{formatJalali(start, "long").replace(/^[۰-۹]+ /, "")}</h1>
            <Button variant="outline" size="icon" aria-label="ماهِ قبل" onClick={() => setAnchor(addDays(start, -1))}>
              <ChevronRightIcon />
            </Button>
            <Button variant="outline" size="icon" aria-label="ماهِ بعد" onClick={() => setAnchor(addDays(start, 32))}>
              <ChevronLeftIcon />
            </Button>
            <Button
              variant="ghost"
              onClick={() => {
                setAnchor(today)
                setSelected(today)
              }}
            >
              امروز
            </Button>
          </div>
          <Button>
            <PlusIcon />
            رویدادِ تازه
          </Button>
        </header>

        <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
          {/* شبکهٔ ماه: سرِ روزهای هفته و خانهٔ هر روز با تا دو رویداد. */}
          <div className="overflow-hidden rounded-xl border border-border">
            <div className="grid grid-cols-7 border-b border-border bg-muted text-center text-xs font-medium text-muted-foreground">
              {WEEKDAYS.map((day) => (
                <span key={day} className="py-3">
                  {day}
                </span>
              ))}
            </div>
            <div className="grid grid-cols-7" role="grid" aria-label="روزهای ماه">
              {Array.from({ length: lead }).map((_, index) => (
                <span key={`lead-${index}`} aria-hidden className="min-h-28 border-e border-b border-border bg-muted" />
              ))}
              {days.map((day) => {
                const events = eventsOn(day)
                const isToday = sameDay(day, today)
                const isSelected = sameDay(day, selected)
                return (
                  <div
                    key={day.toISOString()}
                    role="gridcell"
                    aria-selected={isSelected}
                    className={`flex min-h-28 flex-col gap-1.5 border-e border-b border-border p-2 [&:nth-child(7n)]:border-e-0 ${isSelected ? "bg-[color-mix(in_oklab,var(--brand-secondary)_8%,var(--background))]" : "bg-background"}`}
                  >
                    <Button
                      variant={isToday ? "default" : "ghost"}
                      size="icon"
                      aria-label={`${formatJalali(day, "long")}، ${toFaDigits(events.length)} رویداد`}
                      aria-pressed={isSelected}
                      onClick={() => setSelected(day)}
                      className={`tabular-nums ${isSelected && !isToday ? "font-bold text-brand-secondary" : ""}`}
                    >
                      {toFaDigits(jalali(day).day)}
                    </Button>
                    {events.slice(0, 2).map((event) => (
                      <span key={event.id} className="truncate rounded-md px-1.5 py-1 text-xs font-medium" style={chip(event.tone)}>
                        {event.title}
                      </span>
                    ))}
                    {events.length > 2 && <span className="px-1.5 text-xs text-muted-foreground">+{toFaDigits(events.length - 2)} رویدادِ دیگر</span>}
                  </div>
                )
              })}
            </div>
          </div>

          <div className="flex flex-col gap-6">
            <Card>
              <CardHeader>
                <CardTitle className="text-base">{sameDay(selected, today) ? "برنامهٔ امروز" : formatJalali(selected, "long")}</CardTitle>
              </CardHeader>
              <CardContent className="flex flex-col gap-3">
                {selectedEvents.length === 0 ? (
                  <p className="rounded-lg bg-muted px-4 py-6 text-center text-sm text-muted-foreground">برای این روز رویدادی نیست.</p>
                ) : (
                  selectedEvents.map((event) => (
                    <div key={event.id} className="flex gap-3 rounded-lg border border-border p-3">
                      <span aria-hidden className="w-1 shrink-0 rounded-full" style={{ background: `var(--dig-${event.tone}-500)` }} />
                      <div className="flex min-w-0 flex-1 flex-col gap-1">
                        <span className="text-sm font-semibold">{event.title}</span>
                        <span className="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
                          <ClockIcon aria-hidden className="size-3.5" />
                          {event.time}
                        </span>
                      </div>
                      {event.people && (
                        <AvatarGroup className="self-center">
                          {event.people.map((name) => (
                            <Avatar key={name} size="sm" alt={stockImages[name].alt} src={stockImages[name].src} className="ring-2 ring-card" />
                          ))}
                        </AvatarGroup>
                      )}
                    </div>
                  ))
                )}
              </CardContent>
            </Card>

            <Card>
              <CardHeader>
                <CardTitle className="text-base">پیشِ رو</CardTitle>
              </CardHeader>
              <CardContent>
                <ul className="flex flex-col divide-y divide-border">
                  {upcoming.map((event) => {
                    const date = addDays(today, event.dayOffset)
                    return (
                      <li key={event.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
                        <span className="flex w-12 shrink-0 flex-col items-center rounded-lg py-1.5" style={chip(event.tone)}>
                          <span className="text-lg leading-none font-bold tabular-nums">{toFaDigits(jalali(date).day)}</span>
                          <span className="text-xs">{formatJalali(date, "long").split(" ")[1]}</span>
                        </span>
                        <span className="flex min-w-0 flex-col">
                          <span className="truncate text-sm font-medium">{event.title}</span>
                          <span className="text-xs text-muted-foreground tabular-nums">{event.time}</span>
                        </span>
                      </li>
                    )
                  })}
                </ul>
              </CardContent>
            </Card>
          </div>
        </div>
      </main>
    </ControlSizeProvider>
  )
}