Files
TX/artifacts/tx-os/src/pages/services.tsx
T
riyadhafraa d369447917 Drag-to-reorder service cards (iOS jiggle mode)
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
2026-05-20 11:53:47 +00:00

495 lines
16 KiB
TypeScript

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";
type CatalogService = {
id: number;
nameAr: string;
nameEn: string;
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;
return (
<div className="w-full aspect-square bg-slate-100 overflow-hidden flex items-center justify-center">
{showImage ? (
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onError={() => setErrored(true)}
draggable={false}
/>
) : (
<ImageIcon size={24} className="text-slate-300" />
)}
</div>
);
}
type SortableServiceCardProps = {
service: ServiceRow;
lang: string;
editMode: boolean;
isDragging: boolean;
canEnterEditMode: boolean;
onTapInView: () => void;
onLongPress: () => void;
unavailableLabel: string;
};
function SortableServiceCard({
service,
lang,
editMode,
isDragging,
canEnterEditMode,
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<number | null>(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<HTMLButtonElement>) => {
if (editMode || !canEnterEditMode) 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<HTMLButtonElement>) => {
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 (
<button
ref={setNodeRef}
type="button"
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
disabled={!editMode && !service.isAvailable}
aria-label={name}
style={style}
{...(editMode ? listeners : {})}
{...(editMode ? attributes : {})}
className={`glass-panel rounded-lg overflow-hidden flex flex-col card-hover text-start select-none disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${editMode ? "touch-none" : ""} ${jiggleClass} ${isDragging ? "opacity-90" : ""}`}
>
<ServiceCardImage src={resolvedImage} alt={name} />
<div className="p-2 flex flex-col gap-0.5 flex-1">
<div className="flex items-start justify-between gap-1">
<h3 className="text-xs font-semibold text-foreground leading-tight truncate">
{name}
</h3>
{!service.isAvailable && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 shrink-0">
{unavailableLabel}
</Badge>
)}
</div>
</div>
</button>
);
}
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<CatalogService | null>(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<number[] | null>(null);
const [activeDragId, setActiveDragId] = useState<number | null>(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 = () => {
// 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);
};
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<HTMLDivElement>(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 (
<div className="min-h-screen os-bg flex flex-col">
{/* 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. */}
<style>{`
@keyframes services-jiggle {
0% { transform: rotate(-1.2deg); }
50% { transform: rotate(1.2deg); }
100% { transform: rotate(-1.2deg); }
}
.animate-services-jiggle { animation: services-jiggle 0.25s ease-in-out infinite; transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
.animate-services-jiggle { animation: none; }
}
`}</style>
{/* Header */}
<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("/")}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2">
<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">
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
{t("common.loading")}
</div>
) : !services?.length ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
{t("services.noServices")}
</div>
) : (
<>
{editMode && (
<div className="text-center text-xs text-muted-foreground mb-3">
{t("services.editMode.hint")}
</div>
)}
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<SortableContext
items={orderedRows.map((s) => s.id)}
strategy={rectSortingStrategy}
>
<div
ref={gridRef}
className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-2"
>
{orderedRows.map((service) => (
<SortableServiceCard
key={service.id}
service={service}
lang={lang}
editMode={editMode}
isDragging={activeDragId === service.id}
canEnterEditMode={isAdmin}
onTapInView={() => {
if (!service.isAvailable) return;
setOrderTarget({
id: service.id,
nameAr: service.nameAr,
nameEn: service.nameEn,
imageUrl: service.imageUrl,
});
}}
onLongPress={onLongPress}
unavailableLabel={t("services.unavailable")}
/>
))}
</div>
</SortableContext>
</DndContext>
</>
)}
</div>
{editMode && (
<div className="fixed bottom-24 inset-x-0 flex justify-center z-40 pointer-events-none">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
exitEditMode();
}}
className="pointer-events-auto glass-panel rounded-full px-6 py-2 text-sm font-semibold text-foreground shadow-lg hover:bg-slate-100"
disabled={reorderMutation.isPending}
>
{reorderMutation.isPending
? t("common.loading")
: t("services.editMode.done")}
</button>
</div>
)}
<OrderServiceModal
open={orderTarget !== null}
onOpenChange={(o) => {
if (!o) setOrderTarget(null);
}}
service={orderTarget}
resolveImage={resolveServiceImageUrl}
/>
<AppDock currentSlug="services" />
</div>
);
}