Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * « Mes favoris » — liste des propriétés mises en ♥ (Mon univers Ka).4 * Le hub Groupe KA est le magasin central : retirer un favori = POST synchrone5 * /api/favorites/toggle (action « remove »), la rangée disparaît si le hub6 * confirme. Design éditorial Vrai-Prix (papier/encre, cartes à ombre franche).7 */8"use client";9import { useState } from "react";10import Link from "next/link";11import { useLang } from "@/components/LangContext";12import type { FavItem } from "@/lib/hubfav";1314/** Lien local vers la fiche : chemin du hub si c'est une URL Vrai-Prix,15 * sinon retombée sur /estimation/<item_id>. */16function ficheHref(f: FavItem): string {17 if (f.url) {18 try {19 const u = new URL(f.url);20 if (u.hostname.endsWith("vrai-prix.com")) return u.pathname + u.search;21 } catch {22 /* URL invalide — retombée ci-dessous */23 }24 }25 return `/estimation/${encodeURIComponent(f.item_id)}`;26}2728export default function FavorisClient({ initial }: { initial: FavItem[] | null }) {29 const { lang } = useLang();30 const fr = lang === "fr";31 const [items, setItems] = useState<FavItem[] | null>(initial);32 const [busy, setBusy] = useState<string | null>(null);33 const [err, setErr] = useState(false);3435 const remove = async (f: FavItem) => {36 if (busy) return;37 setBusy(f.item_id);38 setErr(false);39 try {40 const r = await fetch("/api/favorites/toggle", {41 method: "POST",42 headers: { "Content-Type": "application/json" },43 body: JSON.stringify({ ...f, action: "remove" }),44 });45 if (!r.ok) throw new Error(String(r.status));46 setItems((cur) => (cur ?? []).filter((x) => x.item_id !== f.item_id));47 } catch {48 setErr(true);49 } finally {50 setBusy(null);51 }52 };5354 return (55 <div className="py-10">56 {/* en-tête */}57 <span className="kicker">{fr ? "Mon univers Ka" : "My Ka universe"}</span>58 <h1 className="vp-display mt-2 text-[clamp(28px,5vw,44px)] font-bold uppercase leading-none tracking-[-0.03em]">59 {fr ? "Mes favoris" : "My favourites"}60 </h1>61 <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">62 {fr63 ? "Les propriétés que vous avez mises en ♥ — gardées au hub Groupe KA, les mêmes sur toutes les plateformes du groupe."64 : "The properties you hearted — stored at the Groupe KA hub, the same across every platform of the group."}65 </p>6667 {err && (68 <p className="vp-mono mt-4 rounded-[8px] border-[1.5px] border-red-600 bg-surface px-4 py-3 text-[12px] uppercase tracking-[0.04em] text-red-700">69 {fr70 ? "Hub Groupe KA injoignable — le retrait n'a pas été enregistré, réessayez."71 : "Groupe KA hub unreachable — removal was not saved, try again."}72 </p>73 )}7475 {/* hub injoignable au chargement */}76 {items === null ? (77 <div className="vp-card mt-8 p-6 sm:p-8">78 <p className="vp-display text-[18px] font-bold">79 {fr ? "Hub Groupe KA injoignable" : "Groupe KA hub unreachable"}80 </p>81 <p className="mt-2 text-[14.5px] text-ink-2">82 {fr83 ? "Vos favoris vivent sur groupe-ka.com et n'ont pas pu être lus. Rechargez la page dans un instant."84 : "Your favourites live on groupe-ka.com and could not be read. Reload the page in a moment."}85 </p>86 </div>87 ) : items.length === 0 ? (88 /* aucun favori */89 <div className="vp-card mt-8 p-6 sm:p-8">90 <p className="vp-display text-[18px] font-bold">91 {fr ? "Aucun favori pour l'instant" : "No favourites yet"}92 </p>93 <p className="mt-2 max-w-xl text-[14.5px] text-ink-2">94 {fr95 ? "Touchez le ♥ d'une fiche d'estimation pour la garder ici — elle vous suivra sur toutes les plateformes du Groupe KA."96 : "Tap the ♥ on any estimate page to keep it here — it follows you across every Groupe KA platform."}97 </p>98 <Link href="/" className="btn btn-primary vp-display mt-5 inline-flex">99 {fr ? "Estimer une propriété" : "Estimate a property"}100 </Link>101 </div>102 ) : (103 <>104 <p className="vp-mono mt-8 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">105 {items.length}{" "}106 {fr107 ? `propriété${items.length > 1 ? "s" : ""} en favoris`108 : `favourite propert${items.length > 1 ? "ies" : "y"}`}109 </p>110 <ul className="mt-3 grid gap-4 sm:grid-cols-2">111 {items.map((f, i) => (112 <li113 key={f.item_id}114 className="vp-card vp-card-hover rise relative p-5"115 style={{ animationDelay: `${i * 60}ms` }}116 >117 <Link href={ficheHref(f)} className="block pr-10">118 <p className="vp-mono text-[10px] font-bold text-green">119 {String(i + 1).padStart(2, "0")}120 </p>121 <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">122 {f.title}123 </p>124 {f.subtitle && (125 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">126 {f.subtitle}127 </p>128 )}129 <div className="mt-3 flex items-end justify-between gap-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3">130 <p className="vp-display text-[20px] font-bold tracking-[-0.02em]">131 {f.price_label || "—"}132 </p>133 <span className="vp-mono text-[11px] font-bold uppercase tracking-[0.04em] text-green">134 {fr ? "Voir la fiche →" : "View →"}135 </span>136 </div>137 </Link>138 {/* retirer (♥ plein — même geste que sur la fiche) */}139 <button140 type="button"141 onClick={() => remove(f)}142 disabled={busy === f.item_id}143 aria-label={fr ? "Retirer de mes favoris" : "Remove from my favourites"}144 title={fr ? "Retirer de mes favoris" : "Remove from my favourites"}145 className={`absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[14px] leading-none text-lime transition-all hover:bg-green-deep ${146 busy === f.item_id ? "opacity-60" : ""147 }`}148 >149 <span aria-hidden="true" className="translate-y-[-1px]">♥</span>150 </button>151 </li>152 ))}153 </ul>154 </>155 )}156 </div>157 );158}159