diff --git a/src/app/appoint-detail/[id]/page.tsx b/src/app/appoint-detail/[id]/page.tsx new file mode 100644 index 0000000..ab88a39 --- /dev/null +++ b/src/app/appoint-detail/[id]/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { HiArrowLeft, HiClock } from "react-icons/hi"; +import { viewAllAppointmentApi } from "@/lib/api"; +import { formatCategory, formatScheduleTime, getClientId } from "@/lib/appointments"; +import { formatNaira } from "@/lib/marketplace"; +import { getErrorMessage, type ViewAllAppointmentsResponse } from "@/lib/types"; +import EscrowTimeline from "@/components/EscrowTimeline"; +import Card from "@/components/ui/Card"; +import { buttonClasses } from "@/components/ui/Button"; + +export default function AppointmentDetailPage() { + const params = useParams(); + const router = useRouter(); + const id = Number(params.id); + const [appointment, setAppointment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + const clientId = getClientId(); + if (!clientId) { + setError("You need to be signed in."); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const res = await viewAllAppointmentApi(clientId); + const found = (res.data ?? []).find((a) => a.id === id); + if (found) { + setAppointment(found); + } else { + setError("Appointment not found."); + } + } catch (err) { + setError(getErrorMessage(err, "Failed to load appointment.")); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) { + return ( +
+

Loading appointment…

+
+ ); + } + + if (error || !appointment) { + return ( +
+ +

{error ?? "Appointment not found."}

+ +
+
+ ); + } + + const statusColor = + appointment.status === "CANCELLED" || appointment.status === "DECLINED" + ? "bg-err/15 text-err" + : appointment.status === "ACCEPTED" + ? "bg-ok/15 text-ok" + : "bg-gold/15 text-gold-deep"; + + return ( +
+ + +
+
+

+ {formatCategory(appointment.category)} +

+

+ {formatScheduleTime(appointment.scheduleTime)} +

+ {appointment.worker && ( +

+ with {appointment.worker.fullName} +

+ )} +
+
+ + {appointment.status.charAt(0) + appointment.status.slice(1).toLowerCase()} + + {appointment.amount !== null && ( + + {formatNaira(appointment.amount)} + + )} +
+
+ + void load()} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index e48d3c9..26dfe65 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -152,3 +152,123 @@ h1, h2, h3, h4 { button:not(:disabled), [role="button"] { cursor: pointer; } + +/* ── Escrow timeline ── */ + +.escrow-timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.escrow-timeline__item { + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; + padding-bottom: 24px; + opacity: 0.4; + transition: opacity 300ms ease; +} + +.escrow-timeline__item--active { + opacity: 1; +} + +.escrow-timeline__item--current .escrow-timeline__label { + color: var(--navy); +} + +.escrow-timeline__item:last-child { + padding-bottom: 0; +} + +.escrow-timeline__item:not(:last-child)::before { + content: ""; + position: absolute; + left: 15px; + top: 28px; + bottom: 0; + width: 2px; + background: var(--line); + z-index: 0; +} + +.escrow-timeline__item--active:not(:last-child)::before { + background: var(--navy); +} + +.escrow-timeline__item--terminal:not(:last-child)::before { + background: var(--err); +} + +.escrow-timeline__dot { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + margin-top: 1px; +} + +.escrow-timeline__dot-icon { + width: 24px; + height: 24px; + color: var(--line); + transition: color 300ms ease; +} + +.escrow-timeline__item--active .escrow-timeline__dot-icon { + color: var(--navy); +} + +.escrow-timeline__item--terminal .escrow-timeline__dot-icon { + color: var(--err); +} + +.escrow-timeline__dot-ring { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--line); + background: transparent; +} + +.escrow-timeline__content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.escrow-timeline__label { + font-size: 0.9rem; + font-weight: 600; + color: var(--muted); + transition: color 300ms ease; +} + +.escrow-timeline__item--active .escrow-timeline__label { + color: var(--ink); +} + +.escrow-timeline__desc { + margin: 0; + font-size: 0.82rem; + color: var(--muted); + line-height: 1.5; +} + +.escrow-timeline__time { + font-size: 0.75rem; + color: var(--muted); + font-family: var(--font-inter); + margin-top: 2px; +} diff --git a/src/components/EscrowTimeline.tsx b/src/components/EscrowTimeline.tsx new file mode 100644 index 0000000..6cd8df3 --- /dev/null +++ b/src/components/EscrowTimeline.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiCheckCircle, HiClock, HiXCircle, HiRefresh } from "react-icons/hi"; +import { updateAppointmentApi } from "@/lib/api"; +import { formatScheduleTime } from "@/lib/appointments"; +import { getErrorMessage, type AppointmentStatus, type ViewAllAppointmentsResponse } from "@/lib/types"; +import Card from "./ui/Card"; +import { buttonClasses } from "./ui/Button"; + +/* ── Escrow lifecycle stages ── */ + +interface Stage { + status: AppointmentStatus | "COMPLETED" | "RELEASED"; + label: string; + description: string; + icon: typeof HiCheckCircle; + terminal?: boolean; +} + +const STAGES: Stage[] = [ + { status: "SCHEDULED", label: "Scheduled", description: "Appointment booked — payment held in escrow", icon: HiClock }, + { status: "ACCEPTED", label: "Accepted", description: "Worker accepted — job in progress", icon: HiCheckCircle }, + { status: "COMPLETED", label: "Completed", description: "Job finished — awaiting payment release", icon: HiCheckCircle }, + { status: "RELEASED", label: "Released", description: "Payment released from escrow", icon: HiCheckCircle }, +]; + +const TERMINAL_STAGES: Record = { + CANCELLED: { label: "Cancelled", description: "Appointment was cancelled — escrow refunded" }, + DECLINED: { label: "Declined", description: "Worker declined — escrow refunded" }, +}; + +/* ── Status progression map ── */ + +const STATUS_ORDER: Record = { + SCHEDULED: 0, + ACCEPTED: 1, +}; + +/* ── Props ── */ + +type Props = { + appointment: ViewAllAppointmentsResponse; + onStatusChange?: () => void; +}; + +/* ── Component ── */ + +export default function EscrowTimeline({ appointment, onStatusChange }: Props) { + const [optimisticStatus, setOptimisticStatus] = useState(null); + const [updating, setUpdating] = useState(false); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + const currentStatus = optimisticStatus ?? appointment.status; + const isClosed = currentStatus === "CANCELLED" || currentStatus === "DECLINED"; + const currentIndex = STATUS_ORDER[currentStatus] ?? -1; + + const terminalStage = TERMINAL_STAGES[currentStatus]; + const isTerminal = terminalStage !== undefined; + + /* ── Optimistic update — accept appointment ── */ + + const markAccepted = useCallback(async () => { + if (updating) return; + setUpdating(true); + setError(null); + + const previousStatus = appointment.status; + setOptimisticStatus("ACCEPTED"); + + try { + await updateAppointmentApi(appointment.id, { status: "ACCEPTED" }); + onStatusChange?.(); + } catch (err) { + // Rollback: revert to the original status on failure. + if (mountedRef.current) { + setOptimisticStatus(previousStatus); + setError(getErrorMessage(err, "Failed to update status. The timeline has been reverted.")); + } + } finally { + if (mountedRef.current) { + setUpdating(false); + } + } + }, [appointment.id, appointment.status, onStatusChange, updating]); + + /* ── Render: timeline ── */ + + const renderTimeline = () => ( +
    + {STAGES.map((stage, index) => { + const isActive = index <= currentIndex; + const isCurrent = index === currentIndex; + + return ( +
  1. +
  2. + ); + })} + + {/* Terminal state (cancelled / declined) */} + {isTerminal && ( +
  3. + +
    + {terminalStage.label} +

    {terminalStage.description}

    +
    +
  4. + )} +
+ ); + + /* ── Render: actionable card ── */ + + const renderActions = () => { + if (isTerminal) return null; + + return ( +
+ {error && ( +

+ {error} +

+ )} + {currentStatus === "SCHEDULED" && ( + + )} +
+ ); + }; + + return ( + +
+ +

Escrow timeline

+
+

+ Track the payment lifecycle from booking to release. +

+ {renderTimeline()} + {renderActions()} +
+ ); +} \ No newline at end of file diff --git a/src/components/ViewAllAppointments.tsx b/src/components/ViewAllAppointments.tsx index 99fdce9..82792ff 100644 --- a/src/components/ViewAllAppointments.tsx +++ b/src/components/ViewAllAppointments.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { viewAllAppointmentApi } from "@/lib/api"; import { STATUS_TONE, @@ -19,6 +20,7 @@ export default function ViewAllAppointments() { const [appointments, setAppointments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const router = useRouter(); const load = useCallback(async () => { const clientId = getClientId(); @@ -79,7 +81,19 @@ export default function ViewAllAppointments() {
{appointments.map((appointment) => ( - + router.push(`/appoint-detail/${appointment.id}`)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + router.push(`/appoint-detail/${appointment.id}`); + } + }} + >