Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/app/appoint-detail/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewAllAppointmentsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto max-w-lg px-6 py-16">
<p className="text-sm text-muted">Loading appointment…</p>
</div>
);
}

if (error || !appointment) {
return (
<div className="mx-auto max-w-lg px-6 py-16">
<Card className="p-6">
<p className="text-err text-sm">{error ?? "Appointment not found."}</p>
<button
onClick={() => router.push("/view")}
className={buttonClasses("secondary", "md")}
>
← Back to appointments
</button>
</Card>
</div>
);
}

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 (
<div className="mx-auto max-w-lg px-6 py-16">
<button
onClick={() => router.back()}
className="flex items-center gap-1.5 text-sm text-muted hover:text-ink mb-6 transition-colors"
>
<HiArrowLeft size={16} />
Back
</button>

<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="font-heading text-2xl font-semibold text-ink">
{formatCategory(appointment.category)}
</h1>
<p className="mt-1 text-sm text-muted">
{formatScheduleTime(appointment.scheduleTime)}
</p>
{appointment.worker && (
<p className="mt-0.5 text-sm text-muted">
with {appointment.worker.fullName}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1.5">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor}`}
>
{appointment.status.charAt(0) + appointment.status.slice(1).toLowerCase()}
</span>
{appointment.amount !== null && (
<span className="text-sm font-semibold tabular-nums text-ink">
{formatNaira(appointment.amount)}
</span>
)}
</div>
</div>

<EscrowTimeline
appointment={appointment}
onStatusChange={() => void load()}
/>
</div>
);
}
120 changes: 120 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading