Resolve merge conflicts between upstream auth/data refactors and local guest portal navigation updates, then align imports and room data so the combined branch builds successfully. Made-with: Cursor
237 lines
9.9 KiB
TypeScript
237 lines
9.9 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { RequireAuth } from "@/components/RequireAuth";
|
||
import { useAuth } from "@/context/AuthContext";
|
||
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
|
||
import { formatEtb } from "@/lib/format-etb";
|
||
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
|
||
import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/data/laundryCatalog";
|
||
|
||
export function LaundryClient() {
|
||
return (
|
||
<RequireAuth redirectTo="/guest/login">
|
||
<LaundryInner />
|
||
</RequireAuth>
|
||
);
|
||
}
|
||
|
||
function LaundryInner() {
|
||
const { accessToken } = useAuth();
|
||
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
|
||
|
||
// Form states
|
||
const [cart, setCart] = useState<Record<string, number>>({});
|
||
const [sameDay, setSameDay] = useState(false);
|
||
const [pickupAt, setPickupAt] = useState("");
|
||
const [deliverAt, setDeliverAt] = useState("");
|
||
const [notes, setNotes] = useState("");
|
||
const [sent, setSent] = useState(false);
|
||
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const canUseApi = !!(propertyId && accessToken && bookingId);
|
||
|
||
// Compute total
|
||
const total = useCallback(() => {
|
||
let sum = 0;
|
||
for (const [label, qty] of Object.entries(cart)) {
|
||
if (qty > 0) {
|
||
const price = laundryItems.find(item => item.label.toLowerCase() === label.toLowerCase())?.price || 0;
|
||
sum += price * qty;
|
||
}
|
||
}
|
||
if (sameDay) sum += SAME_DAY_SURCHARGE;
|
||
return sum;
|
||
}, [cart, sameDay]);
|
||
|
||
const [displayTotal, setDisplayTotal] = useState(0);
|
||
useEffect(() => {
|
||
setDisplayTotal(total());
|
||
}, [total]);
|
||
|
||
// Build items Json
|
||
const buildItems = (): LaundryCartItem[] =>
|
||
Object.entries(cart)
|
||
.filter(([, qty]) => qty > 0)
|
||
.map(([label, quantity]) => ({ label: laundryItems.find(i => i.label.toLowerCase() === label.toLowerCase())?.label || label, quantity }));
|
||
|
||
async function submit() {
|
||
if (!canUseApi || buildItems().length === 0) {
|
||
setSubmitErr("Please select at least one item.");
|
||
return;
|
||
}
|
||
setSubmitErr(null);
|
||
setSubmitting(true);
|
||
try {
|
||
await guestPlaceLaundry(propertyId!, accessToken!, {
|
||
bookingId: bookingId!,
|
||
items: buildItems(),
|
||
sameDay,
|
||
total: displayTotal,
|
||
// currency: "ETB",
|
||
notes: notes.trim() || undefined,
|
||
pickupAt: pickupAt || undefined,
|
||
deliverAt: deliverAt || undefined,
|
||
});
|
||
setSubmitting(false);
|
||
setCart({});
|
||
setSameDay(false);
|
||
setPickupAt("");
|
||
setDeliverAt("");
|
||
setNotes("");
|
||
setSent(true);
|
||
} catch (e) {
|
||
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
const needBooking = !bookingLoading && !bookingId;
|
||
const hasItems = buildItems().length > 0;
|
||
|
||
const updateQty = (label: string, delta: number) => {
|
||
setCart(prev => {
|
||
const current = prev[label] || 0;
|
||
const newQty = Math.max(0, current + delta);
|
||
const newCart = { ...prev };
|
||
if (newQty === 0) delete newCart[label];
|
||
else newCart[label] = newQty;
|
||
return newCart;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="bg-[var(--color-bg)] pb-24 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>
|
||
<Link href="/guest" className="hover:text-[var(--color-accent)]">Guest hub</Link>
|
||
<span className="mx-2 opacity-50">/</span>
|
||
<span className="text-[var(--color-text)]">Laundry</span>
|
||
</nav>
|
||
|
||
<div className="mt-6 flex flex-col gap-4 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">Laundry service</h1>
|
||
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
|
||
</div>
|
||
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||
View profile →
|
||
</Link>
|
||
</div>
|
||
|
||
{needBooking ? (
|
||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||
Sign in with a booking code or use a reservation to sync laundry with the hotel.
|
||
</div>
|
||
) : null}
|
||
|
||
{submitErr ? (
|
||
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{submitErr}</div>
|
||
) : null}
|
||
|
||
{sent ? (
|
||
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
|
||
Laundry request submitted successfully.
|
||
</div>
|
||
) : null}
|
||
|
||
{!sent && (
|
||
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
|
||
{/* Items Selection */}
|
||
<div className="space-y-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
||
<label className="text-base font-semibold text-[var(--color-text)] block mb-2">Select items</label>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{laundryItems.map((item) => {
|
||
const qty = cart[item.label.toLowerCase()] || 0;
|
||
return (
|
||
<div key={item.label} className="border border-[var(--color-border)] rounded-xl p-4 bg-[var(--color-surface-muted)]">
|
||
<div className="font-medium text-[var(--color-text)] mb-1">{item.label}</div>
|
||
<div className="text-sm text-[var(--color-muted)] mb-3">{formatEtb(item.price)} / each</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateQty(item.label.toLowerCase(), -1)}
|
||
disabled={qty === 0}
|
||
className="w-10 h-10 rounded-lg border bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50 flex items-center justify-center"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="w-16 text-center font-mono text-lg font-semibold">{qty}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateQty(item.label.toLowerCase(), 1)}
|
||
className="w-10 h-10 rounded-lg border bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-dark)] flex items-center justify-center"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary & Form */}
|
||
<div className="space-y-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
||
<div className="text-xl font-bold text-[var(--color-text)]">
|
||
{formatEtb(displayTotal)} {sameDay && <span className="text-sm text-[var(--color-accent)]">(incl. same-day)</span>}
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={sameDay}
|
||
onChange={(e) => setSameDay(e.target.checked)}
|
||
className="w-4 h-4 rounded"
|
||
/>
|
||
<span className="text-sm font-medium text-[var(--color-text)]">Express same-day (+{formatEtb(SAME_DAY_SURCHARGE)})</span>
|
||
</label>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Pickup</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={pickupAt}
|
||
onChange={(e) => setPickupAt(e.target.value)}
|
||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Delivery</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={deliverAt}
|
||
onChange={(e) => setDeliverAt(e.target.value)}
|
||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
rows={3}
|
||
placeholder="Special instructions (e.g., no starch, delicate)..."
|
||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
||
/>
|
||
|
||
<button
|
||
onClick={submit}
|
||
disabled={submitting || !hasItems || !canUseApi}
|
||
className="w-full rounded-xl bg-[var(--color-accent)] px-6 py-3 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|