admin/lib/formatters.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

144 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { texts } from '@/texts'
const EMPTY_VALUE = '—'
/** Product display timezone — keeps SSR (Docker UTC) and client (Iran) text identical. */
export const DISPLAY_TIME_ZONE = 'Asia/Tehran'
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const
/** Normalize Western digits and grouping to Persian digits for stable SSR/client output. */
export function toPersianDigits(value: string): string {
return value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)])
}
const defaultDateTimeOptions: Intl.DateTimeFormatOptions = {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: DISPLAY_TIME_ZONE,
}
function dayKeyInTimeZone(date: Date, timeZone: string): string | null {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date)
const year = parts.find((part) => part.type === 'year')?.value
const month = parts.find((part) => part.type === 'month')?.value
const day = parts.find((part) => part.type === 'day')?.value
if (!year || !month || !day) return null
return `${year}-${month}-${day}`
}
export function formatPersianDate(
value: unknown,
options: Intl.DateTimeFormatOptions = defaultDateTimeOptions,
fallback = EMPTY_VALUE
): string {
if (!value || (typeof value !== 'string' && !(value instanceof Date))) return fallback
const date = value instanceof Date ? value : new Date(value)
return Number.isFinite(date.getTime())
? new Intl.DateTimeFormat('fa-IR', { timeZone: DISPLAY_TIME_ZONE, ...options }).format(date)
: fallback
}
/** Local calendar day key (YYYY-MM-DD) in the product display timezone. */
export function toLocalDayKey(value: string | Date, timeZone = DISPLAY_TIME_ZONE): string | null {
const date = value instanceof Date ? value : new Date(value)
if (!Number.isFinite(date.getTime())) return null
return dayKeyInTimeZone(date, timeZone)
}
/** Telegram-style day label: امروز / فردا / دیروز / full Persian date. */
export function formatChatDayLabel(value: string | Date, now = new Date()): string {
const date = value instanceof Date ? value : new Date(value)
if (!Number.isFinite(date.getTime())) return EMPTY_VALUE
const todayKey = toLocalDayKey(now)
const dayKey = toLocalDayKey(date)
if (!todayKey || !dayKey) return EMPTY_VALUE
if (dayKey === todayKey) return texts.common.today
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
if (dayKey === toLocalDayKey(tomorrow)) return texts.common.tomorrow
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (dayKey === toLocalDayKey(yesterday)) return texts.common.yesterday
return formatPersianDate(date, { dateStyle: 'medium' })
}
/** e.g. `امروز ۱۸:۰۰ تا ۲۰:۳۰` for home category preview rows. */
export function formatEventDayTimeRange(startsAt: string | Date, endsAt: string | Date, now = new Date()): string {
const day = formatChatDayLabel(startsAt, now)
const from = formatPersianDate(startsAt, { hour: '2-digit', minute: '2-digit' })
const to = formatPersianDate(endsAt, { hour: '2-digit', minute: '2-digit' })
return `${day} ${from} تا ${to}`
}
export function formatIranianMobile(value: unknown, fallback = EMPTY_VALUE): string {
if (typeof value !== 'string' || value.length === 0) return fallback
return value.startsWith('98') && value.length === 12 ? `0${value.slice(2)}` : value
}
export function truncateValue(value: unknown, maxLength = 80, fallback = EMPTY_VALUE): string {
if (typeof value !== 'string' || value.trim().length === 0) return fallback
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value
}
/** Relative Persian time for review/activity timestamps (e.g. «۲ روز پیش»). */
export function formatRelativeTimePersian(value: unknown, now = new Date(), fallback = EMPTY_VALUE): string {
if (!value || (typeof value !== 'string' && !(value instanceof Date))) return fallback
const date = value instanceof Date ? value : new Date(value)
if (!Number.isFinite(date.getTime())) return fallback
const diffMs = Math.max(0, now.getTime() - date.getTime())
const diffMinutes = Math.floor(diffMs / 60_000)
if (diffMinutes < 1) return texts.common.justNow
if (diffMinutes < 60) {
return texts.common.minutesAgo.replace('{count}', toPersianDigits(String(diffMinutes)))
}
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) {
return texts.common.hoursAgo.replace('{count}', toPersianDigits(String(diffHours)))
}
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) {
return texts.common.daysAgo.replace('{count}', toPersianDigits(String(diffDays)))
}
const diffWeeks = Math.floor(diffDays / 7)
if (diffWeeks < 5) {
return texts.common.weeksAgo.replace('{count}', toPersianDigits(String(diffWeeks)))
}
return formatPersianDate(date, { dateStyle: 'medium' })
}