Shitaye-FrontEnd/src/app/guest/laundry/LaundryClient.tsx
“kirukib” 202897e83f merge: integrate origin/main with guest portal updates
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
2026-04-27 20:15:43 +03:00

237 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}