d369447917
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
495 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|