SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
11.0 KB · 282 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Location court terme3// pages/CourtTermeFiche.tsx : fiche d'un hébergement court terme.4//   Ordre DOM = ordre visuel, identique mobile ET desktop (standard Groupe Ka5//   « Ordre des sections — pages détail ») : galerie → prix + titre + chips +6//   CTA → description → commodités → détails pratiques → carte.7// -----------------------------------------------------------------------------8import { lazy, Suspense, useEffect, useRef, useState } from "react";9import { Link, useParams } from "react-router-dom";10import {11  CtContext, CtListing, ctSourceCategory, ctSourceName, fetchCtContext,12  fetchCtListing, fetchCtSources, fmtNight, registerCtSourceNames,13} from "../ctapi";14import SmartImg from "../components/SmartImg";15import CtListingCard from "../components/CtListingCard";16import CtPriceAnalysis, { ctDealBadge } from "../components/CtPriceAnalysis";17import { IcoAlert } from "../components/Icons";1819const CtFicheMap = lazy(() => import("../components/CtFicheMap"));2021const NBSP = " ";2223const PETS_LABEL: Record<string, string> = {24  oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions",25};2627const DETAIL_LABELS: Record<string, string> = {28  spa: "Spa", pool: "Piscine", waterfront: "Bord de l'eau", sauna: "Sauna",29  wifi: "Wi-Fi", fireplace: "Foyer", ev_charger: "Borne de recharge",30};3132function Galerie({ images, titre, typeLabel }:33                 { images: string[]; titre: string; typeLabel?: string }) {34  const [idx, setIdx] = useState(0);35  const track = useRef<HTMLDivElement>(null);3637  const onScroll = () => {38    const el = track.current;39    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));40  };41  const goto = (i: number) =>42    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });4344  if (images.length === 0)45    return (46      <div className="carousel">47        <SmartImg src={null} fallbackLabel={typeLabel}48                  alt="Aucune photo fournie par la source" />49      </div>50    );5152  return (53    <>54      <div className="carousel">55        <div className="carousel-track" ref={track} onScroll={onScroll}>56          {images.map((u, i) => (57            <SmartImg58              key={u} src={u} width_={800} fallbackLabel={typeLabel}59              loading={i === 0 ? "eager" : "lazy"} decoding="async"60              alt={`${titre} — photo ${i + 1} de ${images.length}`}61            />62          ))}63        </div>64        <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>65        {idx > 0 && (66          <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>67        )}68        {idx < images.length - 1 && (69          <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>70        )}71      </div>72      {images.length > 1 && (73        <div className="thumbs">74          {images.map((u, i) => (75            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}76                    aria-label={`Photo ${i + 1}`}>77              <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />78            </button>79          ))}80        </div>81      )}82    </>83  );84}8586export default function CourtTermeFichePage() {87  const { uid } = useParams<{ uid: string }>();88  const [l, setL] = useState<CtListing | null>(null);89  const [ctx, setCtx] = useState<CtContext | null>(null);90  const [error, setError] = useState<string | null>(null);9192  useEffect(() => {93    fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {});94    if (!uid) return;95    setCtx(null);96    fetchCtListing(uid).then(setL).catch((e) => setError(String(e)));97    fetchCtContext(uid).then(setCtx).catch(() => setCtx(null));98    window.scrollTo(0, 0);99  }, [uid]);100101  if (error)102    return (103      <div className="notice container">104        <div className="big"><IcoAlert size={40} /></div>105        <h2>Hébergement introuvable</h2>106        <p>{error}</p>107        <Link className="btn btn-primary" to="/court-terme">Retour au court terme</Link>108      </div>109    );110111  if (!l)112    return (113      <div className="container detail">114        <div className="fiche" aria-busy="true">115          <div className="skel"><div className="sk-img" /></div>116          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>117        </div>118      </div>119    );120121  const chips: string[] = [];122  if (l.property_type) chips.push(l.property_type);123  if (l.capacity) chips.push(`${l.capacity} personnes`);124  if (l.bedrooms) chips.push(`${l.bedrooms} chambre${l.bedrooms > 1 ? "s" : ""}`);125  if (l.beds) chips.push(`${l.beds} lit${l.beds > 1 ? "s" : ""}`);126  if (l.bathrooms) chips.push(`${l.bathrooms} salle${l.bathrooms > 1 ? "s" : ""} de bain`);127  if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);128  if (l.citq) chips.push(`CITQ ${l.citq}`);129130  const flags = Object.entries(DETAIL_LABELS)131    .filter(([k]) => l.details?.[k] === true || l.details?.[k] === 1)132    .map(([, label]) => label);133  const autres = l.amenities.filter(134    (a) => !flags.some((b) => b.toLowerCase() === a.toLowerCase()));135136  const ctaLabel = ctSourceCategory(l.source) === "hotels"137    ? "Réserver en direct à l'hôtel"138    : `Réserver chez ${ctSourceName(l.source)}`;139140  const synced = l.last_seen141    ? new Date(l.last_seen * 1000).toLocaleDateString("fr-CA", {142        day: "numeric", month: "long", year: "numeric" }) : null;143144  return (145    <div className="container detail">146      <nav className="crumbs" aria-label="Fil d'Ariane">147        <Link to="/court-terme">Court terme</Link> ›148        {l.region && <span>{l.region}</span>} ›149        <span>{l.title}</span>150      </nav>151152      <div className="fiche">153        <div className="f-col">154          <section className="f-bloc f-galerie" aria-label="Photos">155            <Galerie images={l.images ?? []} titre={l.title}156                     typeLabel={l.property_type || "Séjour"} />157          </section>158159          <section className="f-bloc f-hero">160            <div className="price">161              {fmtNight(l.price_night, l.price_label)}162              {l.price_night != null && <small> /{NBSP}nuit</small>}163            </div>164            {(() => {165              const badge = ctDealBadge(ctx?.price ?? null);166              return badge && (167                <div className={`deal-badge ${badge.cls}`}>{badge.txt}</div>168              );169            })()}170            {l.rating != null && (171              <div className="deal-badge deal-ok">172                ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} / 5173                {l.reviews ? ` · ${l.reviews} avis` : ""}174              </div>175            )}176            <h1>{l.title}</h1>177            <div className="loc">178              {[l.address, l.city, l.region].filter(Boolean).join(" · ")}179            </div>180            <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">181              {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}182            </div>183            <a className="cta cta-desktop" href={l.url}184               target="_blank" rel="noopener noreferrer">185              {ctaLabel} ↗186            </a>187          </section>188189          <section className="f-bloc f-desc" id="description">190            <h2>Description</h2>191            {l.description192              ? <p style={{ color: "var(--ink-2)", whiteSpace: "pre-line" }}>{l.description}</p>193              : <p className="fine">La source ne fournit pas de description pour cet hébergement.</p>}194          </section>195196          <section className="f-bloc f-incl" id="commodites">197            <h2>Commodités</h2>198            <div className="amenity-row">199              {flags.map((b) => (200                <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span>201              ))}202              {autres.map((a) => (203                <span className="amenity unconfirmed" key={a}>{a}</span>204              ))}205            </div>206            {flags.length === 0 && autres.length === 0 && (207              <p className="fine">La source ne précise pas les commodités.</p>208            )}209          </section>210211          <section className="f-bloc f-pratique">212            <h2>Détails pratiques</h2>213            <div className="kv">214              <div className="cell"><div className="k">Plateforme</div><div className="v">{ctSourceName(l.source)}</div></div>215              {l.price_label && (216                <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>217              )}218              {l.citq && (219                <div className="cell"><div className="k">Enregistrement CITQ</div><div className="v">{l.citq}</div></div>220              )}221              {synced && (222                <div className="cell"><div className="k">Synchronisé</div><div className="v">{synced}</div></div>223              )}224            </div>225            {!l.citq && (226              <p className="fine">227                Au Québec, tout séjour de 31 nuits ou moins doit être offert par un228                établissement détenant un numéro d'enregistrement CITQ. Vérifiez le229                numéro affiché sur l'annonce originale avant de réserver.230              </p>231            )}232          </section>233        </div>234235        <div className="f-col">236          <CtPriceAnalysis p={ctx?.price ?? null} price={l.price_night} />237          {l.lat != null && l.lng != null && (238            <section className="f-bloc f-carte" id="emplacement">239              <h2>Emplacement</h2>240              <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>241                <CtFicheMap l={l} />242              </Suspense>243              <p className="fine">244                Position approximative selon les coordonnées fournies par la source.245              </p>246            </section>247          )}248        </div>249      </div>250251      {ctx && ctx.similar.length > 0 && (252        <section className="f-bloc f-similaires" aria-label="Hébergements similaires">253          <h2>254            {l.lat != null && ctx.similar[0]?.distance_km != null255              ? "Hébergements similaires à proximité"256              : `Hébergements similaires — ${l.region || "même région"}`}257          </h2>258          <div className="grid">259            {ctx.similar.map((s) => <CtListingCard key={s.uid} l={s} />)}260          </div>261        </section>262      )}263264      <div className="fine f-foot">265        {synced && <>Dernière synchronisation : {synced}. </>}266        Les prix et disponibilités sont ceux affichés par la source — chaque fiche267        renvoie à l'annonce originale pour réserver.268      </div>269270      <div className="cta-sticky">271        <span className="cta-sticky-prix">272          {fmtNight(l.price_night, l.price_label)}273          {l.price_night != null && <small>/nuit</small>}274        </span>275        <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">276          {ctaLabel} ↗277        </a>278      </div>279    </div>280  );281}282