Shitaye-FrontEnd/src/components/ReviewsMenu.tsx
2026-04-14 15:44:34 +03:00

326 lines
12 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 {
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.comstyle 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>
);
}