Shitaye-FrontEnd/src/app/profile/ProfilePageClient.tsx

333 lines
13 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import {
guestMe,
guestOrders,
guestPointsHistory,
guestSpaBookings,
guestShuttles,
type PointLedgerRow,
type SpaBookingRow,
type ShuttleRow,
} from "@/lib/guest-hotel-api";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" },
{ id: "room-service", label: "Room service" },
{ id: "laundry", label: "Laundry" },
{ id: "gym", label: "Gym" },
{ id: "spa", label: "Spa" },
];
function formatWhen(iso: string) {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
function OrderRow({ o }: { o: OrderRecord }) {
return (
<li className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-3 text-sm">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-semibold text-[var(--color-text)]">{o.title}</p>
<p className="mt-0.5 text-xs text-[var(--color-muted)]">{o.detail}</p>
<p className="mt-1 text-xs text-[var(--color-muted)]">{formatWhen(o.placedAt)}</p>
</div>
<div className="text-right">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)]">
{o.status}
</span>
<p className="font-semibold text-[var(--color-text)]">${o.totalUsd.toFixed(0)}</p>
</div>
</div>
</li>
);
}
export function ProfilePageClient() {
return (
<RequireAuth>
<ProfileContent />
</RequireAuth>
);
}
function ProfileContent() {
const { session, logout, accessToken } = useAuth();
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
const [apiBalance, setApiBalance] = useState<number | null>(null);
const [apiLedger, setApiLedger] = useState<PointLedgerRow[]>([]);
const [apiOrders, setApiOrders] = useState<OrderRecord[]>([]);
const [appointments, setAppointments] = useState<SpaBookingRow[]>([]);
const [apiShuttles, setApiShuttles] = useState<ShuttleRow[]>([]);
useEffect(() => {
if (!accessToken || !session) return;
const pid = session.propertyId;
if (!pid) return;
let cancelled = false;
(async () => {
try {
const me = await guestMe(pid, accessToken);
const ph = await guestPointsHistory(pid, accessToken);
const ord = await guestOrders(pid, accessToken);
const spa = await guestSpaBookings(pid, accessToken);
let shuttles: ShuttleRow[] = [];
if (session.bookingId) {
try {
const sh = await guestShuttles(pid, session.bookingId, accessToken);
shuttles = (sh.data ?? []).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} catch {
// Ignore if shuttles fail (e.g. no access or 404)
}
}
if (!cancelled) {
setApiBalance(me.balance);
setApiLedger(ph.data ?? []);
setApiOrders(
(ord.data ?? []).map((o) => ({
id: o.id,
category: o.type,
title:
o.type === "room-service"
? "Room Service Order"
: o.type === "laundry"
? "Laundry Request"
: o.type === "gym"
? "Gym Booking"
: "Spa Booking",
detail: o.detail,
totalUsd: Number(o.total ?? 0),
placedAt: o.createdAt,
status: (["pending", "confirmed", "completed"].includes(o.status.toLowerCase())
? o.status.toLowerCase()
: "pending") as OrderRecord["status"],
})),
);
setAppointments(spa.data ?? []);
setApiShuttles(shuttles);
}
} catch {
if (!cancelled) {
setApiOrders([]);
setAppointments([]);
setApiShuttles([]);
}
}
})();
return () => {
cancelled = true;
};
}, [accessToken, session]);
const filteredOrders = useMemo(() => {
if (orderFilter === "all") return apiOrders;
return apiOrders.filter((o) => o.category === orderFilter);
}, [apiOrders, orderFilter]);
if (!session) {
return null;
}
return (
<div className="bg-[var(--color-bg)] pb-20 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<nav className="text-xs font-medium text-[var(--color-muted)]">
<Link href="/" className="hover:text-[var(--color-accent)]">
Home
</Link>
<span className="mx-2 opacity-50">/</span>
<span className="text-[var(--color-text)]">My stay</span>
</nav>
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Hello, {session.displayName}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{session.bookingCode ? (
<>
{" · "}
Booking code{" "}
<span className="font-mono font-semibold text-[var(--color-text)]">
{session.bookingCode}
</span>
</>
) : null}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href="/guest" className="btn-mustard px-5 py-2.5 text-sm">
Guest hub
</Link>
<button
type="button"
onClick={() => logout()}
className="rounded-full border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
>
Sign out
</button>
</div>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-3">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points
</p>
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{(apiBalance ?? session.points).toLocaleString()}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{apiBalance != null ? "Balance" : "Balance unavailable"}
</p>
</>
</div>
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
{appointments.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No gym/spa bookings found.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{appointments.map((a) => (
<li
key={a.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{a.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">
{a.offering?.name ?? "Spa/Gym booking"}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{formatWhen(a.scheduledAt ?? a.createdAt)}
</p>
</li>
))}
</ul>
)}
</section>
{session.bookingId && (
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Airport shuttle</h2>
{apiShuttles.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No airport shuttles requested.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{apiShuttles.map((s) => (
<li
key={s.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{s.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">
{s.direction === "AIRPORT_TO_HOTEL" ? "Airport pickup (to hotel)" : s.direction === "HOTEL_TO_AIRPORT" ? "Hotel drop-off (to airport)" : s.direction.replace(/_/g, " ")}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Requested for: {formatWhen(s.requestedAt)}
</p>
{s.flightRef && (
<p className="mt-1 text-sm text-[var(--color-muted)]">Flight: {s.flightRef}</p>
)}
</li>
))}
</ul>
)}
</section>
)}
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Room service, laundry, gym, and spa
</p>
<div className="mt-4 flex flex-wrap gap-2">
{orderTabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setOrderFilter(t.id)}
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:text-sm ${
orderFilter === t.id
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
}`}
>
{t.label}
</button>
))}
</div>
{filteredOrders.length === 0 ? (
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-10 text-center text-sm text-[var(--color-muted)]">
No orders in this category yet.
</p>
) : (
<ul className="mt-6 space-y-3">
{filteredOrders.map((o) => (
<OrderRow key={o.id} o={o} />
))}
</ul>
)}
</section>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards history</h2>
<ul className="mt-4 space-y-2">
{apiLedger.length > 0
? apiLedger.map((r) => (
<li
key={r.id}
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{r.reason.replace(/_/g, " ")}</p>
<p className="text-xs text-[var(--color-muted)]">{formatWhen(r.createdAt)}</p>
</div>
<span
className={
r.delta >= 0 ? "badge-mustard" : "rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800"
}
>
{r.delta >= 0 ? "+" : ""}
{r.delta} pts
</span>
</li>
))
: null}
</ul>
{apiLedger.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No rewards history returned yet.</p>
) : null}
</section>
</div>
</div>
);
}