diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts index de44201b..76c6ccc9 100644 --- a/artifacts/api-server/src/routes/services.ts +++ b/artifacts/api-server/src/routes/services.ts @@ -15,6 +15,7 @@ import { GetServiceParams, UpdateServiceParams, DeleteServiceParams, + ReorderServicesBody, } from "@workspace/api-zod"; const router: IRouter = Router(); @@ -71,6 +72,55 @@ router.get("/service-categories", requireAuth, async (_req, res): Promise res.json(categories); }); +// Bulk reorder. Body carries the desired full ordering as a list of +// service ids; the server validates it covers the entire catalogue +// exactly once (no missing/duplicate/unknown ids) and then rewrites +// `sort_order` to the 0-based index. Wrapped in a transaction so the +// catalogue never observes a partially-applied order. Declared BEFORE +// the `/services/:id` routes so Express doesn't try to parse "reorder" +// as a numeric id and return a 400 from GetServiceParams. +router.post("/services/reorder", requireAdmin, async (req, res): Promise => { + const parsed = ReorderServicesBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { orderedIds } = parsed.data; + + const existing = await db + .select({ id: servicesTable.id }) + .from(servicesTable); + const existingIds = new Set(existing.map((s) => s.id)); + + if (orderedIds.length !== existingIds.size) { + res.status(400).json({ + error: "orderedIds must contain every service id exactly once", + }); + return; + } + const seen = new Set(); + for (const id of orderedIds) { + if (!existingIds.has(id) || seen.has(id)) { + res.status(400).json({ + error: "orderedIds must contain every service id exactly once", + }); + return; + } + seen.add(id); + } + + await db.transaction(async (tx) => { + for (let i = 0; i < orderedIds.length; i++) { + await tx + .update(servicesTable) + .set({ sortOrder: i }) + .where(eq(servicesTable.id, orderedIds[i])); + } + }); + + res.sendStatus(204); +}); + router.get("/services/:id", requireAuth, async (req, res): Promise => { const params = GetServiceParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d250e339..360dd5dd 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -114,7 +114,12 @@ "placing": "جارٍ الإرسال...", "orderPlaced": "تم إرسال طلبك", "orderFailed": "تعذّر إرسال الطلب", - "myOrdersLink": "طلباتي" + "myOrdersLink": "طلباتي", + "editMode": { + "hint": "اسحب لإعادة الترتيب", + "done": "تم", + "saveFailed": "تعذّر حفظ الترتيب الجديد" + } }, "myOrders": { "title": "طلباتي", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index f464bc1c..c6f3c55f 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -114,7 +114,12 @@ "placing": "Sending...", "orderPlaced": "Order placed", "orderFailed": "Could not place the order", - "myOrdersLink": "My Orders" + "myOrdersLink": "My Orders", + "editMode": { + "hint": "Drag to rearrange", + "done": "Done", + "saveFailed": "Could not save the new order" + } }, "myOrders": { "title": "My Orders", diff --git a/artifacts/tx-os/src/pages/services.tsx b/artifacts/tx-os/src/pages/services.tsx index f7d46eb9..ee390e47 100644 --- a/artifacts/tx-os/src/pages/services.tsx +++ b/artifacts/tx-os/src/pages/services.tsx @@ -1,13 +1,33 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useListServices, getListServicesQueryKey, + useReorderServices, } from "@workspace/api-client-react"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowRight, ArrowLeft, Coffee, ImageIcon, Inbox } from "lucide-react"; +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; import { resolveServiceImageUrl } from "@/lib/image-url"; import { OrderServiceModal } from "@/components/order-service-modal"; import { AppDock } from "@/components/app-dock"; @@ -19,6 +39,14 @@ type CatalogService = { imageUrl?: string | null; }; +type ServiceRow = { + id: number; + nameAr: string; + nameEn: string; + imageUrl?: string | null; + isAvailable: boolean; +}; + function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) { const [errored, setErrored] = useState(false); const showImage = src && !errored; @@ -30,6 +58,7 @@ function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) { alt={alt} className="w-full h-full object-cover" onError={() => setErrored(true)} + draggable={false} /> ) : ( @@ -38,22 +67,310 @@ function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) { ); } +type SortableServiceCardProps = { + service: ServiceRow; + lang: string; + editMode: boolean; + isDragging: boolean; + onTapInView: () => void; + onLongPress: () => void; + unavailableLabel: string; +}; + +function SortableServiceCard({ + service, + lang, + editMode, + isDragging, + onTapInView, + onLongPress, + unavailableLabel, +}: SortableServiceCardProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: thisDragging, + } = useSortable({ id: service.id, disabled: !editMode }); + + const name = lang === "ar" ? service.nameAr : service.nameEn; + const resolvedImage = resolveServiceImageUrl(service.imageUrl); + + // Long-press detection — only fires when NOT already in edit mode. + // We track pointer position so a scroll/drag of more than 10 px + // cancels the timer (matches iOS behaviour and prevents accidental + // entry during fast scrolling). + const pressTimer = useRef(null); + const pressStart = useRef<{ x: number; y: number } | null>(null); + const longPressed = useRef(false); + + const clearTimer = () => { + if (pressTimer.current !== null) { + window.clearTimeout(pressTimer.current); + pressTimer.current = null; + } + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (editMode) return; + longPressed.current = false; + pressStart.current = { x: e.clientX, y: e.clientY }; + pressTimer.current = window.setTimeout(() => { + longPressed.current = true; + pressTimer.current = null; + try { + navigator.vibrate?.(10); + } catch { + /* not supported — ignore */ + } + onLongPress(); + }, 500); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (editMode || pressStart.current === null) return; + const dx = e.clientX - pressStart.current.x; + const dy = e.clientY - pressStart.current.y; + if (dx * dx + dy * dy > 100) clearTimer(); + }; + + const handlePointerEnd = () => { + clearTimer(); + pressStart.current = null; + }; + + const handleClick = (e: React.MouseEvent) => { + // Swallow the click that follows a long-press so the order modal + // doesn't open the instant we enter edit mode. + if (longPressed.current) { + longPressed.current = false; + e.preventDefault(); + e.stopPropagation(); + return; + } + if (editMode) { + e.preventDefault(); + return; + } + onTapInView(); + }; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: thisDragging ? 20 : undefined, + } as React.CSSProperties; + + // Jiggle every card while editing, except the one being actively + // dragged (its own motion already conveys intent and the rotation + // would fight the drag transform). + const jiggleClass = editMode && !thisDragging ? "animate-services-jiggle" : ""; + + return ( + + ); +} + export default function ServicesPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); + const { user } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); const lang = i18n.language; const isRtl = lang === "ar"; + const isAdmin = user?.roles?.includes("admin") ?? false; const { data: services, isLoading } = useListServices({ query: { queryKey: getListServicesQueryKey() }, }); + const reorderMutation = useReorderServices(); + const [orderTarget, setOrderTarget] = useState(null); + const [editMode, setEditMode] = useState(false); + // Local working copy of the list — diverges from the server data + // only while the user is actively reordering, so query refetches + // don't clobber an in-progress drag session. + const [orderedIds, setOrderedIds] = useState(null); + const [activeDragId, setActiveDragId] = useState(null); + + // Sync local order from server data while NOT editing. Once the + // user enters edit mode we hold the order locally until they tap + // Done; the mutation's invalidation then refills from the server. + useEffect(() => { + if (!editMode && services) { + setOrderedIds(services.map((s) => s.id)); + } + }, [services, editMode]); + + const orderedRows: ServiceRow[] = useMemo(() => { + if (!services) return []; + const byId = new Map(services.map((s) => [s.id, s])); + const ids = orderedIds ?? services.map((s) => s.id); + const rows: ServiceRow[] = []; + for (const id of ids) { + const s = byId.get(id); + if (s) rows.push(s); + } + // Append any services that arrived mid-edit (admin added one in + // another tab) so the grid never loses cards on refetch. + for (const s of services) { + if (!ids.includes(s.id)) rows.push(s); + } + return rows; + }, [services, orderedIds]); + + // Touch sensor uses delay so a tap-to-open isn't misread as a drag; + // pointer sensor needs only the tiny activation distance so mouse + // drags feel responsive on the desktop preview. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 8 }, + }), + ); const BackIcon = isRtl ? ArrowRight : ArrowLeft; + // Prevents two concurrent exits (e.g. the floating Done button and + // the document-level outside-tap handler both firing for the same + // gesture). Without this we'd see double POSTs to /services/reorder. + const exitingRef = useRef(false); + + const exitEditMode = async () => { + if (!editMode || exitingRef.current) return; + exitingRef.current = true; + setActiveDragId(null); + + const serverIds = (services ?? []).map((s) => s.id); + const localIds = orderedIds ?? serverIds; + const changed = + localIds.length !== serverIds.length || + localIds.some((id, i) => id !== serverIds[i]); + + try { + if (!changed) { + setEditMode(false); + return; + } + if (!isAdmin) { + // Non-admins can enter edit mode for the haptic affordance but + // can't persist — silently revert. + setOrderedIds(serverIds); + setEditMode(false); + return; + } + + try { + await reorderMutation.mutateAsync({ data: { orderedIds: localIds } }); + await queryClient.invalidateQueries({ + queryKey: getListServicesQueryKey(), + }); + // Only drop edit mode after the refetch resolves so the + // sync-from-server effect doesn't briefly rehydrate from the + // stale pre-mutation cache and snap the order back. + setEditMode(false); + } catch (err) { + toast({ + title: t("services.editMode.saveFailed"), + variant: "destructive", + }); + setOrderedIds(serverIds); + setEditMode(false); + } + } finally { + exitingRef.current = false; + } + }; + + const onLongPress = () => { + if (!editMode) setEditMode(true); + }; + + const onDragStart = (e: DragStartEvent) => { + setActiveDragId(Number(e.active.id)); + }; + + const onDragEnd = (e: DragEndEvent) => { + setActiveDragId(null); + const { active, over } = e; + if (!over || active.id === over.id) return; + setOrderedIds((prev) => { + const base = prev ?? (services ?? []).map((s) => s.id); + const from = base.indexOf(Number(active.id)); + const to = base.indexOf(Number(over.id)); + if (from < 0 || to < 0) return base; + return arrayMove(base, from, to); + }); + }; + + // Tap outside the grid (on the empty page area) exits edit mode. + const gridRef = useRef(null); + useEffect(() => { + if (!editMode) return; + const onDocPointerDown = (ev: PointerEvent) => { + const target = ev.target as Node | null; + if (target && gridRef.current && !gridRef.current.contains(target)) { + // Defer so the click on the Done button (which sits outside + // the grid) still gets to fire its own handler first. + setTimeout(() => exitEditMode(), 0); + } + }; + document.addEventListener("pointerdown", onDocPointerDown); + return () => document.removeEventListener("pointerdown", onDocPointerDown); + // exitEditMode closes over editMode/orderedIds — re-bind each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editMode, orderedIds]); + return (
+ {/* Inline keyframes — no Tailwind plugin available for arbitrary + keyframes and we'd rather not pull tailwindcss-animate just + for this one effect. Kept here so the page is self-contained. */} + + {/* Header */}
@@ -89,48 +410,70 @@ export default function ServicesPage() { {t("services.noServices")} ) : ( -
- {services.map((service) => { - const name = lang === "ar" ? service.nameAr : service.nameEn; - const resolvedImage = resolveServiceImageUrl(service.imageUrl); - const openOrder = () => { - if (!service.isAvailable) return; - setOrderTarget({ - id: service.id, - nameAr: service.nameAr, - nameEn: service.nameEn, - imageUrl: service.imageUrl, - }); - }; - - return ( - - ); - })} -
+ {orderedRows.map((service) => ( + { + if (!service.isAvailable) return; + setOrderTarget({ + id: service.id, + nameAr: service.nameAr, + nameEn: service.nameEn, + imageUrl: service.imageUrl, + }); + }} + onLongPress={onLongPress} + unavailableLabel={t("services.unavailable")} + /> + ))} + + + + )} + {editMode && ( +
+ +
+ )} + { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index e34b53c2..5f842963 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -643,6 +643,21 @@ export interface UpdateServiceBody { sortOrder?: number; } +/** + * New global service order. `orderedIds` must contain every +existing service id exactly once. The server sets each +service's `sortOrder` to its position in the list (0-based), +so a later GET /services returns them in the same order. + + */ +export interface ReorderServicesBody { + /** + * @minItems 1 + * @maxItems 1000 + */ + orderedIds: number[]; +} + export interface ServiceCategory { id: number; nameAr: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index f9a4843b..d2b9873d 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -89,6 +89,7 @@ import type { PermissionAuditList, ReceivedNote, RegisterBody, + ReorderServicesBody, ReplaceRolePermissionsBody, ReplyToNoteBody, RequestUploadUrlBody, @@ -2660,6 +2661,98 @@ export const useCreateService = < return useMutation(getCreateServiceMutationOptions(options)); }; +/** + * Persists a new global `sortOrder` for the service catalogue by +applying the index of each id in `orderedIds`. Admin-only. +The body must contain every existing service id exactly once; +partial reorders are rejected with 400 so the client can never +accidentally drop services from the sort plan. + + * @summary Replace the global service sort order (admin) + */ +export const getReorderServicesUrl = () => { + return `/api/services/reorder`; +}; + +export const reorderServices = async ( + reorderServicesBody: ReorderServicesBody, + options?: RequestInit, +): Promise => { + return customFetch(getReorderServicesUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(reorderServicesBody), + }); +}; + +export const getReorderServicesMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["reorderServices"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return reorderServices(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ReorderServicesMutationResult = NonNullable< + Awaited> +>; +export type ReorderServicesMutationBody = BodyType; +export type ReorderServicesMutationError = ErrorType; + +/** + * @summary Replace the global service sort order (admin) + */ +export const useReorderServices = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getReorderServicesMutationOptions(options)); +}; + /** * @summary Get service by ID */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0ab1b7c1..e8a3f6f6 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -730,6 +730,33 @@ paths: schema: $ref: "#/components/schemas/Service" + /services/reorder: + post: + operationId: reorderServices + tags: [services] + summary: Replace the global service sort order (admin) + description: | + Persists a new global `sortOrder` for the service catalogue by + applying the index of each id in `orderedIds`. Admin-only. + The body must contain every existing service id exactly once; + partial reorders are rejected with 400 so the client can never + accidentally drop services from the sort plan. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReorderServicesBody" + responses: + "204": + description: Sort order updated + "400": + description: Validation error or mismatched id set + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /services/{id}: get: operationId: getService @@ -4031,6 +4058,24 @@ components: sortOrder: type: integer + ReorderServicesBody: + type: object + description: | + New global service order. `orderedIds` must contain every + existing service id exactly once. The server sets each + service's `sortOrder` to its position in the list (0-based), + so a later GET /services returns them in the same order. + properties: + orderedIds: + type: array + minItems: 1 + maxItems: 1000 + items: + type: integer + minimum: 1 + required: + - orderedIds + ServiceCategory: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index ceb34993..fab4bcf5 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1117,6 +1117,29 @@ export const CreateServiceBody = zod.object({ sortOrder: zod.number().optional(), }); +/** + * Persists a new global `sortOrder` for the service catalogue by +applying the index of each id in `orderedIds`. Admin-only. +The body must contain every existing service id exactly once; +partial reorders are rejected with 400 so the client can never +accidentally drop services from the sort plan. + + * @summary Replace the global service sort order (admin) + */ + +export const reorderServicesBodyOrderedIdsMax = 1000; + +export const ReorderServicesBody = zod + .object({ + orderedIds: zod + .array(zod.number().min(1)) + .min(1) + .max(reorderServicesBodyOrderedIdsMax), + }) + .describe( + "New global service order. `orderedIds` must contain every\nexisting service id exactly once. The server sets each\nservice's `sortOrder` to its position in the list (0-based),\nso a later GET \/services returns them in the same order.\n", + ); + /** * @summary Get service by ID */