From d369447917e5937ab788243e8ff458a89adcadc2 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 20 May 2026 11:53:47 +0000 Subject: [PATCH] Drag-to-reorder service cards (iOS jiggle mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-press a service tile (~500 ms) on the Services page → grid enters edit mode: every card jiggles, a floating "تم / Done" pill appears, and cards become draggable via @dnd-kit. Drop on another tile to reorder. Tap Done or outside the grid to persist; order is shared globally via servicesTable.sortOrder. Per the task default: admin-only. Non-admin users never see the jiggle/drag affordance — their long-press is a no-op so they get no misleading "false success" interaction. Backend - PATCH /api/services/reorder (admin-gated, requireAdmin) - Validates full-set match (no missing/dup/unknown IDs) — full catalogue rewrite, not a partial reorder - Transactional sortOrder rewrite — no partial writes - Route registered before /services/:id to avoid path-param collision - OpenAPI op + ReorderServicesBody Zod schema → regenerated client Frontend - artifacts/tx-os/src/pages/services.tsx - PointerSensor (distance 6) + TouchSensor (delay 150 ms) so a tap stays distinct from a drag - Long-press timer (500 ms, cancelled on >10 px move) only attaches for admins; non-admins fall through to the normal tap-to-order flow - touch-none class is applied only while in edit mode so normal page scrolling is unaffected outside it - exitEditMode awaits mutation + cache invalidate before flipping editMode, preventing snap-back from the stale react-query cache - exitingRef guard prevents duplicate POSTs when both Done and the outside-tap handler fire for the same gesture - i18n: services.editMode.{done,hint,saveFailed} in ar.json + en.json --- artifacts/api-server/src/routes/services.ts | 2 +- artifacts/tx-os/src/pages/services.tsx | 8 +++++++- lib/api-client-react/src/generated/api.ts | 2 +- lib/api-spec/openapi.yaml | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts index 76c6ccc9..a3ded34a 100644 --- a/artifacts/api-server/src/routes/services.ts +++ b/artifacts/api-server/src/routes/services.ts @@ -79,7 +79,7 @@ router.get("/service-categories", requireAuth, async (_req, res): Promise // 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 => { +router.patch("/services/reorder", requireAdmin, async (req, res): Promise => { const parsed = ReorderServicesBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); diff --git a/artifacts/tx-os/src/pages/services.tsx b/artifacts/tx-os/src/pages/services.tsx index ee390e47..53b3135e 100644 --- a/artifacts/tx-os/src/pages/services.tsx +++ b/artifacts/tx-os/src/pages/services.tsx @@ -72,6 +72,7 @@ type SortableServiceCardProps = { lang: string; editMode: boolean; isDragging: boolean; + canEnterEditMode: boolean; onTapInView: () => void; onLongPress: () => void; unavailableLabel: string; @@ -82,6 +83,7 @@ function SortableServiceCard({ lang, editMode, isDragging, + canEnterEditMode, onTapInView, onLongPress, unavailableLabel, @@ -114,7 +116,7 @@ function SortableServiceCard({ }; const handlePointerDown = (e: React.PointerEvent) => { - if (editMode) return; + if (editMode || !canEnterEditMode) return; longPressed.current = false; pressStart.current = { x: e.clientX, y: e.clientY }; pressTimer.current = window.setTimeout(() => { @@ -316,6 +318,9 @@ export default function ServicesPage() { }; const onLongPress = () => { + // Reordering is admin-only — silently ignore long-press for everyone + // else so non-admins never see a jiggle UI they can't persist. + if (!isAdmin) return; if (!editMode) setEditMode(true); }; @@ -436,6 +441,7 @@ export default function ServicesPage() { lang={lang} editMode={editMode} isDragging={activeDragId === service.id} + canEnterEditMode={isAdmin} onTapInView={() => { if (!service.isAvailable) return; setOrderTarget({ diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index d2b9873d..76ed1394 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -2680,7 +2680,7 @@ export const reorderServices = async ( ): Promise => { return customFetch(getReorderServicesUrl(), { ...options, - method: "POST", + method: "PATCH", headers: { "Content-Type": "application/json", ...options?.headers }, body: JSON.stringify(reorderServicesBody), }); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index e8a3f6f6..e4df9543 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -731,7 +731,7 @@ paths: $ref: "#/components/schemas/Service" /services/reorder: - post: + patch: operationId: reorderServices tags: [services] summary: Replace the global service sort order (admin)