diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 530741e0..7dfb16c8 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/lib/i18n-format.ts b/artifacts/teaboy-os/src/lib/i18n-format.ts new file mode 100644 index 00000000..30e6c1fc --- /dev/null +++ b/artifacts/teaboy-os/src/lib/i18n-format.ts @@ -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"; +} diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 706bff69..466080a4 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -35,7 +35,20 @@ "rights": "جميع الحقوق محفوظة." }, "home": { - "myApps": "تطبيقاتي" + "myApps": "تطبيقاتي", + "noApps": "لا توجد تطبيقات بعد", + "noAppsHint": "ستظهر هنا التطبيقات التي تملك صلاحية الوصول إليها.", + "greeting": { + "morning": "صباح الخير، {{name}}", + "afternoon": "مساء الخير، {{name}}", + "evening": "مساء الخير، {{name}}" + }, + "stats": { + "apps": "التطبيقات", + "services": "الخدمات", + "messages": "الرسائل", + "alerts": "التنبيهات" + } }, "services": { "title": "الخدمات", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index bfc6b059..eb4a5492 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 70f682cc..d1c1deb7 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -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 ""; } diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index 78de046b..c6d49a28 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -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}
- {new Date(msg.createdAt).toLocaleTimeString(lang === "ar" ? "ar-SA" : "en-US", { - hour: "2-digit", - minute: "2-digit", - })} + {formatTime(msg.createdAt, lang)}
diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index ff2cad03..54dc4095 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -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>)[app.iconName] - ?? LucideIcons.Grid2X2; + const Icon = resolveIcon(app.iconName); return ( @@ -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 ( +
+
+ +
+
+
{value}
+
{label}
+
+
+ ); +} + 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 (
{/* Status Bar */} -
-
- {displayName} +
+ {/* Identity */} +
+
+ {initials} +
+
+
{displayName}
+ {isAdmin && ( +
Admin
+ )} +
-
-
- {time.toLocaleTimeString(dateLocale, { - hour: "2-digit", - minute: "2-digit", - })} + {/* Clock + dates */} +
+
+ {clock}
-
+
{dayName} - {hijriDate} + {hijriDate}
-
+
{gregorianDate}
-
+ {/* Controls */} +
@@ -244,59 +303,97 @@ export default function HomePage() {
{/* Main Content */} -
+
+ {/* Greeting */} +
+

+ {greeting} +

+

+ {dayName} · {gregorianDate} +

+
+ {/* Stats Row — admin only */} {isAdmin && stats && ( -
-
-
{stats.totalApps}
-
{t("home.myApps")}
-
-
-
{stats.totalServices}
-
{t("nav.services")}
-
-
-
{stats.unreadMessages}
-
{t("nav.chat")}
-
-
-
{stats.unreadNotifications}
-
{t("nav.notifications")}
-
+
+ + + +
)} {/* Apps Grid */}
-

{t("home.myApps")}

- - -
- {orderedApps?.map((app) => ( - setLocation(app.route)} - /> - ))} +
+

+ {t("home.myApps")} +

+ {orderedApps && orderedApps.length > 0 && ( + + {formatNumber(orderedApps.length, lang)} + + )} +
+ + {orderedApps && orderedApps.length === 0 ? ( +
+
+
- - +
{t("home.noApps")}
+
{t("home.noAppsHint")}
+
+ ) : ( + + +
+ {orderedApps?.map((app) => ( + setLocation(app.route)} + /> + ))} +
+
+
+ )}
{/* Bottom Dock */} -
-
+
+
{dockApps.slice(0, 4).map((app) => { - const Icon = (LucideIcons as unknown as Record>)[app.iconName] - ?? LucideIcons.Grid2X2; + const Icon = resolveIcon(app.iconName); + const showBadge = app.slug === "notifications" && unreadCount > 0; return ( ); })} diff --git a/artifacts/teaboy-os/src/pages/notifications.tsx b/artifacts/teaboy-os/src/pages/notifications.tsx index 3ae410e7..99cbc872 100644 --- a/artifacts/teaboy-os/src/pages/notifications.tsx +++ b/artifacts/teaboy-os/src/pages/notifications.tsx @@ -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() { )}
- {new Date(n.createdAt).toLocaleString(lang === "ar" ? "ar-SA" : "en-US")} + {formatDateTime(n.createdAt, lang)}
);