feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)

* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
This commit is contained in:
Hamed Fallah
2026-07-08 15:42:42 +02:00
committed by GitHub
parent 3d36492518
commit e10fced28a
31 changed files with 798 additions and 118 deletions
+26 -27
View File
@@ -1,11 +1,8 @@
"use client"; "use client";
import { useMemo, useState, useCallback, type DragEvent } from "react"; import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations } from "next-intl";
import { import { format, parseISO } from "date-fns";
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
} from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EventCard } from "./event-card"; import { EventCard } from "./event-card";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarMonthViewProps { interface CalendarMonthViewProps {
selectedDate: Date; selectedDate: Date;
@@ -47,16 +45,21 @@ export function CalendarMonthView({
pendingPreview, pendingPreview,
}: CalendarMonthViewProps) { }: CalendarMonthViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const {
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatFullDate,
} = useCalendarLocale();
const days = useMemo(() => { const days = useMemo(
const monthStart = startOfMonth(selectedDate); () => getMonthGridDays(selectedDate),
const monthEnd = endOfMonth(selectedDate); [selectedDate, getMonthGridDays],
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); );
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [selectedDate, weekStart]);
const calendarMap = useMemo(() => { const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>(); const map = new Map<string, Calendar>();
@@ -83,10 +86,6 @@ export function CalendarMonthView({
return map; return map;
}, [events]); }, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const weeks = useMemo(() => { const weeks = useMemo(() => {
const result: Date[][] = []; const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) { for (let i = 0; i < days.length; i += 7) {
@@ -141,9 +140,9 @@ export function CalendarMonthView({
}, [t]); }, [t]);
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}> <div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
<div className="grid grid-cols-7 border-b border-border" role="row"> <div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => ( {dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn( <div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0", "text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
isMobile && "py-1.5 text-[11px]" isMobile && "py-1.5 text-[11px]"
@@ -161,12 +160,12 @@ export function CalendarMonthView({
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}> )} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
<div className="grid grid-cols-7 h-full"> <div className="grid grid-cols-7 h-full">
{week.map((day) => { {week.map((day) => {
const inMonth = isSameMonth(day, selectedDate); const inMonth = checkIsSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate); const selected = checkIsSameDay(day, selectedDate);
const today = isToday(day); const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || []; const dayEvents = eventsByDate.get(key) || [];
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); const fullDateLabel = formatFullDate(day);
return ( return (
<div <div
@@ -199,7 +198,7 @@ export function CalendarMonthView({
inMonth && !selected && !today && "font-medium" inMonth && !selected && !today && "font-medium"
)} )}
> >
{format(day, "d")} {formatDayNumber(day)}
</span> </span>
</div> </div>
{isMobile ? ( {isMobile ? (
@@ -219,7 +218,7 @@ export function CalendarMonthView({
{dayEvents.length > 3 && ( {dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" /> <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)} )}
{pendingPreview && isSameDay(pendingPreview.start, day) && ( {pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
<span <span
className="w-1.5 h-1.5 rounded-full border border-dashed" className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }} style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
@@ -233,7 +232,7 @@ export function CalendarMonthView({
</div> </div>
{!isMobile && pendingPreview && (() => { {!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start)); const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null; if (previewDayIdx === -1) return null;
const previewRow = rowCount; const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId); const cal = calendarMap.get(pendingPreview.calendarId);
+21 -19
View File
@@ -1,13 +1,14 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns"; import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarToolbarProps { interface CalendarToolbarProps {
selectedDate: Date; selectedDate: Date;
@@ -50,7 +51,14 @@ export function CalendarToolbar({
onMenuClick, onMenuClick,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const {
weekStartsOn,
formatMonthYear,
formatMonthYearShort,
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"] ? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"]; : ["month", "week", "day", "agenda"];
@@ -72,28 +80,22 @@ export function CalendarToolbar({
switch (viewMode) { switch (viewMode) {
case "month": case "month":
return isMobile return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) ? formatMonthYearShort(selectedDate)
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); : formatMonthYear(selectedDate);
case "week": { case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); const ws = startOfWeek(selectedDate, { weekStartsOn });
const we = addDays(ws, 6); return isMobile
if (isMobile) { ? formatWeekRangeShort(ws)
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`; : formatWeekRange(ws);
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
}
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
} }
case "day": case "day":
return isMobile return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) ? formatFullDate(selectedDate)
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); : formatFullDate(selectedDate);
case "agenda": case "agenda":
return isMobile return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) ? formatMonthYearShort(selectedDate)
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); : formatMonthYear(selectedDate);
case "tasks": case "tasks":
return t("views.tasks"); return t("views.tasks");
} }
+1 -1
View File
@@ -58,7 +58,7 @@ export function CalendarWeekView({
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const weekDays = useMemo(() => { const weekDays = useMemo(() => {
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
+30 -32
View File
@@ -1,25 +1,19 @@
"use client"; "use client";
import { useState, useMemo, Fragment } from "react"; import { useState, useMemo, Fragment } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear, addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek, getISOWeek, getWeek, format,
isSameDay, isSameMonth, isToday, format,
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils"; import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
type PickerView = "days" | "months" | "years"; type PickerView = "days" | "months" | "years";
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
interface MiniCalendarProps { interface MiniCalendarProps {
selectedDate: Date; selectedDate: Date;
displayMonth: Date; displayMonth: Date;
@@ -40,17 +34,25 @@ export function MiniCalendar({
showWeekNumbers = false, showWeekNumbers = false,
}: MiniCalendarProps) { }: MiniCalendarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const {
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const [pickerView, setPickerView] = useState<PickerView>("days"); const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(() => { const days = useMemo(
const monthStart = startOfMonth(displayMonth); () => getMonthGridDays(displayMonth),
const monthEnd = endOfMonth(displayMonth); [displayMonth, getMonthGridDays],
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); );
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [displayMonth, weekStart]);
const eventDates = useMemo(() => { const eventDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -67,20 +69,16 @@ export function MiniCalendar({
return set; return set;
}, [events]); }, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
// Compute week numbers for each row (one per 7-day chunk) // Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => { const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return []; if (!showWeekNumbers) return [];
const nums: number[] = []; const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) { for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number // Use the first day of each row to determine the week number
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 })); nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
} }
return nums; return nums;
}, [days, showWeekNumbers, weekStart]); }, [days, showWeekNumbers, weekStartsOn]);
const currentYear = getYear(displayMonth); const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth); const currentMonth = getMonth(displayMonth);
@@ -116,7 +114,7 @@ export function MiniCalendar({
const headerLabel = const headerLabel =
pickerView === "days" pickerView === "days"
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" }) ? formatMonthYear(displayMonth)
: pickerView === "months" : pickerView === "months"
? String(currentYear) ? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`; : `${decadeStart}\u2013${decadeStart + 9}`;
@@ -160,15 +158,15 @@ export function MiniCalendar({
{showWeekNumbers && ( {showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" /> <div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)} )}
{dayHeaders.map((d) => ( {dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1"> <div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)} {t(`days.${d}`)}
</div> </div>
))} ))}
{days.map((day, index) => { {days.map((day, index) => {
const inMonth = isSameMonth(day, displayMonth); const inMonth = checkIsSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate); const selected = checkIsSameDay(day, selectedDate);
const today = isToday(day); const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd")); const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0; const isFirstDayOfRow = index % 7 === 0;
@@ -193,7 +191,7 @@ export function MiniCalendar({
selected && "bg-primary text-primary-foreground" selected && "bg-primary text-primary-foreground"
)} )}
> >
{format(day, "d")} {formatDayNumber(day)}
{hasEvent && !selected && ( {hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" /> <span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)} )}
@@ -206,7 +204,7 @@ export function MiniCalendar({
{pickerView === "months" && ( {pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1"> <div className="grid grid-cols-3 gap-1 py-1">
{MONTH_LABELS.map((label, i) => { {monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date()); const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate); const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return ( return (
@@ -220,7 +218,7 @@ export function MiniCalendar({
!isSelected && !isCurrentMonth && "hover:bg-muted" !isSelected && !isCurrentMonth && "hover:bg-muted"
)} )}
> >
{label} {t(`months.${labelKey}`)}
</button> </button>
); );
})} })}
@@ -104,6 +104,7 @@ export function LanguageSettings() {
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)} onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
options={[ options={[
{ value: '1', label: tDays('monday') }, { value: '1', label: tDays('monday') },
{ value: '6', label: tDays('saturday') },
{ value: '0', label: tDays('sunday') }, { value: '0', label: tDays('sunday') },
]} ]}
/> />
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useMemo } from "react";
import { useLocale } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import {
toJalali,
jalaliMonthLength,
startOfJalaliMonth,
endOfJalaliMonth,
eachDayOfJalaliMonth,
getDayHeaderKeys,
shouldUseJalaliCalendar,
JALALI_MONTHS,
type JalaliDate,
} from "@/lib/jalali-utils";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameDay,
isSameMonth,
isToday,
} from "date-fns";
/**
* Unified calendar-locale hook.
*
* Abstracts away the differences between Gregorian and Jalali calendars so
* view components can render dates correctly without calendar-specific
* branching.
*/
export function useCalendarLocale() {
const locale = useLocale();
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
const isJalali = shouldUseJalaliCalendar(locale);
// Normalize weekStart for date-fns (0 | 1 | 2 | 3 | 4 | 5 | 6)
const weekStartsOn = useMemo(() => {
if (firstDayOfWeek === 0) return 0 as const;
if (firstDayOfWeek === 6) return 6 as const;
return 1 as const;
}, [firstDayOfWeek]);
// Ordered day-header translation keys
const dayHeaderKeys = useMemo(
() => getDayHeaderKeys(weekStartsOn),
[weekStartsOn],
);
// ------------------------------------------------------------------
// Month-grid construction
// ------------------------------------------------------------------
/** Build the flat array of Dates that populate a full month grid. */
const getMonthGridDays = (referenceDate: Date): Date[] => {
if (isJalali) {
const { jy, jm } = toJalali(referenceDate);
return eachDayOfJalaliMonth(jy, jm, weekStartsOn);
}
const monthStart = startOfMonth(referenceDate);
const monthEnd = endOfMonth(referenceDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
};
// ------------------------------------------------------------------
// Day-level queries
// ------------------------------------------------------------------
/** Is the given date "today" in the active calendar system? */
const checkIsToday = (date: Date): boolean => {
if (isJalali) {
const now = toJalali(new Date());
const target = toJalali(date);
return now.jy === target.jy && now.jm === target.jm && now.jd === target.jd;
}
return isToday(date);
};
/** Does the date belong to the same month as the reference date? */
const checkIsSameMonth = (date: Date, referenceDate: Date): boolean => {
if (isJalali) {
const a = toJalali(date);
const b = toJalali(referenceDate);
return a.jy === b.jy && a.jm === b.jm;
}
return isSameMonth(date, referenceDate);
};
/** Are two dates the same calendar day? */
const checkIsSameDay = (date1: Date, date2: Date): boolean => {
if (isJalali) {
const a = toJalali(date1);
const b = toJalali(date2);
return a.jy === b.jy && a.jm === b.jm && a.jd === b.jd;
}
return isSameDay(date1, date2);
};
// ------------------------------------------------------------------
// Display formatting
// ------------------------------------------------------------------
/** Day-of-month number for a calendar cell (string). */
const formatDayNumber = (date: Date): string => {
if (isJalali) {
return String(toJalali(date).jd);
}
return String(date.getDate());
};
/** Full month + year label for the toolbar / mini-calendar header. */
const formatMonthYear = (date: Date): string => {
if (isJalali) {
const { jy, jm } = toJalali(date);
return `${JALALI_MONTHS[jm - 1]} ${jy}`;
}
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "long",
});
return `${month} ${date.getFullYear()}`;
};
/** Short month + year for mobile. */
const formatMonthYearShort = (date: Date): string => {
if (isJalali) {
const { jy, jm } = toJalali(date);
const short = JALALI_MONTHS[jm - 1].slice(0, 3);
return `${short} ${jy}`;
}
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
});
return `${month} ${date.getFullYear()}`;
};
/** Week range label (e.g. "6 12 Farvardin 1404"). */
const formatWeekRange = (weekStart: Date): string => {
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
if (isJalali) {
const start = toJalali(weekStart);
const end = toJalali(weekEnd);
if (start.jm === end.jm) {
return `${start.jd} ${end.jd} ${JALALI_MONTHS[start.jm - 1]} ${start.jy}`;
}
return `${start.jd} ${JALALI_MONTHS[start.jm - 1]} ${end.jd} ${JALALI_MONTHS[end.jm - 1]} ${end.jy}`;
}
const sameMonth = weekStart.getMonth() === weekEnd.getMonth();
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
});
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
month: sameMonth ? undefined : "short",
day: "numeric",
});
return `${s} ${e}, ${weekEnd.getFullYear()}`;
};
/** Short week range for mobile. */
const formatWeekRangeShort = (weekStart: Date): string => {
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
if (isJalali) {
const start = toJalali(weekStart);
const end = toJalali(weekEnd);
return `${start.jd}/${start.jm} ${end.jd}/${end.jm}`;
}
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
});
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
day: "numeric",
});
return `${s} ${e}`;
};
/** Full date label for accessibility / tooltips. */
const formatFullDate = (date: Date): string => {
if (isJalali) {
const { jy, jm, jd } = toJalali(date);
const dayOfWeek = date.getDay();
const dayNames = getDayHeaderKeys(weekStartsOn);
// Map from Gregorian day index to the correct label from the reordered list
const dayIdx = (dayOfWeek - weekStartsOn + 7) % 7;
const dayKey = dayNames[dayIdx];
return `${dayKey} ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`;
}
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
// ------------------------------------------------------------------
// Calendar-system-aware month/year getters (for navigation, etc.)
// All return values use **0-based** months to stay compatible with
// date-fns functions like `setMonth`.
// ------------------------------------------------------------------
const getMonth = (date: Date): number => {
if (isJalali) return toJalali(date).jm - 1; // 0-11
return date.getMonth(); // 0-11
};
const getYear = (date: Date): number => {
if (isJalali) return toJalali(date).jy;
return date.getFullYear();
};
/** Keys for the month selector dropdown (used by MiniCalendar). */
const monthLabelKeys = useMemo(() => {
if (isJalali) {
return [
"far", "ord", "kho", "tir", "mor", "sha",
"meh", "aba", "aza", "dey", "bah", "esf",
];
}
return [
"jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec",
];
}, [isJalali]);
return {
isJalali,
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
formatMonthYearShort,
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
getMonth,
getYear,
monthLabelKeys,
} as const;
}
+19 -4
View File
@@ -1,27 +1,42 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { format } from "date-fns"; import { format } from "date-fns";
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
/** /**
* Returns a memoized function that formats a calendar event date * Returns a memoized function that formats a calendar event date
* using the current locale for day and month names. * using the current locale for day and month names.
* *
* The string will be in the format: "EEE, MMM d, yyyy" * The string will be in the format: "EEE, MMM d, yyyy"
* *
* For example: "Wed, Apr 29, 2026" (en) * For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt) * "Qua, Abr 29, 2026" (pt)
*
* When the Jalali calendar is active (fa locale), the format uses
* Persian day/month names with the Jalali year, e.g.:
* "چهارشنبه, ۹ اردیبهشت ۱۴۰۵"
*/ */
export function useFormatEventDate(): (date: Date) => string { export function useFormatEventDate(): (date: Date) => string {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const locale = useLocale();
const isJalali = shouldUseJalaliCalendar(locale);
return useCallback( return useCallback(
(date: Date): string => { (date: Date): string => {
if (isJalali) {
const { jy, jm, jd } = toJalali(date);
// Use Gregorian day-of-week for the translation key (date-fns format)
const dayOfWeek = format(date, "EEE").toLowerCase();
const monthName = JALALI_MONTHS[jm - 1];
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
}
const dayOfWeek = format(date, "EEE").toLowerCase(); const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase(); const month = format(date, "MMM").toLowerCase();
const day = format(date, "d"); const day = format(date, "d");
const year = format(date, "yyyy"); const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`; return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
}, },
[t] [t, isJalali]
); );
} }
+169
View File
@@ -0,0 +1,169 @@
/**
* Jalali (Persian/Shamsi) calendar utilities.
*
* All internal date handling remains Gregorian (ISO 8601). The functions
* in this module convert Gregorian ↔ Jalali at the display layer only.
*
* Uses `jalaali-js` for the underlying calendar math.
*/
import * as jalaali from 'jalaali-js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A Jalali date represented as year, month (1-12), day (1-31). */
export interface JalaliDate {
/** Jalali year (e.g. 1405) */
jy: number;
/** Jalali month (1 = Farvardin, 12 = Esfand) */
jm: number;
/** Jalali day of month (1-31) */
jd: number;
}
// ---------------------------------------------------------------------------
// Gregorian ↔ Jalali conversion
// ---------------------------------------------------------------------------
/** Convert a Gregorian Date to its Jalali equivalent. */
export function toJalali(date: Date): JalaliDate {
const { jy, jm, jd } = jalaali.toJalaali(date);
return { jy, jm, jd };
}
/** Convert a Jalali date to a Gregorian Date. */
export function toGregorian(jy: number, jm: number, jd: number): Date {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd);
}
// ---------------------------------------------------------------------------
// Jalali month / day info
// ---------------------------------------------------------------------------
/** Full Persian month names (Farvardin … Esfand). */
export const JALALI_MONTHS: readonly string[] = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
/** Number of days in a Jalali month (handles leap years). */
export function jalaliMonthLength(jy: number, jm: number): number {
return jalaali.jalaaliMonthLength(jy, jm);
}
/** Is the given Jalali year a leap year? */
export function isJalaliLeapYear(jy: number): boolean {
return jalaali.isLeapJalaaliYear(jy);
}
// ---------------------------------------------------------------------------
// Calendar grid helpers (analogous to date-fns startOfWeek / eachDayOfInterval)
// ---------------------------------------------------------------------------
/**
* Return the first day of the Jalali month (Gregorian Date) aligned to the
* week grid so the month view can be rendered. `weekStartsOn` follows the
* same convention as `date-fns`: 0=Sun, 1=Mon, …, 6=Sat.
*/
export function startOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date {
const firstDay = toGregorian(jy, jm, 1);
const dayOfWeek = firstDay.getDay(); // 0=Sun … 6=Sat
const offset = (dayOfWeek - weekStartsOn + 7) % 7;
const result = new Date(firstDay);
result.setDate(result.getDate() - offset);
return result;
}
/**
* Return the last day of the Jalali month (Gregorian Date) aligned to the
* week grid.
*/
export function endOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date {
const lastDay = toGregorian(jy, jm, jalaliMonthLength(jy, jm));
const dayOfWeek = lastDay.getDay();
const offset = (weekStartsOn - dayOfWeek + 6) % 7;
const result = new Date(lastDay);
result.setDate(result.getDate() + offset);
return result;
}
/**
* Build a flat array of Gregorian Dates covering the entire calendar grid
* for a Jalali month (from the week-aligned start to the week-aligned end).
*/
export function eachDayOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date[] {
const start = startOfJalaliMonth(jy, jm, weekStartsOn);
const end = endOfJalaliMonth(jy, jm, weekStartsOn);
const days: Date[] = [];
const cursor = new Date(start);
while (cursor <= end) {
days.push(new Date(cursor));
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
// ---------------------------------------------------------------------------
// Locale-aware day header order
// ---------------------------------------------------------------------------
/**
* Return the array of day-abbreviation translation keys in the correct order
* for the given `firstDayOfWeek` (0=Sun … 6=Sat).
*
* Usage:
* const dayHeaders = getDayHeaderKeys(firstDayOfWeek);
* dayHeaders.map((key) => t(`calendar.days.${key}`))
*/
export function getDayHeaderKeys(
firstDayOfWeek: number,
): readonly string[] {
const ALL: readonly string[] = [
'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
] as const;
return [...ALL.slice(firstDayOfWeek), ...ALL.slice(0, firstDayOfWeek)];
}
// ---------------------------------------------------------------------------
// Locale detection helper
// ---------------------------------------------------------------------------
/**
* Should the UI render dates using the Jalali calendar?
*
* Currently this is keyed off the `fa` locale. Administrators who want a
* different locale with Jalali dates can extend this logic later.
*/
export function shouldUseJalaliCalendar(locale: string): boolean {
return locale === 'fa';
}
/** Default `firstDayOfWeek` for a given locale. */
export function defaultFirstDayOfWeek(locale: string): number {
if (locale === 'fa') return 6; // Saturday
return 1; // Monday (ISO convention)
}
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "zář", "sep": "zář",
"oct": "říj", "oct": "říj",
"nov": "lis", "nov": "lis",
"dec": "pro" "dec": "pro",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Otevřít nabídku" "nav_open_menu": "Otevřít nabídku"
}, },
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "Sep", "sep": "Sep",
"oct": "Okt", "oct": "Okt",
"nov": "Nov", "nov": "Nov",
"dec": "Dec" "dec": "Dec",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Begivenhed oprettet", "event_created": "Begivenhed oprettet",
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "Sep", "sep": "Sep",
"oct": "Okt", "oct": "Okt",
"nov": "Nov", "nov": "Nov",
"dec": "Dez" "dec": "Dez",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Menü öffnen" "nav_open_menu": "Menü öffnen"
}, },
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "Sep", "sep": "Sep",
"oct": "Oct", "oct": "Oct",
"nov": "Nov", "nov": "Nov",
"dec": "Dec" "dec": "Dec",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Event created", "event_created": "Event created",
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "Sep", "sep": "Sep",
"oct": "Oct", "oct": "Oct",
"nov": "Nov", "nov": "Nov",
"dec": "Dic" "dec": "Dic",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Abrir menú" "nav_open_menu": "Abrir menú"
}, },
+21 -3
View File
@@ -681,7 +681,12 @@
"recipient_email_placeholder": "آدرس ایمیل", "recipient_email_placeholder": "آدرس ایمیل",
"recipient_name_placeholder": "نام نمایشی", "recipient_name_placeholder": "نام نمایشی",
"autocomplete_search_server": "جستجو در سرور", "autocomplete_search_server": "جستجو در سرور",
"autocomplete_searching": "در حال جستجو..." "autocomplete_searching": "در حال جستجو...",
"text_direction": {
"toggle": "تغییر جهت متن (چپ‌به‌راست / راست‌به‌چپ)",
"ltr": "چپ‌به‌راست",
"rtl": "راست‌به‌چپ"
}
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "تأیید", "confirm": "تأیید",
@@ -1880,7 +1885,8 @@
"validation": { "validation": {
"empty": "نام قالب الزامی است", "empty": "نام قالب الزامی است",
"too_long": "نام قالب باید ۲۰۰ کاراکتر یا کمتر باشد" "too_long": "نام قالب باید ۲۰۰ کاراکتر یا کمتر باشد"
} },
"image_too_large": "حجم تصویر بیش از حد مجاز (حداکثر ۱ مگابایت)"
}, },
"files": { "files": {
"display": { "display": {
@@ -2723,7 +2729,19 @@
"sep": "سپتامبر", "sep": "سپتامبر",
"oct": "اکتبر", "oct": "اکتبر",
"nov": "نوامبر", "nov": "نوامبر",
"dec": "دسامبر" "dec": "دسامبر",
"far": "فروردین",
"ord": "اردیبهشت",
"kho": "خرداد",
"tir": "تیر",
"mor": "مرداد",
"sha": "شهریور",
"meh": "مهر",
"aba": "آبان",
"aza": "آذر",
"dey": "دی",
"bah": "بهمن",
"esf": "اسفند"
}, },
"notifications": { "notifications": {
"event_created": "رویداد ایجاد شد", "event_created": "رویداد ایجاد شد",
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "sept.", "sep": "sept.",
"oct": "oct.", "oct": "oct.",
"nov": "nov.", "nov": "nov.",
"dec": "déc." "dec": "déc.",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Événement créé", "event_created": "Événement créé",
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "Szep", "sep": "Szep",
"oct": "Okt", "oct": "Okt",
"nov": "Nov", "nov": "Nov",
"dec": "Dec" "dec": "Dec",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Esemény létrehozva", "event_created": "Esemény létrehozva",
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "set", "sep": "set",
"oct": "ott", "oct": "ott",
"nov": "nov", "nov": "nov",
"dec": "dic" "dec": "dic",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Apri menu" "nav_open_menu": "Apri menu"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "9月", "sep": "9月",
"oct": "10月", "oct": "10月",
"nov": "11月", "nov": "11月",
"dec": "12月" "dec": "12月",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "メニューを開く" "nav_open_menu": "メニューを開く"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "9월", "sep": "9월",
"oct": "10월", "oct": "10월",
"nov": "11월", "nov": "11월",
"dec": "12월" "dec": "12월",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "메뉴 열기" "nav_open_menu": "메뉴 열기"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "sept.", "sep": "sept.",
"oct": "okt.", "oct": "okt.",
"nov": "nov.", "nov": "nov.",
"dec": "dec." "dec": "dec.",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Atvērt izvēlni" "nav_open_menu": "Atvērt izvēlni"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "sep", "sep": "sep",
"oct": "okt", "oct": "okt",
"nov": "nov", "nov": "nov",
"dec": "dec" "dec": "dec",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Menu openen" "nav_open_menu": "Menu openen"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "wrz", "sep": "wrz",
"oct": "paź", "oct": "paź",
"nov": "lis", "nov": "lis",
"dec": "gru" "dec": "gru",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Otwórz menu" "nav_open_menu": "Otwórz menu"
}, },
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "set", "sep": "set",
"oct": "out", "oct": "out",
"nov": "nov", "nov": "nov",
"dec": "dez" "dec": "dez",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Evento criado", "event_created": "Evento criado",
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "Sep", "sep": "Sep",
"oct": "Oct", "oct": "Oct",
"nov": "Nov", "nov": "Nov",
"dec": "Dec" "dec": "Dec",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Eveniment creat", "event_created": "Eveniment creat",
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "сент.", "sep": "сент.",
"oct": "окт.", "oct": "окт.",
"nov": "нояб.", "nov": "нояб.",
"dec": "дек." "dec": "дек.",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Открыть меню" "nav_open_menu": "Открыть меню"
}, },
+13 -1
View File
@@ -2723,7 +2723,19 @@
"sep": "Eyl", "sep": "Eyl",
"oct": "Eki", "oct": "Eki",
"nov": "Kas", "nov": "Kas",
"dec": "Ara" "dec": "Ara",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"notifications": { "notifications": {
"event_created": "Etkinlik oluşturuldu", "event_created": "Etkinlik oluşturuldu",
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "верес.", "sep": "верес.",
"oct": "жовт.", "oct": "жовт.",
"nov": "лист.", "nov": "лист.",
"dec": "груд." "dec": "груд.",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "Відкрити меню" "nav_open_menu": "Відкрити меню"
}, },
+13 -1
View File
@@ -2904,7 +2904,19 @@
"sep": "9月", "sep": "9月",
"oct": "10月", "oct": "10月",
"nov": "11月", "nov": "11月",
"dec": "12月" "dec": "12月",
"far": "Farvardin",
"ord": "Ordibehesht",
"kho": "Khordad",
"tir": "Tir",
"mor": "Mordad",
"sha": "Shahrivar",
"meh": "Mehr",
"aba": "Aban",
"aza": "Azar",
"dey": "Dey",
"bah": "Bahman",
"esf": "Esfand"
}, },
"nav_open_menu": "打开菜单" "nav_open_menu": "打开菜单"
}, },
+10 -12
View File
@@ -28,6 +28,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"jalaali-js": "^2.0.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^16.2.6", "next": "^16.2.6",
@@ -6222,7 +6223,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -7139,6 +7139,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jalaali-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz",
"integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -7894,17 +7903,6 @@
} }
} }
}, },
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1
View File
@@ -51,6 +51,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"jalaali-js": "^2.0.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^16.2.6", "next": "^16.2.6",
+1 -1
View File
@@ -48,7 +48,7 @@ export type DateFormat = 'smart' | 'relative' | 'full';
*/ */
export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US'; export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US';
export type TimeFormat = '12h' | '24h'; export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download'; export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header'; export type AttachmentPosition = 'beside-sender' | 'below-header';