// -----------------------------------------------------------------------------
// Lou-Ka — Agrégateur de logements à louer (province de Québec)
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
// fiche/ui.tsx : primitives d'interface de la fiche logement (refonte 2026-09-04)
// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
// -----------------------------------------------------------------------------
import { ReactNode, useCallback, useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { IcoChevronDown } from "../components/Icons";
export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
/* --- carte de section ------------------------------------------------------ */
export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
children: ReactNode; className?: string; label?: string;
}) {
return (
{(title || aside) && (
{title &&
{icon}{title}
}
{sub &&
{sub}
}
{aside &&
{aside}
}
)}
{children}
);
}
/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
small?: boolean; onToggle?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
return (
);
}
/* --- tuile KPI ------------------------------------------------------------- */
export function StatTile({ value, unit, label, accent = false, anim = true }: {
value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
}) {
return (
8 ? "wrap" : ""}`}>{value}{unit && {unit}}
{label}
);
}
/* --- pastille d'état ------------------------------------------------------- */
export function StatusBadge({ tone = "neutral", children, lg = false }: {
tone?: Tone; children: ReactNode; lg?: boolean;
}) {
return {children};
}
/* --- squelettes ------------------------------------------------------------ */
export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
return ;
}
export function SkeletonLines({ n = 3 }: { n?: number }) {
return (
{Array.from({ length: n }).map((_, i) => (
))}
);
}
/* --- états vides / erreur -------------------------------------------------- */
export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
return {children}
;
}
export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
onRetry?: () => void; children?: ReactNode;
}) {
return (
{children}
{onRetry && }
);
}
/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
export function MoreButton({ children, onClick, expanded }: {
children: ReactNode; onClick: () => void; expanded?: boolean;
}) {
return (
);
}
/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
}) {
return (
Source : {href ? {name} : name}
{date && <> · {date}>}
{onMethod &&
}
);
}
/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
export function useToast(): [ReactNode, (msg: string) => void] {
const [msg, setMsg] = useState(null);
useEffect(() => {
if (!msg) return;
const t = setTimeout(() => setMsg(null), 2200);
return () => clearTimeout(t);
}, [msg]);
const show = useCallback((m: string) => setMsg(m), []);
const node = msg
? createPortal({msg}
, document.body)
: null;
return [node, show];
}
/* --- utilitaires de format -------------------------------------------------- */
export const NBSP = " ";
export const fmtN = (v: number, d = 0) =>
v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
export const fmtPct = (v: number, signed = true) =>
`${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
export const relTime = (ts: number | null | undefined): string | null => {
if (!ts) return null;
const s = Date.now() / 1000 - ts;
if (s < 3600) return "à l'instant";
if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
const j = Math.round(s / 86400);
if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
};