"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Building2, Heart, LayoutGrid, List, Map as MapIcon, Mountain, Sailboat, SlidersHorizontal, Sun, TreePine, Waves, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_FILTERS, DEFAULT_LISTINGS, makeDateFmt, makeMoney, nightsBetween, PRICE_BOUNDS, rangeBlocked, SETTING_LABEL } from "./data";
import { FiltersSheet } from "./filters-sheet";
import { ListingCard } from "./listing-card";
import { ListingDetail } from "./listing-detail";
import { StayMap } from "./map";
import { SearchBar } from "./search-bar";
import type { Filters, Listing, Reservation, SearchState, Setting, SortKey } from "./types";
import { focusRing } from "./ui";
export type { Listing, Reservation, Filters, SearchState } from "./types";
export { generateListings } from "./data";
const CATS: { key: Setting | "all"; label: string; icon: LucideIcon }[] = [
{ key: "all", label: "All stays", icon: LayoutGrid },
{ key: "mountain", label: SETTING_LABEL.mountain, icon: Mountain },
{ key: "coast", label: SETTING_LABEL.coast, icon: Waves },
{ key: "forest", label: SETTING_LABEL.forest, icon: TreePine },
{ key: "lake", label: SETTING_LABEL.lake, icon: Sailboat },
{ key: "desert", label: SETTING_LABEL.desert, icon: Sun },
{ key: "city", label: SETTING_LABEL.city, icon: Building2 },
];
export interface PropertyMarketplaceAppProps {
listings?: Listing[];
currency?: string;
locale?: string;
brand?: string;
initialSearch?: Partial<SearchState>;
initialFavourites?: string[];
onReserve?: (r: Reservation) => void;
onFavouritesChange?: (ids: string[]) => void;
onSearch?: (s: SearchState, f: Filters) => void;
className?: string;
}
function BrandMark() {
const gid = `orbit${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
return (
<svg viewBox="0 0 32 32" className="size-8 shrink-0" aria-hidden>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#f43f5e" />
<stop offset="1" stopColor="#c026d3" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="10" fill={`url(#${gid})`} />
<path d="M9 21.5 16 9l7 12.5" fill="none" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="16" cy="19" r="2.6" fill="#fff" />
</svg>
);
}
export function PropertyMarketplaceApp({ listings = DEFAULT_LISTINGS, currency = "USD", locale = "en-US", brand = "orbit", initialSearch, initialFavourites = ["stay-03", "stay-12"], onReserve, onFavouritesChange, onSearch, className }: PropertyMarketplaceAppProps) {
const reduce = useReducedMotion();
const money = React.useMemo(() => makeMoney(currency, locale), [currency, locale]);
const df = React.useMemo(() => makeDateFmt(locale), [locale]);
const [search, setSearch] = React.useState<SearchState>({ location: "", range: { start: null, end: null }, guests: 0, ...initialSearch });
const [filters, setFilters] = React.useState<Filters>(DEFAULT_FILTERS);
const [cat, setCat] = React.useState<Setting | "all">("all");
const [sort, setSort] = React.useState<SortKey>("recommended");
const [favs, setFavs] = React.useState<Set<string>>(() => new Set(initialFavourites));
const [favOnly, setFavOnly] = React.useState(false);
const [hovered, setHovered] = React.useState<string | null>(null);
const [selected, setSelected] = React.useState<string | null>(null);
const [openId, setOpenId] = React.useState<string | null>(null);
const [mobileMap, setMobileMap] = React.useState(false);
const [filtersOpen, setFiltersOpen] = React.useState(false);
const [trips, setTrips] = React.useState<Reservation[]>([]);
const listRef = React.useRef<HTMLDivElement>(null);
const uid = React.useId();
const match = React.useCallback(
(l: Listing, f: Filters, c: Setting | "all" = cat) => {
const q = search.location.trim().toLowerCase();
if (q && ![l.location, l.region, l.title].some((s) => s.toLowerCase().includes(q))) return false;
if (search.guests > l.maxGuests) return false;
if (search.range.start && search.range.end && rangeBlocked(l, search.range.start, search.range.end)) return false;
if (c !== "all" && l.setting !== c) return false;
if (favOnly && !favs.has(l.id)) return false;
if (l.pricePerNight < f.price[0] || (f.price[1] < PRICE_BOUNDS[1] && l.pricePerNight > f.price[1])) return false;
if (f.types.length && !f.types.includes(l.type)) return false;
if (f.amenities.some((a) => !l.amenities.includes(a))) return false;
if (l.bedrooms < f.minBedrooms) return false;
if (f.superhost && !l.host.superhost) return false;
if (f.instantBook && !l.instantBook) return false;
return true;
},
[search, cat, favOnly, favs],
);
const results = React.useMemo(() => {
const r = listings.filter((l) => match(l, filters));
if (sort === "price-asc") r.sort((a, b) => a.pricePerNight - b.pricePerNight);
else if (sort === "price-desc") r.sort((a, b) => b.pricePerNight - a.pricePerNight);
else if (sort === "rating") r.sort((a, b) => b.rating - a.rating);
return r;
}, [listings, match, filters, sort]);
const nights = search.range.start && search.range.end ? nightsBetween(search.range.start, search.range.end) : 0;
const filterCount =
(filters.price[0] > PRICE_BOUNDS[0] || filters.price[1] < PRICE_BOUNDS[1] ? 1 : 0) + filters.types.length + filters.amenities.length + (filters.minBedrooms ? 1 : 0) + (filters.superhost ? 1 : 0) + (filters.instantBook ? 1 : 0);
const open = listings.find((l) => l.id === openId) ?? null;
const places = React.useMemo(() => {
const seen = new Map<string, string>();
for (const l of listings) if (!seen.has(l.location)) seen.set(l.location, `${l.region} · ${SETTING_LABEL[l.setting]}`);
return [...seen].map(([name, hint]) => ({ name, hint }));
}, [listings]);
const toggleFav = (id: string) => {
const n = new Set(favs);
if (n.has(id)) n.delete(id);
else n.add(id);
setFavs(n);
onFavouritesChange?.([...n]);
};
const selectFromMap = (id: string | null) => {
setSelected(id);
if (id && !mobileMap) listRef.current?.querySelector(`[data-listing="${id}"]`)?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "nearest" });
};
const openListing = (id: string) => {
setOpenId(id);
setSelected(null);
setHovered(null);
};
React.useEffect(() => {
onSearch?.(search, filters);
// eslint-disable-next-line react-hooks/exhaustive-deps -- notify on change only
}, [search, filters]);
const summary = [search.location || "Anywhere", search.range.start && search.range.end ? `${df.short(search.range.start)} – ${df.short(search.range.end)}` : null, search.guests ? `${search.guests} guests` : null].filter(Boolean).join(" · ");
return (
<div className={cn("relative flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<header className="relative z-40 shrink-0 border-b bg-background">
<div className="flex items-center gap-3 px-4 py-3 sm:px-6">
<button type="button" onClick={() => setOpenId(null)} className={cn("hidden items-center gap-2 rounded-xl md:flex", focusRing)} aria-label={`${brand} home`}>
<BrandMark />
<span className="text-lg font-bold tracking-tight">{brand}</span>
</button>
<div className="flex min-w-0 flex-1 justify-center">
<SearchBar
value={search}
places={places}
onChange={(s) => {
setSearch(s);
setSelected(null);
}}
onSubmit={() => setOpenId(null)}
/>
</div>
<button
type="button"
onClick={() => {
setFavOnly((v) => !v);
setOpenId(null);
}}
aria-pressed={favOnly}
className={cn("relative inline-flex h-10 shrink-0 items-center gap-2 rounded-full border px-3 text-sm font-medium transition hover:shadow-md", favOnly && "border-foreground bg-foreground text-background", focusRing)}
>
<Heart className={cn("size-4", favs.size > 0 && !favOnly && "fill-rose-500 text-rose-500")} aria-hidden />
<span className="hidden sm:inline">Saved</span>
<span className="tabular-nums" aria-label={`${favs.size} saved`}>
{favs.size}
</span>
</button>
<span className="hidden size-9 shrink-0 place-items-center rounded-full bg-gradient-to-br from-zinc-700 to-zinc-900 text-xs font-semibold text-white lg:grid dark:from-zinc-300 dark:to-zinc-500 dark:text-zinc-900" aria-hidden>
AR
</span>
</div>
{!open && (
<div className="flex items-center gap-3 px-4 pb-2.5 sm:px-6">
<div className="-mx-1 flex min-w-0 flex-1 gap-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="radiogroup" aria-label="Stay category">
<LayoutGroup id={`${uid}-cats`}>
{CATS.map((c) => {
const on = cat === c.key;
return (
<button
key={c.key}
type="button"
role="radio"
aria-checked={on}
onClick={() => setCat(c.key)}
className={cn("relative flex shrink-0 flex-col items-center gap-1 px-3 pb-2 pt-1 text-xs font-medium transition-colors", on ? "text-foreground" : "text-muted-foreground hover:text-foreground", "rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring")}
>
<c.icon className="size-5" aria-hidden />
{c.label}
{on && <motion.span layoutId="cat-underline" className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-foreground" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
</button>
);
})}
</LayoutGroup>
</div>
<button type="button" onClick={() => setFiltersOpen(true)} className={cn("relative inline-flex h-10 shrink-0 items-center gap-2 rounded-xl border px-3 text-sm font-medium transition hover:border-foreground", filterCount > 0 && "border-foreground bg-accent/60", focusRing)}>
<SlidersHorizontal className="size-4" aria-hidden />
<span className="hidden sm:inline">Filters</span>
{filterCount > 0 && <span className="grid size-5 place-items-center rounded-full bg-foreground text-[11px] font-bold text-background tabular-nums">{filterCount}</span>}
</button>
</div>
)}
</header>
<main className="flex min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
{open ? (
<motion.div key={`detail-${open.id}`} className="min-w-0 flex-1" initial={reduce ? false : { opacity: 0, x: 24 }} animate={{ opacity: 1, x: 0 }} exit={reduce ? undefined : { opacity: 0, x: 24 }} transition={{ duration: 0.22 }}>
<ListingDetail
listing={open}
money={money}
currency={currency}
range={search.range}
guests={search.guests}
favourite={favs.has(open.id)}
onFavourite={() => toggleFav(open.id)}
onRange={(r) => setSearch((s) => ({ ...s, range: r }))}
onGuests={(g) => setSearch((s) => ({ ...s, guests: g }))}
onBack={() => setOpenId(null)}
onReserve={(r) => {
setTrips((t) => [...t, r]);
onReserve?.(r);
}}
/>
</motion.div>
) : (
<motion.div key="results" className="flex min-w-0 flex-1" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.18 }}>
<div ref={listRef} className={cn("min-w-0 flex-1 overflow-y-auto overscroll-contain", mobileMap && "hidden lg:block")}>
<div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-1 pt-4 sm:px-6">
<div className="min-w-0">
<h1 className="text-base font-semibold" aria-live="polite">
{results.length} {results.length === 1 ? "stay" : "stays"}
{favOnly ? " saved" : ""}
{trips.length > 0 && <span className="ml-2 rounded-full bg-emerald-500/12 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:text-emerald-300">{trips.length} upcoming trip{trips.length > 1 ? "s" : ""}</span>}
</h1>
<p className="truncate text-xs text-muted-foreground">{summary}</p>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
Sort
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)} className={cn("h-8 rounded-lg border bg-background px-2 text-xs font-medium text-foreground", focusRing)}>
<option value="recommended">Recommended</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
<option value="rating">Top rated</option>
</select>
</label>
</div>
{results.length === 0 ? (
<div className="grid place-items-center px-6 py-20 text-center">
<div>
<div className="mx-auto grid size-14 place-items-center rounded-2xl bg-muted">
<MapIcon className="size-6 text-muted-foreground" aria-hidden />
</div>
<p className="mt-3 font-semibold">No exact matches</p>
<p className="mt-1 text-sm text-muted-foreground">Try changing or removing some of your filters or dates.</p>
<button
type="button"
onClick={() => {
setFilters(DEFAULT_FILTERS);
setCat("all");
setFavOnly(false);
setSearch({ location: "", range: { start: null, end: null }, guests: 0 });
}}
className={cn("mt-4 h-10 rounded-xl border border-foreground px-4 text-sm font-semibold hover:bg-accent", focusRing)}
>
Remove all filters
</button>
</div>
</div>
) : (
<div className="grid gap-x-3 gap-y-4 px-2.5 pb-24 pt-2 sm:grid-cols-2 sm:px-4.5 lg:pb-6 xl:grid-cols-2">
<AnimatePresence initial={false} mode="popLayout">
{results.map((l) => (
<ListingCard
key={l.id}
listing={l}
money={money}
nights={nights}
active={hovered === l.id || selected === l.id}
favourite={favs.has(l.id)}
onFavourite={() => toggleFav(l.id)}
onOpen={() => openListing(l.id)}
onHover={(on) => setHovered((h) => (on ? l.id : h === l.id ? null : h))}
/>
))}
</AnimatePresence>
</div>
)}
</div>
<StayMap
listings={results}
activeId={hovered}
selectedId={selected}
favourites={favs}
money={money}
onHover={setHovered}
onSelect={selectFromMap}
onOpen={openListing}
className={cn("shrink-0 border-l lg:block lg:w-[42%]", mobileMap ? "block flex-1" : "hidden")}
/>
<button
type="button"
onClick={() => setMobileMap((v) => !v)}
className={cn("absolute bottom-5 left-1/2 z-30 inline-flex h-11 -translate-x-1/2 items-center gap-2 rounded-full bg-foreground px-5 text-sm font-semibold text-background shadow-xl transition hover:scale-105 lg:hidden", focusRing)}
>
{mobileMap ? (
<>
<List className="size-4" aria-hidden /> Show list
</>
) : (
<>
<MapIcon className="size-4" aria-hidden /> Show map
</>
)}
</button>
</motion.div>
)}
</AnimatePresence>
</main>
<FiltersSheet
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
value={filters}
listings={listings}
money={money}
countFor={(f) => listings.filter((l) => match(l, f)).length}
onApply={(f) => {
setFilters(f);
setFiltersOpen(false);
}}
/>
</div>
);
}
export default PropertyMarketplaceApp;