326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useRef,
|
||
useState,
|
||
useSyncExternalStore,
|
||
} from "react";
|
||
import { createPortal } from "react-dom";
|
||
import {
|
||
bookingStyleReviews,
|
||
overallRatingOutOfFive,
|
||
} from "@/lib/data/bookingReviews";
|
||
import { siteConfig } from "@/lib/site-config";
|
||
|
||
function useIsClient() {
|
||
return useSyncExternalStore(
|
||
() => () => {},
|
||
() => true,
|
||
() => false,
|
||
);
|
||
}
|
||
|
||
type ReviewsMenuProps = { variant?: "default" | "topBar" };
|
||
|
||
export function ReviewsMenu({ variant = "default" }: ReviewsMenuProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const isTopBar = variant === "topBar";
|
||
const mounted = useIsClient();
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
|
||
const close = useCallback(() => setOpen(false), []);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
const triggerEl = triggerRef.current;
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === "Escape") close();
|
||
}
|
||
document.addEventListener("keydown", onKey);
|
||
queueMicrotask(() => {
|
||
panelRef.current
|
||
?.querySelector<HTMLElement>("button[aria-label='Close']")
|
||
?.focus();
|
||
});
|
||
|
||
return () => {
|
||
document.body.style.overflow = prev;
|
||
document.removeEventListener("keydown", onKey);
|
||
triggerEl?.focus();
|
||
};
|
||
}, [open, close]);
|
||
|
||
const dialog =
|
||
open && mounted ? (
|
||
<div
|
||
className="fixed inset-0 z-[100] overflow-x-hidden overflow-y-auto overscroll-contain"
|
||
role="presentation"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="fixed inset-0 bg-black/50 backdrop-blur-[2px]"
|
||
aria-label="Close reviews dialog"
|
||
onClick={close}
|
||
/>
|
||
{/* min-h-full = full viewport inside fixed inset-0; items-center centers the card */}
|
||
<div
|
||
className="flex min-h-full w-full items-center justify-center px-4 py-6 sm:px-6 sm:py-10"
|
||
style={{
|
||
paddingTop: "max(1.5rem, env(safe-area-inset-top, 0px))",
|
||
paddingBottom: "max(1.5rem, env(safe-area-inset-bottom, 0px))",
|
||
paddingLeft: "max(1rem, env(safe-area-inset-left, 0px))",
|
||
paddingRight: "max(1rem, env(safe-area-inset-right, 0px))",
|
||
}}
|
||
>
|
||
<div
|
||
ref={panelRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="reviews-dialog-title"
|
||
className="relative z-10 flex min-h-0 w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
|
||
style={{ maxHeight: "min(calc(100dvh - 3rem), 640px)" }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--color-border)] px-4 py-4 sm:px-5 sm:py-4">
|
||
<div className="min-w-0 flex-1 pr-2">
|
||
<BookingDotLogo />
|
||
<h2
|
||
id="reviews-dialog-title"
|
||
className="mt-3 font-heading text-lg text-[var(--color-text)] sm:text-xl"
|
||
>
|
||
Guest reviews
|
||
</h2>
|
||
<p className="mt-1 hidden text-xs text-[var(--color-muted)] sm:block sm:text-sm">
|
||
Sample scores for layout — connect your live Booking.com page when ready.
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 sm:mt-4 sm:gap-3">
|
||
<CircleRatingRow rating={overallRatingOutOfFive} />
|
||
<span className="text-base font-semibold tabular-nums text-[var(--color-text)] sm:text-lg">
|
||
{overallRatingOutOfFive}
|
||
<span className="text-xs font-normal text-[var(--color-muted)] sm:text-sm">
|
||
{" "}
|
||
/ 5
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={close}
|
||
className="shrink-0 rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
|
||
aria-label="Close"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||
<path
|
||
d="M6 6l12 12M18 6L6 18"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<ul className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 py-3 sm:px-5 sm:py-4">
|
||
{bookingStyleReviews.map((r) => (
|
||
<li
|
||
key={r.id}
|
||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-sm"
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<p className="font-semibold text-[var(--color-text)]">{r.author}</p>
|
||
<p className="text-xs text-[var(--color-muted)]">
|
||
{r.country} · {r.stayDate}
|
||
</p>
|
||
</div>
|
||
<span className="badge-mustard shrink-0 tabular-nums">
|
||
{r.rating.toFixed(1)}/{r.maxRating}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 font-medium text-[var(--color-text)]">{r.title}</p>
|
||
<p className="mt-1 line-clamp-3 leading-relaxed text-[var(--color-muted)] sm:line-clamp-none">
|
||
{r.text}
|
||
</p>
|
||
<p className="mt-2 text-xs text-[var(--color-muted)]">Stayed in: {r.roomType}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="shrink-0 border-t border-[var(--color-border)] p-4 sm:p-5">
|
||
<Link
|
||
href={siteConfig.bookingComReviewsUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="btn-mustard flex w-full flex-col items-center justify-center gap-2 px-4 py-3 text-center text-sm sm:flex-row sm:gap-3"
|
||
onClick={close}
|
||
>
|
||
<BookingDotLogo compact />
|
||
<span>Read all reviews</span>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
onClick={() => setOpen(true)}
|
||
className={
|
||
isTopBar
|
||
? "flex items-center gap-2 rounded-md border border-white/20 px-2.5 py-1 text-xs font-medium text-white/95 transition hover:border-white/35 hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:gap-2 sm:px-3 sm:text-sm"
|
||
: "flex items-center gap-1.5 rounded-full border border-transparent px-2 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[var(--color-border)] hover:bg-[var(--color-surface-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] sm:gap-2 sm:px-3"
|
||
}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={open}
|
||
aria-label={`Guest reviews, ${overallRatingOutOfFive} out of 5`}
|
||
>
|
||
<span className={isTopBar ? "inline" : "hidden sm:inline"}>Reviews</span>
|
||
<span
|
||
className={
|
||
isTopBar
|
||
? "badge-mustard flex items-center gap-1 px-2 py-0.5"
|
||
: "badge-mustard flex items-center gap-1 px-2 py-1 sm:py-0.5"
|
||
}
|
||
>
|
||
<span>{overallRatingOutOfFive}</span>
|
||
<span className={isTopBar ? "inline" : "hidden sm:inline"}>
|
||
<CircleRatingMini rating={overallRatingOutOfFive} />
|
||
</span>
|
||
</span>
|
||
</button>
|
||
|
||
{mounted && dialog ? createPortal(dialog, document.body) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Booking.com–style wordmark: “Booking” + yellow dot + “.com” */
|
||
function BookingDotLogo({
|
||
className = "",
|
||
compact = false,
|
||
}: {
|
||
className?: string;
|
||
compact?: boolean;
|
||
}) {
|
||
if (compact) {
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-0.5 font-bold tracking-tight text-white ${className}`}
|
||
>
|
||
<span className="text-[15px]">Booking</span>
|
||
<span
|
||
className="mx-px inline-block h-2 w-2 shrink-0 rounded-full bg-[#febb02]"
|
||
aria-hidden
|
||
/>
|
||
<span className="text-[15px]">.com</span>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<div className={`inline-flex items-baseline gap-0 ${className}`}>
|
||
<span className="text-xl font-bold tracking-tight text-[var(--color-primary)] sm:text-2xl">Booking</span>
|
||
<span
|
||
className="mx-0.5 inline-block h-2 w-2 shrink-0 translate-y-0.5 rounded-full bg-[#febb02] sm:h-2.5 sm:w-2.5"
|
||
aria-hidden
|
||
/>
|
||
<span className="text-xl font-bold tracking-tight text-[var(--color-primary)] sm:text-2xl">.com</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Five circles: filled amount per position = min(1, max(0, rating - i)) */
|
||
function CircleRatingRow({ rating, max = 5 }: { rating: number; max?: number }) {
|
||
const uid = useId().replace(/:/g, "");
|
||
const size = 26;
|
||
const vb = 24;
|
||
const cx = 12;
|
||
const cy = 12;
|
||
const r = 8;
|
||
const stroke = "#d6d3d1";
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5" aria-label={`${rating} out of ${max}`}>
|
||
{Array.from({ length: max }, (_, i) => {
|
||
const fill = Math.min(1, Math.max(0, rating - i));
|
||
const clipW = vb * fill;
|
||
const clipId = `${uid}-clip-${i}`;
|
||
return (
|
||
<svg
|
||
key={i}
|
||
width={size}
|
||
height={size}
|
||
viewBox={`0 0 ${vb} ${vb}`}
|
||
className="shrink-0"
|
||
aria-hidden
|
||
>
|
||
<circle cx={cx} cy={cy} r={r} fill="none" stroke={stroke} strokeWidth="1.75" />
|
||
{fill > 0 ? (
|
||
<>
|
||
<defs>
|
||
<clipPath id={clipId}>
|
||
<rect x="0" y="0" width={clipW} height={vb} />
|
||
</clipPath>
|
||
</defs>
|
||
<circle
|
||
cx={cx}
|
||
cy={cy}
|
||
r={r}
|
||
fill="#febb02"
|
||
clipPath={`url(#${clipId})`}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</svg>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CircleRatingMini({ rating }: { rating: number }) {
|
||
const uid = useId().replace(/:/g, "");
|
||
const max = 5;
|
||
const vb = 24;
|
||
const cx = 12;
|
||
const cy = 12;
|
||
const r = 7;
|
||
|
||
return (
|
||
<span className="inline-flex items-center gap-px" aria-hidden>
|
||
{Array.from({ length: max }, (_, i) => {
|
||
const fill = Math.min(1, Math.max(0, rating - i));
|
||
const clipW = vb * fill;
|
||
const clipId = `${uid}-m-${i}`;
|
||
return (
|
||
<svg key={i} width="10" height="10" viewBox={`0 0 ${vb} ${vb}`} className="shrink-0">
|
||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="2" />
|
||
{fill > 0 ? (
|
||
<>
|
||
<defs>
|
||
<clipPath id={clipId}>
|
||
<rect x="0" y="0" width={clipW} height={vb} />
|
||
</clipPath>
|
||
</defs>
|
||
<circle cx={cx} cy={cy} r={r} fill="#febb02" clipPath={`url(#${clipId})`} />
|
||
</>
|
||
) : null}
|
||
</svg>
|
||
);
|
||
})}
|
||
</span>
|
||
);
|
||
}
|