Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
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' })
|
||
}
|