تقویم رویدادها
پیشنمایش تمامصفحهماهِ شمسی (شنبهاول) با رویدادهای رنگیِ هر روز و «+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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}