Fazekit

Code

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

More in E-commerce

View all →