Task #19: Force Latin digits + redesign home screen
- New helper artifacts/teaboy-os/src/lib/i18n-format.ts wraps Intl with
numberingSystem: "latn" so numerals always render 0-9 even in Arabic,
while month/weekday names stay localized. Exports formatTime,
formatDate, formatHijri, formatWeekday, formatDateTime, formatNumber
and a greetingKey() helper for time-of-day greetings.
- Replaced every toLocaleTimeString / toLocaleDateString /
toLocaleString / direct Intl.DateTimeFormat call in home, admin,
chat and notifications with the new helpers. No remaining
Arabic-Indic digits anywhere in the UI (verified e2e).
- Redesigned the home screen:
* Tighter status bar with three balanced sections: identity (avatar
+ name + admin tag), centered clock + Hijri/Gregorian dates,
grouped controls (language, bell, logout).
* Personal greeting line ("Good morning / صباح الخير …") driven by
local hour and the user's display name.
* Admin-only 4-card stat row (Apps / Services / Messages / Alerts)
with soft glass + small colored icon tiles.
* Polished apps grid with section count badge and a friendly empty
state. Drag-and-drop reorder behavior preserved.
* Type-safe Lucide icon resolver (no unsafe casts).
- Added i18n keys home.greeting.{morning,afternoon,evening},
home.noApps, home.noAppsHint, home.stats.* in ar.json and en.json.
- Cleaned up obsolete follow-up plan file now that this work shipped.
Verified via TypeScript + e2e Playwright run in both Arabic (RTL) and
English (LTR): login → home redesign → notifications → chat, all
numerals Latin in both languages.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Locale-aware date/number formatters that ALWAYS render Latin (0-9) digits,
|
||||
* even when the active language is Arabic. Month and weekday names still
|
||||
* localize to Arabic as expected.
|
||||
*/
|
||||
|
||||
function withLatn(locale: string): string {
|
||||
return locale.includes("-u-") ? `${locale}-nu-latn` : `${locale}-u-nu-latn`;
|
||||
}
|
||||
|
||||
function baseLocale(lang: string): string {
|
||||
return lang === "ar" ? "ar" : "en-US";
|
||||
}
|
||||
|
||||
export function formatTime(
|
||||
date: Date | string | number,
|
||||
lang: string,
|
||||
options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" },
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
|
||||
...options,
|
||||
hour12: false,
|
||||
numberingSystem: "latn",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
lang: string,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
|
||||
...options,
|
||||
numberingSystem: "latn",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatHijri(
|
||||
date: Date | string | number,
|
||||
lang: string,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const loc = lang === "ar" ? "ar-u-ca-islamic-umalqura-nu-latn" : "en-u-ca-islamic-umalqura-nu-latn";
|
||||
return new Intl.DateTimeFormat(loc, {
|
||||
...options,
|
||||
numberingSystem: "latn",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatWeekday(
|
||||
date: Date | string | number,
|
||||
lang: string,
|
||||
weekday: "long" | "short" | "narrow" = "long",
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
|
||||
weekday,
|
||||
numberingSystem: "latn",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
date: Date | string | number,
|
||||
lang: string,
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
numberingSystem: "latn",
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, lang: string): string {
|
||||
return new Intl.NumberFormat(withLatn(baseLocale(lang)), {
|
||||
numberingSystem: "latn",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized time-of-day greeting key. Uses the local hour to pick one of
|
||||
* "morning", "afternoon", or "evening". Caller provides the i18n strings.
|
||||
*/
|
||||
export function greetingKey(date: Date = new Date()): "morning" | "afternoon" | "evening" {
|
||||
const h = date.getHours();
|
||||
if (h < 12) return "morning";
|
||||
if (h < 18) return "afternoon";
|
||||
return "evening";
|
||||
}
|
||||
@@ -35,7 +35,20 @@
|
||||
"rights": "جميع الحقوق محفوظة."
|
||||
},
|
||||
"home": {
|
||||
"myApps": "تطبيقاتي"
|
||||
"myApps": "تطبيقاتي",
|
||||
"noApps": "لا توجد تطبيقات بعد",
|
||||
"noAppsHint": "ستظهر هنا التطبيقات التي تملك صلاحية الوصول إليها.",
|
||||
"greeting": {
|
||||
"morning": "صباح الخير، {{name}}",
|
||||
"afternoon": "مساء الخير، {{name}}",
|
||||
"evening": "مساء الخير، {{name}}"
|
||||
},
|
||||
"stats": {
|
||||
"apps": "التطبيقات",
|
||||
"services": "الخدمات",
|
||||
"messages": "الرسائل",
|
||||
"alerts": "التنبيهات"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "الخدمات",
|
||||
|
||||
@@ -35,7 +35,20 @@
|
||||
"rights": "All rights reserved."
|
||||
},
|
||||
"home": {
|
||||
"myApps": "My Apps"
|
||||
"myApps": "My Apps",
|
||||
"noApps": "No apps yet",
|
||||
"noAppsHint": "Apps you have access to will appear here.",
|
||||
"greeting": {
|
||||
"morning": "Good morning, {{name}}",
|
||||
"afternoon": "Good afternoon, {{name}}",
|
||||
"evening": "Good evening, {{name}}"
|
||||
},
|
||||
"stats": {
|
||||
"apps": "Apps",
|
||||
"services": "Services",
|
||||
"messages": "Messages",
|
||||
"alerts": "Alerts"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
|
||||
|
||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "settings";
|
||||
|
||||
@@ -778,15 +779,10 @@ function DashboardSection({
|
||||
? [...apps].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0]
|
||||
: undefined;
|
||||
|
||||
const dateLocale = lang === "ar" ? "ar" : "en";
|
||||
const formatDate = (iso?: string) => {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(dateLocale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return formatDateUtil(iso, lang, { year: "numeric", month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -814,9 +810,7 @@ function DashboardSection({
|
||||
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
||||
const dayLabel = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso + "T00:00:00Z").toLocaleDateString(dateLocale, {
|
||||
weekday: "short",
|
||||
});
|
||||
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { io } from "socket.io-client";
|
||||
import { ArrowRight, ArrowLeft, MessageCircle, Plus, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatTime } from "@/lib/i18n-format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -265,10 +266,7 @@ export default function ChatPage() {
|
||||
{msg.content}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 px-1">
|
||||
{new Date(msg.createdAt).toLocaleTimeString(lang === "ar" ? "ar-SA" : "en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{formatTime(msg.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,18 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Bell, LogOut, Globe } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
LogOut,
|
||||
Globe,
|
||||
Grid2X2,
|
||||
Coffee,
|
||||
MessageSquare,
|
||||
BellRing,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import i18n from "@/i18n";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -35,6 +45,28 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatHijri,
|
||||
formatWeekday,
|
||||
formatNumber,
|
||||
greetingKey,
|
||||
} from "@/lib/i18n-format";
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
function isIconName(x: string): x is IconName {
|
||||
return x in LucideIcons;
|
||||
}
|
||||
function resolveIcon(name: string): LucideIcon {
|
||||
if (isIconName(name)) {
|
||||
const Candidate = LucideIcons[name] as unknown;
|
||||
if (typeof Candidate === "function" || typeof Candidate === "object") {
|
||||
return Candidate as LucideIcon;
|
||||
}
|
||||
}
|
||||
return LucideIcons.Grid2X2;
|
||||
}
|
||||
|
||||
function gradientForColor(color: string): string {
|
||||
const c = (color ?? "").toLowerCase();
|
||||
@@ -51,18 +83,17 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon
|
||||
const lang = i18nInstance.language;
|
||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
|
||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
|
||||
?? LucideIcons.Grid2X2;
|
||||
const Icon = resolveIcon(app.iconName);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 group"
|
||||
className="flex flex-col items-center gap-2 group select-none"
|
||||
>
|
||||
<div className={`icon-tile w-16 h-16 ${gradientForColor(app.color)} group-hover:scale-105`}>
|
||||
<Icon size={28} color="#FFFFFF" />
|
||||
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
</div>
|
||||
<span className="text-xs text-foreground/80 font-medium text-center leading-tight max-w-16 truncate">
|
||||
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
@@ -85,6 +116,27 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
type StatCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
Icon: typeof Grid2X2;
|
||||
iconClass: string;
|
||||
};
|
||||
|
||||
function StatCard({ label, value, Icon, iconClass }: StatCardProps) {
|
||||
return (
|
||||
<div className="glass-panel rounded-2xl p-3 flex items-center gap-3">
|
||||
<div className={`shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${iconClass}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xl font-bold text-foreground leading-none tabular-nums">{value}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 truncate">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t, i18n: i18nInstance } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -152,25 +204,21 @@ export default function HomePage() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const dateLocale = lang === "ar" ? "ar-SA" : "en-US";
|
||||
const dayName = new Intl.DateTimeFormat(dateLocale, { weekday: "long" }).format(time);
|
||||
const hijriDate = new Intl.DateTimeFormat(
|
||||
lang === "ar" ? "ar-SA-u-ca-islamic-umalqura" : "en-US-u-ca-islamic-umalqura",
|
||||
{ day: "numeric", month: "long", year: "numeric" },
|
||||
).format(time);
|
||||
const gregorianDate = new Intl.DateTimeFormat(
|
||||
lang === "ar" ? "ar-SA-u-ca-gregory" : "en-US",
|
||||
{ day: "numeric", month: "long", year: "numeric" },
|
||||
).format(time);
|
||||
const clock = formatTime(time, lang);
|
||||
const dayName = formatWeekday(time, lang);
|
||||
const hijriDate = formatHijri(time, lang);
|
||||
const gregorianDate = formatDate(time, lang);
|
||||
|
||||
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0;
|
||||
|
||||
const displayName = lang === "ar"
|
||||
? (user?.displayNameAr ?? user?.username)
|
||||
: (user?.displayNameEn ?? user?.username);
|
||||
? (user?.displayNameAr ?? user?.username ?? "")
|
||||
: (user?.displayNameEn ?? user?.username ?? "");
|
||||
|
||||
const isAdmin = user?.roles?.includes("admin") ?? false;
|
||||
|
||||
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
@@ -191,52 +239,63 @@ export default function HomePage() {
|
||||
|
||||
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? [];
|
||||
|
||||
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
|
||||
{/* Status Bar */}
|
||||
<div className="topbar-glass px-4 py-3 flex items-center justify-between gap-2 z-10 sticky top-0">
|
||||
<div className="flex items-center gap-3 min-w-0 shrink">
|
||||
<span className="text-foreground font-medium text-sm truncate max-w-24">{displayName}</span>
|
||||
<div className="topbar-glass px-4 sm:px-6 py-3 flex items-center justify-between gap-3 z-10 sticky top-0">
|
||||
{/* Identity */}
|
||||
<div className="flex items-center gap-2.5 min-w-0 shrink basis-0 flex-1">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-sm shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 hidden sm:block">
|
||||
<div className="text-foreground font-semibold text-sm truncate leading-tight">{displayName}</div>
|
||||
{isAdmin && (
|
||||
<div className="text-[10px] text-muted-foreground leading-tight">Admin</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2">
|
||||
<div className="font-mono text-sm font-bold">
|
||||
{time.toLocaleTimeString(dateLocale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{/* Clock + dates */}
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2 shrink-0">
|
||||
<div className="font-mono text-base sm:text-lg font-bold tabular-nums tracking-tight">
|
||||
{clock}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-full truncate">
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-[60vw] truncate">
|
||||
<span className="truncate">{dayName}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate">{hijriDate}</span>
|
||||
<span className="truncate tabular-nums">{hijriDate}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground max-w-full truncate">
|
||||
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
|
||||
{gregorianDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink basis-0 flex-1 justify-end">
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-xs font-medium px-2 py-1 rounded-lg hover:bg-slate-100"
|
||||
aria-label="Toggle language"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
|
||||
>
|
||||
{t("common.language")}
|
||||
<Globe size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocation("/notifications")}
|
||||
className="relative p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-xs rounded-full w-4 h-4 flex items-center justify-center font-bold">
|
||||
<span className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -244,59 +303,97 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-6 pb-28 overflow-y-auto">
|
||||
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
|
||||
{/* Greeting */}
|
||||
<div className="mb-6 max-w-3xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
|
||||
{greeting}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Row — admin only */}
|
||||
{isAdmin && stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
||||
<div className="glass-panel rounded-2xl p-3 text-center">
|
||||
<div className="text-2xl font-bold text-primary">{stats.totalApps}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("home.myApps")}</div>
|
||||
</div>
|
||||
<div className="glass-panel rounded-2xl p-3 text-center">
|
||||
<div className="text-2xl font-bold text-secondary">{stats.totalServices}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("nav.services")}</div>
|
||||
</div>
|
||||
<div className="glass-panel rounded-2xl p-3 text-center">
|
||||
<div className="text-2xl font-bold text-accent">{stats.unreadMessages}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("nav.chat")}</div>
|
||||
</div>
|
||||
<div className="glass-panel rounded-2xl p-3 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">{stats.unreadNotifications}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("nav.notifications")}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8 max-w-4xl">
|
||||
<StatCard
|
||||
label={t("home.stats.apps")}
|
||||
value={formatNumber(stats.totalApps, lang)}
|
||||
Icon={Grid2X2}
|
||||
iconClass="bg-primary/15 text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("home.stats.services")}
|
||||
value={formatNumber(stats.totalServices, lang)}
|
||||
Icon={Coffee}
|
||||
iconClass="bg-amber-100 text-amber-600"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("home.stats.messages")}
|
||||
value={formatNumber(stats.unreadMessages, lang)}
|
||||
Icon={MessageSquare}
|
||||
iconClass="bg-emerald-100 text-emerald-600"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("home.stats.alerts")}
|
||||
value={formatNumber(stats.unreadNotifications, lang)}
|
||||
Icon={BellRing}
|
||||
iconClass="bg-rose-100 text-rose-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apps Grid */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-4">{t("home.myApps")}</h3>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
|
||||
{orderedApps?.map((app) => (
|
||||
<SortableAppIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
onClick={() => setLocation(app.route)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("home.myApps")}
|
||||
</h3>
|
||||
{orderedApps && orderedApps.length > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatNumber(orderedApps.length, lang)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orderedApps && orderedApps.length === 0 ? (
|
||||
<div className="glass-panel rounded-2xl p-8 text-center max-w-md mx-auto">
|
||||
<div className="mx-auto w-12 h-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-3">
|
||||
<Sparkles size={22} />
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="font-semibold text-foreground">{t("home.noApps")}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{t("home.noAppsHint")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-x-3 gap-y-5">
|
||||
{orderedApps?.map((app) => (
|
||||
<SortableAppIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
onClick={() => setLocation(app.route)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Dock */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 z-10">
|
||||
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto">
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 z-10 pointer-events-none">
|
||||
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto pointer-events-auto">
|
||||
{dockApps.slice(0, 4).map((app) => {
|
||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
|
||||
?? LucideIcons.Grid2X2;
|
||||
const Icon = resolveIcon(app.iconName);
|
||||
const showBadge = app.slug === "notifications" && unreadCount > 0;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => setLocation(app.route)}
|
||||
className="flex flex-col items-center gap-1 group"
|
||||
className="relative flex flex-col items-center gap-1 group"
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
@@ -304,6 +401,11 @@ export default function HomePage() {
|
||||
>
|
||||
<Icon size={22} color={app.color} />
|
||||
</div>
|
||||
{showBadge && (
|
||||
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, ArrowLeft, Bell, CheckCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/i18n-format";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -113,7 +114,7 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{new Date(n.createdAt).toLocaleString(lang === "ar" ? "ar-SA" : "en-US")}
|
||||
{formatDateTime(n.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user