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:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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 () => {
|
||||
|
||||
@@ -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": "المحادثات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user