feat(dashboard): Jalali date pickers + mobile/tablet responsive shell
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m41s

Full Persian calendar:
- New JalaliDateField — Shamsi popover picker (Saturday-first weeks,
  Persian digits, امروز shortcut); wire format stays ISO Gregorian
  YYYY-MM-DD. Falls back to the native input for the en locale.
- Replaces all 5 native type="date" inputs (Gregorian-only pickers):
  reservations, expenses from/to, reports from/to.
- Reservations list date now renders Jalali instead of the raw ISO
  string; branches purge timestamp now formats with fa-IR.

Responsive shell (mobile + tablet):
- New MobileNav: hamburger in the topbar (< md) opening an RTL-aware
  slide-over drawer with all nav destinations, permission-filtered,
  Escape/backdrop close and body scroll lock.
- Desktop sidebar hidden below md; header center cluster (clock/plan)
  hidden below md; language switcher hidden below sm.
- Main content padding scales p-3 → p-4 → p-6.
- Verified at 375px and 768px: no horizontal overflow, drawer and
  Jalali picker fully functional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 23:10:38 +03:30
parent d811b7d6d5
commit 2a4cf1d20b
10 changed files with 439 additions and 53 deletions
@@ -37,7 +37,7 @@ export default function DashboardLayout({
const mainColumn = ( const mainColumn = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar /> <Topbar />
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background"> <main className="min-h-0 flex-1 overflow-auto bg-background p-3 sm:p-4 lg:p-6">
<RouteGuard>{children}</RouteGuard> <RouteGuard>{children}</RouteGuard>
</main> </main>
</div> </div>
@@ -191,7 +191,7 @@ export function BranchesScreen() {
</p> </p>
{b.scheduledPermanentDeleteAt ? ( {b.scheduledPermanentDeleteAt ? (
<p className="text-[10px] text-muted-foreground" dir="ltr"> <p className="text-[10px] text-muted-foreground" dir="ltr">
{new Date(b.scheduledPermanentDeleteAt).toLocaleString()} {new Date(b.scheduledPermanentDeleteAt).toLocaleString("fa-IR")}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -11,6 +11,7 @@ import { isoTodayTehran } from "@/lib/reports/analytics";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { JalaliDateField } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type Branch = { id: string; name: string }; type Branch = { id: string; name: string };
@@ -172,27 +173,10 @@ export function ExpensesScreen() {
</select> </select>
</LabeledField> </LabeledField>
<LabeledField label={t("fromDate")} htmlFor="exp-from"> <LabeledField label={t("fromDate")} htmlFor="exp-from">
<Input <JalaliDateField id="exp-from" className="w-40" value={from} onChange={setFrom} />
id="exp-from"
type="date"
dir="ltr"
className="w-40 text-end"
value={from}
max={to}
onChange={(e) => setFrom(e.target.value)}
/>
</LabeledField> </LabeledField>
<LabeledField label={t("toDate")} htmlFor="exp-to"> <LabeledField label={t("toDate")} htmlFor="exp-to">
<Input <JalaliDateField id="exp-to" className="w-40" value={to} onChange={setTo} />
id="exp-to"
type="date"
dir="ltr"
className="w-40 text-end"
value={to}
min={from}
max={today}
onChange={(e) => setTo(e.target.value)}
/>
</LabeledField> </LabeledField>
<div className="ms-auto text-end"> <div className="ms-auto text-end">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground"> <p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
@@ -50,7 +50,7 @@ export function HeaderCenterCluster() {
return ( return (
<div <div
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center" className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 items-center md:flex"
aria-live="polite" aria-live="polite"
> >
<div <div
@@ -0,0 +1,187 @@
"use client";
// Mobile/tablet navigation: hamburger button + slide-over drawer (< md screens).
// Shows the same destinations as the desktop sidebar — flat main items, then the
// collapsible groups rendered as plain titled sections (everything visible, the
// drawer itself scrolls), then the footer utility links.
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Menu, X } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { canSeeNavItem } from "@/lib/auth-permissions";
import { permissionsOf } from "@/lib/permissions";
import { FOOTER_NAV, NAV_GROUPS, type NavItemDef } from "@/lib/sidebar-nav";
import { useAuthStore } from "@/lib/stores/auth.store";
import { cn } from "@/lib/utils";
function DrawerLink({
item,
label,
active,
onNavigate,
}: {
item: NavItemDef;
label: string;
active: boolean;
onNavigate: () => void;
}) {
const Icon = item.icon;
return (
<Link
href={item.href}
onClick={onNavigate}
className={cn(
"flex min-h-[44px] items-center gap-3 rounded-xl px-3 text-sm transition-colors cursor-pointer",
active
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<Icon className={cn("h-5 w-5 shrink-0", active && "text-primary")} aria-hidden />
<span className="min-w-0 truncate">{label}</span>
</Link>
);
}
export function MobileNav() {
const t = useTranslations("nav");
const tGroups = useTranslations("nav.groups");
const tBrand = useTranslations("brand");
const locale = useLocale();
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const role = user?.role;
const branchId = user?.branchId ?? null;
const permissions = useMemo(() => permissionsOf(user), [user]);
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Close on Escape; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open]);
const close = () => setOpen(false);
const isRtl = locale !== "en";
const visible = (items: NavItemDef[]) =>
items.filter((item) => canSeeNavItem(item.key, role, branchId, permissions));
const isActive = (item: NavItemDef) =>
item.href === "/"
? pathname === "/"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={t("aria")}
className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
{mounted &&
open &&
createPortal(
<div className="fixed inset-0 z-50 md:hidden" dir={isRtl ? "rtl" : "ltr"}>
{/* Backdrop */}
<button
type="button"
aria-label={t("aria")}
onClick={close}
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
/>
{/* Panel */}
<div className="absolute inset-y-0 start-0 flex w-[280px] max-w-[85vw] flex-col bg-background shadow-2xl">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
<span className="text-sm font-bold tracking-tight">{tBrand("name")}</span>
<button
type="button"
onClick={close}
aria-label={t("collapseSidebar")}
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-5 w-5" aria-hidden />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3" aria-label={t("aria")}>
{NAV_GROUPS.map((group) => {
const items = visible(group.items);
if (items.length === 0) return null;
return (
<div key={group.id} className="mb-3">
{!group.flat && (
<p className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
{tGroups(group.id)}
</p>
)}
<div className="space-y-0.5">
{items.map((item) => (
<DrawerLink
key={item.key}
item={item}
label={t(item.key)}
active={isActive(item)}
onNavigate={close}
/>
))}
</div>
</div>
);
})}
{visible(FOOTER_NAV).length > 0 && (
<div className="border-t border-border/60 pt-2">
<div className="space-y-0.5">
{visible(FOOTER_NAV).map((item) => (
<DrawerLink
key={item.key}
item={item}
label={t(item.key)}
active={isActive(item)}
onNavigate={close}
/>
))}
</div>
</div>
)}
</nav>
{user && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-semibold text-primary">
{(user.actor ?? user.role).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{user.actor ?? user.userId}</p>
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
</div>
</div>
)}
</div>
</div>,
document.body
)}
</>
);
}
@@ -283,7 +283,8 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
return ( return (
<aside <aside
className={cn( className={cn(
"flex shrink-0 flex-col bg-background overflow-hidden", // Hidden below md — mobile/tablet use the MobileNav drawer instead.
"hidden shrink-0 flex-col bg-background overflow-hidden md:flex",
"transition-[width] duration-200 ease-in-out", "transition-[width] duration-200 ease-in-out",
collapsed ? "w-14" : "w-56", collapsed ? "w-14" : "w-56",
"border-border", "border-border",
+11 -3
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster"; import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
import { BranchSwitcher } from "@/components/layout/branch-switcher"; import { BranchSwitcher } from "@/components/layout/branch-switcher";
import { MobileNav } from "@/components/layout/mobile-nav";
import { NotificationCenter } from "@/components/notifications/notification-center"; import { NotificationCenter } from "@/components/notifications/notification-center";
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator"; import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
@@ -36,7 +37,10 @@ export function Topbar() {
}; };
return ( return (
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6"> <header className="relative flex h-14 items-center gap-2 border-b border-border bg-background px-2 sm:gap-3 sm:px-4 lg:px-6">
{/* Hamburger — mobile/tablet only */}
<MobileNav />
{/* Cafe name */} {/* Cafe name */}
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{showNameSkeleton ? ( {showNameSkeleton ? (
@@ -67,10 +71,14 @@ export function Topbar() {
<SyncStatusIndicator /> <SyncStatusIndicator />
<NotificationCenter /> <NotificationCenter />
{/* Language switcher */} {/* Language switcher — hidden on phones to save header space */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer"> <Button
variant="ghost"
size="sm"
className="hidden h-8 gap-1 px-2.5 text-xs cursor-pointer sm:inline-flex"
>
{t(`languages.${locale}`)} {t(`languages.${locale}`)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -25,6 +25,7 @@ import {
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { JalaliDateField } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -222,30 +223,19 @@ export function ReportsScreen() {
</div> </div>
<LabeledField label={t("fromDate")} htmlFor="report-from"> <LabeledField label={t("fromDate")} htmlFor="report-from">
<Input <JalaliDateField
id="report-from" id="report-from"
type="date" className="w-40"
dir="ltr"
className="w-40 text-end"
value={range.from} value={range.from}
max={range.to} onChange={(iso) => setRange((r) => ({ ...r, from: iso, preset: "custom" }))}
onChange={(e) =>
setRange((r) => ({ ...r, from: e.target.value, preset: "custom" }))
}
/> />
</LabeledField> </LabeledField>
<LabeledField label={t("toDate")} htmlFor="report-to"> <LabeledField label={t("toDate")} htmlFor="report-to">
<Input <JalaliDateField
id="report-to" id="report-to"
type="date" className="w-40"
dir="ltr"
className="w-40 text-end"
value={range.to} value={range.to}
min={range.from} onChange={(iso) => setRange((r) => ({ ...r, to: iso, preset: "custom" }))}
max={isoTodayTehran()}
onChange={(e) =>
setRange((r) => ({ ...r, to: e.target.value, preset: "custom" }))
}
/> />
</LabeledField> </LabeledField>
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
@@ -13,6 +13,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { JalaliDateField, formatIsoDateJalali } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -50,6 +51,7 @@ const statusStyle: Record<ReservationStatus, string> = {
export function ReservationsScreen() { export function ReservationsScreen() {
const t = useTranslations("reservations"); const t = useTranslations("reservations");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const locale = useLocale();
const apiError = useApiError(); const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -160,14 +162,7 @@ export function ReservationsScreen() {
</select> </select>
</LabeledField> </LabeledField>
<LabeledField label={t("date")} htmlFor="res-date"> <LabeledField label={t("date")} htmlFor="res-date">
<Input <JalaliDateField id="res-date" value={date} onChange={setDate} />
id="res-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField> </LabeledField>
<LabeledField label={t("time")} htmlFor="res-time"> <LabeledField label={t("time")} htmlFor="res-time">
<Input <Input
@@ -222,7 +217,7 @@ export function ReservationsScreen() {
<div> <div>
<p className="font-medium">{r.guestName}</p> <p className="font-medium">{r.guestName}</p>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{r.date} {r.time.slice(0, 5)} · {formatNumber(r.partySize)} {t("party")} {formatIsoDateJalali(r.date, locale)} {r.time.slice(0, 5)} · {formatNumber(r.partySize)} {t("party")}
{r.tableNumber ? ` · ${t("tableNumber", { number: r.tableNumber })}` : ""} {r.tableNumber ? ` · ${t("tableNumber", { number: r.tableNumber })}` : ""}
</p> </p>
<p className="text-[11px] text-muted-foreground">{r.guestPhone}</p> <p className="text-[11px] text-muted-foreground">{r.guestPhone}</p>
@@ -0,0 +1,221 @@
"use client";
// Jalali (Shamsi) date picker. The `value`/`onChange` wire format stays the ISO
// Gregorian `YYYY-MM-DD` the API expects — only the UI is Persian-calendar.
// For the `en` locale it falls back to the native Gregorian date input.
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocale } from "next-intl";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format as formatJalali,
isSameDay,
isSameMonth,
parseISO,
startOfMonth,
startOfWeek,
} from "date-fns-jalali";
import { faIR } from "date-fns-jalali/locale/fa-IR";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
function localizeDigits(s: string, locale: string): string {
if (locale === "en") return s;
const digits = locale === "ar" ? AR_DIGITS : FA_DIGITS;
return s.replace(/\d/g, (d) => digits[Number(d)]!);
}
/** Gregorian ISO date (YYYY-MM-DD) from a local Date — no UTC shift. */
function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate()
).padStart(2, "0")}`;
}
/** Saturday-first week, the Iranian convention. */
const WEEK_STARTS_ON = 6 as const;
const FA_WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
export function JalaliDateField({
id,
value,
onChange,
className,
placeholder,
}: {
id?: string;
/** ISO Gregorian date string (YYYY-MM-DD) or "". */
value: string;
onChange: (iso: string) => void;
className?: string;
placeholder?: string;
}) {
const locale = useLocale();
const [open, setOpen] = useState(false);
const selected = useMemo(() => (value ? parseISO(value) : null), [value]);
const [viewDate, setViewDate] = useState<Date>(() => selected ?? new Date());
const rootRef = useRef<HTMLDivElement>(null);
// Keep the visible month in sync when the value changes externally.
useEffect(() => {
if (selected) setViewDate(selected);
}, [selected]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
// English UI keeps the familiar native Gregorian picker.
if (locale === "en") {
return (
<Input
id={id}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
dir="ltr"
className={cn("text-end", className)}
/>
);
}
const days = eachDayOfInterval({
start: startOfWeek(startOfMonth(viewDate), { weekStartsOn: WEEK_STARTS_ON }),
end: endOfWeek(endOfMonth(viewDate), { weekStartsOn: WEEK_STARTS_ON }),
});
const today = new Date();
const label = selected
? localizeDigits(formatJalali(selected, "yyyy/MM/dd"), locale)
: (placeholder ?? "");
const monthTitle = localizeDigits(
formatJalali(viewDate, "MMMM yyyy", { locale: faIR }),
locale
);
return (
<div ref={rootRef} className="relative">
<button
id={id}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!selected && "text-muted-foreground",
className
)}
>
<span className="truncate">{label || "—"}</span>
<CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
{open && (
<div
role="dialog"
className="absolute z-50 mt-1 w-64 rounded-xl border border-border bg-popover p-2 shadow-lg start-0"
>
{/* Month header */}
<div className="mb-1 flex items-center justify-between">
<button
type="button"
onClick={() => setViewDate((d) => addMonths(d, -1))}
aria-label="ماه قبل"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronRight className="h-4 w-4 rtl:block ltr:hidden" aria-hidden />
<ChevronLeft className="h-4 w-4 rtl:hidden ltr:block" aria-hidden />
</button>
<span className="text-sm font-semibold">{monthTitle}</span>
<button
type="button"
onClick={() => setViewDate((d) => addMonths(d, 1))}
aria-label="ماه بعد"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronLeft className="h-4 w-4 rtl:block ltr:hidden" aria-hidden />
<ChevronRight className="h-4 w-4 rtl:hidden ltr:block" aria-hidden />
</button>
</div>
{/* Weekday header */}
<div className="grid grid-cols-7 text-center">
{FA_WEEKDAYS.map((w) => (
<span key={w} className="py-1 text-[10px] font-semibold text-muted-foreground">
{w}
</span>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{days.map((day) => {
const inMonth = isSameMonth(day, viewDate);
const isSelected = selected ? isSameDay(day, selected) : false;
const isToday = isSameDay(day, today);
return (
<button
key={day.getTime()}
type="button"
onClick={() => {
onChange(toIsoDate(day));
setOpen(false);
}}
className={cn(
"flex h-8 cursor-pointer items-center justify-center rounded-lg text-xs transition-colors",
inMonth ? "text-foreground" : "text-muted-foreground/40",
isSelected
? "bg-primary font-bold text-primary-foreground"
: isToday
? "border border-primary/50 font-semibold text-primary hover:bg-accent"
: "hover:bg-accent"
)}
>
{localizeDigits(formatJalali(day, "d"), locale)}
</button>
);
})}
</div>
{/* Today shortcut */}
<button
type="button"
onClick={() => {
onChange(toIsoDate(today));
setViewDate(today);
setOpen(false);
}}
className="mt-1 w-full cursor-pointer rounded-lg py-1.5 text-center text-xs font-medium text-primary transition-colors hover:bg-accent"
>
امروز
</button>
</div>
)}
</div>
);
}
/** Format an ISO Gregorian date string as a Jalali date for display. */
export function formatIsoDateJalali(iso: string, locale: string): string {
if (locale === "en") return iso;
try {
return localizeDigits(formatJalali(parseISO(iso), "yyyy/MM/dd"), locale);
} catch {
return iso;
}
}