Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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