333 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|