Task #5: Refine home and services screens

- Removed greeting and welcome banner from the home page; trimmed unused
  ar.json/en.json greeting keys.
- Hid the 4-tile stats row for non-admin users (`isAdmin` from user roles)
  and scoped `totalApps` in /api/stats/home to apps the user can actually
  access (mirrors the RBAC join in /api/apps).
- Renamed nav.services translation: "خدماتي" -> "الخدمات",
  "My Services" -> "Services".
- Removed price/free text from the user-facing services page; cards now
  show name, description, availability badge, and (when present) image.
- Admin service editor now exposes an "Image URL" / "رابط الصورة" text
  field with a live preview thumbnail; saved imageUrl renders at the top
  of the corresponding service card (16:10, object-cover, onError hides
  broken images). Uses existing imageUrl column in services schema; no DB
  migration required. Image upload via object storage was deferred — see
  follow-up task.

Verified: typecheck passes for teaboy-os and api-server; api-server
restarted clean; e2e plan validated by testing subagent; architect code
review approved.
This commit is contained in:
riyadhafraa
2026-04-20 10:49:57 +00:00
parent a73d2566bc
commit 40100125da
7 changed files with 111 additions and 44 deletions
+8
View File
@@ -27,9 +27,17 @@ externalPort = 8080
localPort = 8081 localPort = 8081
externalPort = 80 externalPort = 80
[[ports]]
localPort = 8082
externalPort = 3003
[[ports]] [[ports]]
localPort = 25785 localPort = 25785
externalPort = 3000 externalPort = 3000
[[ports]]
localPort = 25786
externalPort = 3002
[nix] [nix]
channel = "stable-25_05" channel = "stable-25_05"
+56 -5
View File
@@ -1,8 +1,12 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db"; import { db } from "@workspace/db";
import { import {
appsTable, appsTable,
appPermissionsTable,
rolesTable,
rolePermissionsTable,
userRolesTable,
servicesTable, servicesTable,
notificationsTable, notificationsTable,
usersTable, usersTable,
@@ -16,10 +20,57 @@ const router: IRouter = Router();
router.get("/stats/home", requireAuth, async (req, res): Promise<void> => { router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!; const userId = req.session.userId!;
const [appsCount] = await db // Determine if the user is an admin and which apps they can access.
.select({ count: sql<number>`count(*)` }) const userRoleRows = await db
.from(appsTable) .select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
.where(eq(appsTable.isActive, true)); .from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.where(eq(userRolesTable.userId, userId));
const isAdmin = userRoleRows.some((r) => r.roleName === "admin");
let totalApps = 0;
if (isAdmin) {
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(appsTable)
.where(eq(appsTable.isActive, true));
totalApps = Number(row?.count ?? 0);
} else {
const userRoleIds = userRoleRows.map((r) => r.roleId);
const userPermissionIds = userRoleIds.length > 0
? (await db
.select({ permissionId: rolePermissionsTable.permissionId })
.from(rolePermissionsTable)
.where(inArray(rolePermissionsTable.roleId, userRoleIds))
).map((r) => r.permissionId)
: [];
const restrictedRows = await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable);
const restrictedAppIds = new Set(restrictedRows.map((r) => r.appId));
const allowedRestrictedAppIds = userPermissionIds.length > 0
? new Set(
(await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable)
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
).map((r) => r.appId),
)
: new Set<number>();
const activeApps = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.isActive, true));
totalApps = activeApps.filter(
(a) => !restrictedAppIds.has(a.id) || allowedRestrictedAppIds.has(a.id),
).length;
}
const appsCount = { count: totalApps };
const [servicesCount] = await db const [servicesCount] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
+3 -6
View File
@@ -1,7 +1,7 @@
{ {
"nav": { "nav": {
"home": "الرئيسية", "home": "الرئيسية",
"services": "خدماتي", "services": "الخدمات",
"chat": "المحادثات", "chat": "المحادثات",
"notifications": "الإشعارات", "notifications": "الإشعارات",
"admin": "لوحة التحكم", "admin": "لوحة التحكم",
@@ -21,11 +21,7 @@
"displayNameEn": "الاسم (إنجليزي)" "displayNameEn": "الاسم (إنجليزي)"
}, },
"home": { "home": {
"goodMorning": "صباح الخير", "myApps": "تطبيقاتي"
"goodAfternoon": "مساء الخير",
"goodEvening": "مساء الخير",
"myApps": "تطبيقاتي",
"welcome": "مرحباً بك في نظام TeaBoy"
}, },
"services": { "services": {
"title": "الخدمات", "title": "الخدمات",
@@ -83,6 +79,7 @@
"serviceDescriptionEn": "الوصف (إنجليزي)", "serviceDescriptionEn": "الوصف (إنجليزي)",
"servicePrice": "السعر", "servicePrice": "السعر",
"serviceAvailable": "متاح؟", "serviceAvailable": "متاح؟",
"serviceImageUrl": "رابط الصورة",
"users": "المستخدمين", "users": "المستخدمين",
"username": "اسم المستخدم", "username": "اسم المستخدم",
"email": "البريد الإلكتروني", "email": "البريد الإلكتروني",
+3 -6
View File
@@ -1,7 +1,7 @@
{ {
"nav": { "nav": {
"home": "Home", "home": "Home",
"services": "My Services", "services": "Services",
"chat": "Chat", "chat": "Chat",
"notifications": "Notifications", "notifications": "Notifications",
"admin": "Admin Dashboard", "admin": "Admin Dashboard",
@@ -21,11 +21,7 @@
"displayNameEn": "Name (English)" "displayNameEn": "Name (English)"
}, },
"home": { "home": {
"goodMorning": "Good Morning", "myApps": "My Apps"
"goodAfternoon": "Good Afternoon",
"goodEvening": "Good Evening",
"myApps": "My Apps",
"welcome": "Welcome to TeaBoy OS"
}, },
"services": { "services": {
"title": "Services", "title": "Services",
@@ -83,6 +79,7 @@
"serviceDescriptionEn": "Description (English)", "serviceDescriptionEn": "Description (English)",
"servicePrice": "Price", "servicePrice": "Price",
"serviceAvailable": "Available?", "serviceAvailable": "Available?",
"serviceImageUrl": "Image URL",
"users": "Users", "users": "Users",
"username": "Username", "username": "Username",
"email": "Email", "email": "Email",
+15 -1
View File
@@ -44,6 +44,7 @@ type ServiceForm = {
descriptionAr: string; descriptionAr: string;
descriptionEn: string; descriptionEn: string;
price: string; price: string;
imageUrl: string;
isAvailable: boolean; isAvailable: boolean;
}; };
@@ -84,7 +85,7 @@ export default function AdminPage() {
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null); const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 }; const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 };
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", isAvailable: true }; const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true };
const handleSaveApp = () => { const handleSaveApp = () => {
if (!editingApp) return; if (!editingApp) return;
@@ -220,6 +221,7 @@ export default function AdminPage() {
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") }, { field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") }, { field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
{ field: "price", label: t("admin.servicePrice") }, { field: "price", label: t("admin.servicePrice") },
{ field: "imageUrl", label: t("admin.serviceImageUrl") },
] as { field: keyof ServiceForm; label: string }[] ] as { field: keyof ServiceForm; label: string }[]
).map(({ field, label }) => ( ).map(({ field, label }) => (
<div key={field} className="space-y-1"> <div key={field} className="space-y-1">
@@ -228,9 +230,20 @@ export default function AdminPage() {
value={String(editingService.form[field])} value={String(editingService.form[field])}
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })} onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
className="bg-white/70 border-slate-200" className="bg-white/70 border-slate-200"
placeholder={field === "imageUrl" ? "https://..." : undefined}
/> />
</div> </div>
))} ))}
{editingService.form.imageUrl && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={editingService.form.imageUrl}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>{t("admin.serviceAvailable")}</Label> <Label>{t("admin.serviceAvailable")}</Label>
<Switch <Switch
@@ -395,6 +408,7 @@ export default function AdminPage() {
descriptionAr: service.descriptionAr ?? "", descriptionAr: service.descriptionAr ?? "",
descriptionEn: service.descriptionEn ?? "", descriptionEn: service.descriptionEn ?? "",
price: service.price ?? "0.00", price: service.price ?? "0.00",
imageUrl: service.imageUrl ?? "",
isAvailable: service.isAvailable ?? true, isAvailable: service.isAvailable ?? true,
}, },
})} })}
+3 -10
View File
@@ -77,8 +77,7 @@ export default function HomePage() {
? (user?.displayNameAr ?? user?.username) ? (user?.displayNameAr ?? user?.username)
: (user?.displayNameEn ?? user?.username); : (user?.displayNameEn ?? user?.username);
const hour = time.getHours(); const isAdmin = user?.roles?.includes("admin") ?? false;
const greeting = hour < 12 ? t("home.goodMorning") : hour < 17 ? t("home.goodAfternoon") : t("home.goodEvening");
const handleLogout = () => { const handleLogout = () => {
logout.mutate(undefined, { logout.mutate(undefined, {
@@ -144,14 +143,8 @@ export default function HomePage() {
{/* Main Content */} {/* Main Content */}
<div className="flex-1 p-6 pb-28 overflow-y-auto"> <div className="flex-1 p-6 pb-28 overflow-y-auto">
{/* Greeting */} {/* Stats Row — admin only */}
<div className="mb-8"> {isAdmin && stats && (
<h2 className="text-3xl font-bold text-foreground">{greeting}</h2>
<p className="text-muted-foreground mt-1">{t("home.welcome")}</p>
</div>
{/* Stats Row */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8"> <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="glass-panel rounded-2xl p-3 text-center">
<div className="text-2xl font-bold text-primary">{stats.totalApps}</div> <div className="text-2xl font-bold text-primary">{stats.totalApps}</div>
+23 -16
View File
@@ -49,29 +49,36 @@ export default function ServicesPage() {
{services.map((service) => { {services.map((service) => {
const name = lang === "ar" ? service.nameAr : service.nameEn; const name = lang === "ar" ? service.nameAr : service.nameEn;
const description = lang === "ar" ? service.descriptionAr : service.descriptionEn; const description = lang === "ar" ? service.descriptionAr : service.descriptionEn;
const price = service.price ? parseFloat(service.price) : 0;
return ( return (
<div <div
key={service.id} key={service.id}
className="glass-panel rounded-2xl p-5 flex flex-col gap-3" className="glass-panel rounded-2xl overflow-hidden flex flex-col card-hover"
> >
<div className="flex items-start justify-between"> {service.imageUrl && (
<h3 className="text-base font-semibold text-foreground">{name}</h3> <div className="w-full aspect-[16/10] bg-slate-100 overflow-hidden">
<Badge <img
variant={service.isAvailable ? "default" : "secondary"} src={service.imageUrl}
className={service.isAvailable ? "bg-green-500/20 text-green-400 border-green-500/30" : ""} alt={name}
> className="w-full h-full object-cover"
{service.isAvailable ? t("services.available") : t("services.unavailable")} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
</Badge> />
</div> </div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)} )}
<div className="p-5 flex flex-col gap-3 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-foreground">{name}</h3>
<Badge
variant={service.isAvailable ? "default" : "secondary"}
className={service.isAvailable ? "bg-emerald-100 text-emerald-700 border-emerald-200" : ""}
>
{service.isAvailable ? t("services.available") : t("services.unavailable")}
</Badge>
</div>
<div className="mt-auto text-sm text-primary font-medium"> {description && (
{price === 0 ? t("services.free") : `${price} ${t("services.price")}`} <p className="text-sm text-muted-foreground">{description}</p>
)}
</div> </div>
</div> </div>
); );