Task #63: Service Orders client UI (place order + My Orders)

Adds the user-facing half of the service ordering workflow on top of the
backend foundation merged in Task #62.

What's new:
- Order button on each available service card (hidden when service is
  unavailable) opens a focused OrderServiceModal showing only the
  service name + image with a multi-line notes textarea (500-char cap
  with live counter). Submitting calls POST /orders, shows a toast, and
  invalidates the My Orders cache.
- New /my-orders page lists the user's own orders newest-first with a
  status pill, a horizontal 4-step timeline (pending → received →
  preparing → completed), and a red terminal pill for cancelled orders.
- Cancel action with confirm dialog is shown only while the order is
  pending or received; it calls PATCH /orders/:id/status with
  cancelled.
- "My Orders" link added to the services page header.
- Realtime: the existing notifications socket also invalidates the
  my-orders query on notification_created (when type === 'order') and
  on a new order_updated event.
- Locale keys mirrored in ar.json and en.json for services.order/*,
  myOrders.*, and orderStatus.*. RTL/LTR handled.
- Friendly load-error state with retry, plus client-side sort by
  createdAt desc.

Verification: typecheck clean, all 3 e2e tests pass, manual end-to-end
UI flow (place + cancel + locale switch) verified via testing skill,
backend smoke test confirmed POST /orders + GET /orders/my wiring.
This commit is contained in:
Riyadh
2026-04-21 18:47:30 +00:00
parent 310dad874a
commit 4eb8cbfad3
8 changed files with 610 additions and 4 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+2
View File
@@ -15,6 +15,7 @@ import ChatPage from "@/pages/chat";
import NotificationsPage from "@/pages/notifications";
import AdminPage from "@/pages/admin";
import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
const queryClient = new QueryClient({
defaultOptions: {
@@ -44,6 +45,7 @@ function Router() {
<Route path="/notifications" component={NotificationsPage} />
<Route path="/admin" component={AdminPage} />
<Route path="/notes" component={NotesPage} />
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
@@ -0,0 +1,148 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
useCreateServiceOrder,
getListMyServiceOrdersQueryKey,
} from "@workspace/api-client-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ImageIcon } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
const NOTES_MAX = 500;
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
service: {
id: number;
nameAr: string;
nameEn: string;
imageUrl?: string | null;
} | null;
resolveImage: (url: string | null | undefined) => string | null;
};
export function OrderServiceModal({
open,
onOpenChange,
service,
resolveImage,
}: Props) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const queryClient = useQueryClient();
const { toast } = useToast();
const [notes, setNotes] = useState("");
const [imgErrored, setImgErrored] = useState(false);
const createOrder = useCreateServiceOrder();
useEffect(() => {
if (open) {
setNotes("");
setImgErrored(false);
}
}, [open, service?.id]);
if (!service) return null;
const name = lang === "ar" ? service.nameAr : service.nameEn;
const img = resolveImage(service.imageUrl);
const handleSubmit = () => {
const trimmed = notes.trim();
createOrder.mutate(
{
data: {
serviceId: service.id,
notes: trimmed.length > 0 ? trimmed.slice(0, NOTES_MAX) : null,
},
},
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("services.orderPlaced") });
onOpenChange(false);
},
onError: () => {
toast({
title: t("services.orderFailed"),
variant: "destructive",
});
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
dir={lang === "ar" ? "rtl" : "ltr"}
>
<DialogHeader>
<DialogTitle>
{t("services.orderModalTitle", { name })}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-lg bg-slate-100 overflow-hidden flex items-center justify-center shrink-0">
{img && !imgErrored ? (
<img
src={img}
alt={name}
className="w-full h-full object-cover"
onError={() => setImgErrored(true)}
/>
) : (
<ImageIcon size={20} className="text-slate-300" />
)}
</div>
<div className="text-base font-medium text-foreground">{name}</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="order-notes">{t("services.orderNotes")}</Label>
<Textarea
id="order-notes"
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
placeholder={t("services.orderNotesPlaceholder")}
maxLength={NOTES_MAX}
rows={3}
/>
<div className="text-[11px] text-muted-foreground text-end">
{t("services.orderNotesCounter", { count: notes.length })}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={createOrder.isPending}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={createOrder.isPending}>
{createOrder.isPending
? t("services.placing")
: t("services.placeOrder")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
getListNotificationsQueryKey,
getGetHomeStatsQueryKey,
getListMyServiceOrdersQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/contexts/AuthContext";
@@ -20,9 +21,20 @@ export function useNotificationsSocket() {
path: `${BASE}/api/socket.io`,
});
socket.on("notification_created", () => {
socket.on("notification_created", (payload?: { type?: string }) => {
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetHomeStatsQueryKey() });
if (payload?.type === "order") {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
}
});
socket.on("order_updated", () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
});
return () => {
+34 -1
View File
@@ -104,7 +104,40 @@
"price": "السعر",
"noServices": "لا توجد خدمات متاحة",
"category": "التصنيف",
"free": "مجاني"
"free": "مجاني",
"order": "اطلب",
"orderModalTitle": "اطلب: {{name}}",
"orderNotes": "ملاحظات (اختياري)",
"orderNotesPlaceholder": "أي ملاحظات إضافية؟",
"orderNotesCounter": "{{count}}/500",
"placeOrder": "إرسال الطلب",
"placing": "جارٍ الإرسال...",
"orderPlaced": "تم إرسال طلبك",
"orderFailed": "تعذّر إرسال الطلب",
"myOrdersLink": "طلباتي"
},
"myOrders": {
"title": "طلباتي",
"loadError": "تعذّر تحميل الطلبات. حاول مرة أخرى.",
"retry": "إعادة المحاولة",
"empty": "لا توجد طلبات بعد",
"emptyHint": "ابدأ من كتالوج الخدمات.",
"browseServices": "تصفّح الخدمات",
"notesLabel": "ملاحظات:",
"cancel": "إلغاء الطلب",
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
"cancelConfirmBody": "لن يكون من الممكن إعادته بعد الإلغاء.",
"cancelConfirm": "نعم، إلغاء",
"keep": "احتفظ به",
"cancelled": "تم إلغاء الطلب",
"cancelFailed": "تعذّر إلغاء الطلب"
},
"orderStatus": {
"pending": "بانتظار المستلِم",
"received": "تم الاستلام",
"preparing": "قيد التحضير",
"completed": "مكتمل",
"cancelled": "ملغى"
},
"chat": {
"title": "المحادثات",
+34 -1
View File
@@ -104,7 +104,40 @@
"price": "Price",
"noServices": "No services available",
"category": "Category",
"free": "Free"
"free": "Free",
"order": "Order",
"orderModalTitle": "Order: {{name}}",
"orderNotes": "Notes (optional)",
"orderNotesPlaceholder": "Any extra notes?",
"orderNotesCounter": "{{count}}/500",
"placeOrder": "Place order",
"placing": "Sending...",
"orderPlaced": "Order placed",
"orderFailed": "Could not place the order",
"myOrdersLink": "My Orders"
},
"myOrders": {
"title": "My Orders",
"loadError": "Could not load your orders. Please try again.",
"retry": "Retry",
"empty": "No orders yet",
"emptyHint": "Start from the services catalog.",
"browseServices": "Browse services",
"notesLabel": "Notes:",
"cancel": "Cancel order",
"cancelConfirmTitle": "Cancel this order?",
"cancelConfirmBody": "Once cancelled, it can't be reopened.",
"cancelConfirm": "Yes, cancel",
"keep": "Keep it",
"cancelled": "Order cancelled",
"cancelFailed": "Could not cancel the order"
},
"orderStatus": {
"pending": "Awaiting receiver",
"received": "Received",
"preparing": "Preparing",
"completed": "Completed",
"cancelled": "Cancelled"
},
"chat": {
"title": "Chat",
+330
View File
@@ -0,0 +1,330 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import {
useListMyServiceOrders,
getListMyServiceOrdersQueryKey,
useUpdateServiceOrderStatus,
type ServiceOrder,
type ServiceOrderStatus,
} from "@workspace/api-client-react";
import {
ArrowLeft,
ArrowRight,
ImageIcon,
Inbox,
Check,
Clock,
Flame,
PackageCheck,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { useToast } from "@/hooks/use-toast";
import { formatDateTime } from "@/lib/i18n-format";
import { cn } from "@/lib/utils";
const TIMELINE_STEPS: ServiceOrderStatus[] = [
"pending",
"received",
"preparing",
"completed",
];
const STATUS_PILL: Record<ServiceOrderStatus, string> = {
pending: "bg-slate-200 text-slate-700",
received: "bg-sky-100 text-sky-700",
preparing: "bg-amber-100 text-amber-800",
completed: "bg-emerald-100 text-emerald-700",
cancelled: "bg-rose-100 text-rose-700",
};
function StatusPill({ status }: { status: ServiceOrderStatus }) {
const { t } = useTranslation();
return (
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium",
STATUS_PILL[status],
)}
>
{t(`orderStatus.${status}`)}
</span>
);
}
function StatusTimeline({ status }: { status: ServiceOrderStatus }) {
const { t } = useTranslation();
if (status === "cancelled") {
return (
<div className="flex items-center justify-center">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-rose-100 text-rose-700 text-xs font-semibold">
<XCircle size={14} />
{t("orderStatus.cancelled")}
</span>
</div>
);
}
const currentIdx = TIMELINE_STEPS.indexOf(status);
return (
<div className="flex items-center gap-1 w-full">
{TIMELINE_STEPS.map((step, i) => {
const reached = i <= currentIdx;
const Icon =
step === "pending"
? Clock
: step === "received"
? Check
: step === "preparing"
? Flame
: PackageCheck;
return (
<div key={step} className="flex items-center flex-1 min-w-0 last:flex-initial">
<div
className={cn(
"h-7 w-7 rounded-full flex items-center justify-center shrink-0 border",
reached
? "bg-primary text-primary-foreground border-primary"
: "bg-slate-100 text-slate-400 border-slate-200",
)}
title={t(`orderStatus.${step}`)}
aria-label={t(`orderStatus.${step}`)}
>
<Icon size={14} />
</div>
{i < TIMELINE_STEPS.length - 1 && (
<div
className={cn(
"h-0.5 flex-1 mx-1 rounded",
i < currentIdx ? "bg-primary" : "bg-slate-200",
)}
/>
)}
</div>
);
})}
</div>
);
}
function OrderImage({
src,
alt,
}: {
src: string | null;
alt: string;
}) {
const [errored, setErrored] = useState(false);
if (!src || errored) {
return (
<div className="w-16 h-16 rounded-lg bg-slate-100 flex items-center justify-center shrink-0">
<ImageIcon size={20} className="text-slate-300" />
</div>
);
}
return (
<img
src={src}
alt={alt}
className="w-16 h-16 rounded-lg object-cover shrink-0"
onError={() => setErrored(true)}
/>
);
}
function OrderCard({ order }: { order: ServiceOrder }) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const lang = i18n.language;
const [confirmOpen, setConfirmOpen] = useState(false);
const updateStatus = useUpdateServiceOrderStatus();
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
const img = resolveServiceImageUrl(order.service.imageUrl);
const cancellable =
order.status === "pending" || order.status === "received";
const handleCancel = () => {
updateStatus.mutate(
{ id: order.id, data: { status: "cancelled" } },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("myOrders.cancelled") });
setConfirmOpen(false);
},
onError: () => {
toast({
title: t("myOrders.cancelFailed"),
variant: "destructive",
});
},
},
);
};
return (
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-start gap-3">
<OrderImage src={img} alt={name} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground truncate">
{name}
</h3>
<StatusPill status={order.status} />
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatDateTime(order.createdAt, lang)}
</div>
{order.notes && (
<div className="mt-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">
{t("myOrders.notesLabel")}{" "}
</span>
{order.notes}
</div>
)}
</div>
</div>
<StatusTimeline status={order.status} />
{cancellable && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
onClick={() => setConfirmOpen(true)}
disabled={updateStatus.isPending}
>
{t("myOrders.cancel")}
</Button>
</div>
)}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("myOrders.cancelConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.cancelConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={updateStatus.isPending}>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleCancel();
}}
disabled={updateStatus.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.cancelConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function MyOrdersPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const lang = i18n.language;
const isRtl = lang === "ar";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const { data: orders, isLoading, isError, refetch } = useListMyServiceOrders({
query: { queryKey: getListMyServiceOrdersQueryKey() },
});
const sortedOrders = orders
? [...orders].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
: [];
return (
<div className="min-h-screen os-bg flex flex-col">
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
<button
onClick={() => setLocation("/services")}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("common.cancel")}
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2">
<Inbox size={20} className="text-amber-400" />
<h1 className="text-lg font-semibold text-foreground">
{t("myOrders.title")}
</h1>
</div>
</div>
<div className="flex-1 p-4 pb-8 max-w-2xl mx-auto w-full">
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
{t("common.loading")}
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<div className="text-sm text-rose-700">
{t("myOrders.loadError")}
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("myOrders.retry")}
</Button>
</div>
) : !sortedOrders.length ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Inbox size={36} className="text-slate-300" />
<div className="text-sm font-medium text-foreground">
{t("myOrders.empty")}
</div>
<div className="text-xs text-muted-foreground">
{t("myOrders.emptyHint")}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setLocation("/services")}
>
{t("myOrders.browseServices")}
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{sortedOrders.map((o) => (
<OrderCard key={o.id} order={o} />
))}
</div>
)}
</div>
</div>
);
}
+49 -1
View File
@@ -5,9 +5,18 @@ import {
useListServices,
getListServicesQueryKey,
} from "@workspace/api-client-react";
import { ArrowRight, ArrowLeft, Coffee, ImageIcon } from "lucide-react";
import { ArrowRight, ArrowLeft, Coffee, ImageIcon, Inbox } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { OrderServiceModal } from "@/components/order-service-modal";
type CatalogService = {
id: number;
nameAr: string;
nameEn: string;
imageUrl?: string | null;
};
function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) {
const [errored, setErrored] = useState(false);
@@ -38,6 +47,8 @@ export default function ServicesPage() {
query: { queryKey: getListServicesQueryKey() },
});
const [orderTarget, setOrderTarget] = useState<CatalogService | null>(null);
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
return (
@@ -54,6 +65,17 @@ export default function ServicesPage() {
<Coffee size={20} className="text-amber-400" />
<h1 className="text-lg font-semibold text-foreground">{t("services.title")}</h1>
</div>
<div className="ms-auto">
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => setLocation("/my-orders")}
>
<Inbox size={16} />
<span className="hidden sm:inline">{t("services.myOrdersLink")}</span>
</Button>
</div>
</div>
<div className="flex-1 p-4 pb-8">
@@ -91,6 +113,23 @@ export default function ServicesPage() {
{description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-snug">{description}</p>
)}
{service.isAvailable && (
<Button
size="sm"
className="mt-auto w-full"
onClick={() =>
setOrderTarget({
id: service.id,
nameAr: service.nameAr,
nameEn: service.nameEn,
imageUrl: service.imageUrl,
})
}
>
{t("services.order")}
</Button>
)}
</div>
</div>
);
@@ -98,6 +137,15 @@ export default function ServicesPage() {
</div>
)}
</div>
<OrderServiceModal
open={orderTarget !== null}
onOpenChange={(o) => {
if (!o) setOrderTarget(null);
}}
service={orderTarget}
resolveImage={resolveServiceImageUrl}
/>
</div>
);
}