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:
@@ -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"
|
||||||
|
|||||||
@@ -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(*)` })
|
||||||
|
|||||||
@@ -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": "البريد الإلكتروني",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user