SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%

accueil : compteur combiné dynamique en tête + image OG dynamique ; cartes mobiles (pastille courte, loyer compact) ; socle identité (Drizzle, OTP, sessions) et service d'annonces directes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Oct 17, 2026) parent bd22435

23 changed files +460 −15

modified package.json +1 −0
@@ -44,6 +44,7 @@
44 44 "drizzle-kit": "^0.31.11",
45 45 "eslint": "^9",
46 46 "eslint-config-next": "16.4.2",
47 + "playwright": "^1.64.1",
47 48 "tailwindcss": "^4",
48 49 "tsx": "^4.23.15",
49 50 "typescript": "^5",
modified pnpm-lock.yaml +19 −0
@@ -87,6 +87,9 @@ importers:
87 87 eslint-config-next:
88 88 specifier: 16.4.2
89 89 version: 16.4.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
90 + playwright:
91 + specifier: ^1.64.1
92 + version: 1.64.1
90 93 tailwindcss:
91 94 specifier: ^4
92 95 version: 4.3.3
@@ -2814,6 +2817,16 @@ packages:
2814 2817 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
2815 2818 engines: {node: '>=12'}
2816 2819
2820 + playwright-core@1.64.1:
2821 + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
2822 + engines: {node: '>=20'}
2823 + hasBin: true
2824 +
2825 + playwright@1.64.1:
2826 + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
2827 + engines: {node: '>=20'}
2828 + hasBin: true
2829 +
2817 2830 pngjs@6.0.0:
2818 2831 resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
2819 2832 engines: {node: '>=12.13.0'}
@@ -5802,6 +5815,12 @@ snapshots:
5802 5815
5803 5816 picomatch@4.0.7: {}
5804 5817
5818 + playwright-core@1.64.1: {}
5819 +
5820 + playwright@1.64.1:
5821 + dependencies:
5822 + playwright-core: 1.64.1
5823 +
5805 5824 pngjs@6.0.0: {}
5806 5825
5807 5826 possible-typed-array-names@1.1.0: {}
added scripts/screenshots.mjs +19 −0
@@ -0,0 +1,19 @@
1 +// Captures d'écran QA : node scripts/screenshots.mjs <baseUrl> <outDir>
2 +import { chromium, devices } from "playwright";
3 +const [base = "https://www.toveli.ca", out = "/tmp/toveli-shots"] = process.argv.slice(2);
4 +import { mkdirSync } from "node:fs";
5 +mkdirSync(out, { recursive: true });
6 +const pages = ["/", "/recherche?marche=achat&ville=Qu%C3%A9bec", "/carte", "/estimer?q=1234+erables", "/marche", "/financement", "/assistant", "/favoris", "/connexion"];
7 +const browser = await chromium.launch();
8 +for (const [name, ctxOpts] of [["mobile", { ...devices["iPhone 14"] }], ["android", { ...devices["Pixel 7"] }], ["desktop", { viewport: { width: 1440, height: 900 } }]]) {
9 + const ctx = await browser.newContext(ctxOpts); const page = await ctx.newPage();
10 + const errors = [];
11 + page.on("pageerror", (e) => errors.push(`${name} pageerror: ${e.message}`)); page.on("console", (m) => { if (m.type() === "error") errors.push(`${name} console: ${m.text().slice(0, 160)}`); });
12 + for (const p of pages) {
13 + try { await page.goto(base + p, { waitUntil: "networkidle", timeout: 45000 }); await page.waitForTimeout(800); await page.screenshot({ path: `${out}/${name}${p.replace(/[^a-z0-9]+/gi, "_") || "_home"}.png`, fullPage: !p.startsWith("/carte") }); console.log("ok", name, p); }
14 + catch (e) { console.log("ERR", name, p, e.message.slice(0, 120)); }
15 + }
16 + if (errors.length) console.log(errors.slice(0, 12).join("\n"));
17 + await ctx.close();
18 +}
19 +await browser.close();
added src/app/api/auth/logout-all/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { NextResponse } from "next/server";
2 +import { cookies } from "next/headers";
3 +import { assertSameOrigin, requireUser, revokeAllSessions, COOKIE } from "@/lib/auth/session";
4 +import { audit } from "@/lib/auth/audit";
5 +import { handle } from "@/lib/api";
6 +export const dynamic = "force-dynamic";
7 +export async function POST() {
8 + try { await assertSameOrigin(); const u = await requireUser(); await revokeAllSessions(u.id); await audit("auth.logout_all", { actorId: u.id }); (await cookies()).delete(COOKIE); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
9 +}
added src/app/api/auth/logout/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { NextResponse } from "next/server";
2 +import { cookies } from "next/headers";
3 +import { assertSameOrigin, currentUser, revokeSession, COOKIE } from "@/lib/auth/session";
4 +import { audit } from "@/lib/auth/audit";
5 +import { handle } from "@/lib/api";
6 +export const dynamic = "force-dynamic";
7 +export async function POST() {
8 + try { await assertSameOrigin(); const u = await currentUser(); if (u) { await revokeSession(u.sessionId, u.id); await audit("auth.logout", { actorId: u.id }); } (await cookies()).delete(COOKIE); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
9 +}
added src/app/api/auth/request-code/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { requestCode } from "@/lib/auth/otp";
4 +import { assertSameOrigin, clientIp } from "@/lib/auth/session";
5 +import { handle, parseBody } from "@/lib/api";
6 +export const dynamic = "force-dynamic";
7 +const Body = z.object({ email: z.string().min(3).max(254) });
8 +/** Réponse volontairement neutre : ne révèle jamais si l'adresse possède un compte. */
9 +export async function POST(req: NextRequest) {
10 + try {
11 + await assertSameOrigin();
12 + const { email } = await parseBody(req, Body);
13 + const r = await requestCode(email, await clientIp());
14 + if (!r.ok && r.reason === "invalid_email") return NextResponse.json({ error: { code: "invalid_email", message: "Adresse courriel invalide" } }, { status: 400 });
15 + if (!r.ok) return NextResponse.json({ ok: true, sent: false, retryAfterSec: r.retryAfterSec, message: r.reason === "cooldown" ? "Un code vient d'être envoyé. Patientez avant d'en demander un autre." : "Trop de demandes. Réessayez plus tard." }, { status: 429 });
16 + return NextResponse.json({ ok: true, sent: true, challengeId: r.challengeId, retryAfterSec: r.retryAfterSec, message: "Si cette adresse est valide, un code vient d'être envoyé." });
17 + } catch (e) { return handle(e); }
18 +}
added src/app/api/auth/verify-code/route.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { cookies } from "next/headers";
4 +import { verifyCode } from "@/lib/auth/otp";
5 +import { assertSameOrigin, clientIp, createSession, cookieOptions, COOKIE, userAgent } from "@/lib/auth/session";
6 +import { handle, parseBody } from "@/lib/api";
7 +import { hit } from "@/lib/auth/ratelimit";
8 +import { ipHash } from "@/lib/auth/crypto";
9 +export const dynamic = "force-dynamic";
10 +const Body = z.object({ challengeId: z.string().uuid(), code: z.string().regex(/^\d{6}$/), acceptTerms: z.boolean().optional().default(false) });
11 +const MSG: Record<string, string> = { invalid: "Code invalide.", expired: "Ce code a expiré. Demandez-en un nouveau.", too_many_attempts: "Trop d'essais. Demandez un nouveau code.", not_found: "Demande introuvable. Recommencez." };
12 +export async function POST(req: NextRequest) {
13 + try {
14 + await assertSameOrigin();
15 + const { challengeId, code, acceptTerms } = await parseBody(req, Body);
16 + const ip = await clientIp();
17 + const rl = await hit(`otp:verify:${ipHash(ip)}`, 30, 600); if (!rl.ok) return NextResponse.json({ error: { code: "rate_limited", message: "Trop de tentatives. Réessayez plus tard." } }, { status: 429 });
18 + const r = await verifyCode(challengeId, code, ip, acceptTerms);
19 + if (!r.ok) return NextResponse.json({ error: { code: r.reason, message: MSG[r.reason] } }, { status: r.reason === "invalid" && !acceptTerms ? 409 : 400 });
20 + const s = await createSession(r.userId, { ip, device: await userAgent() });
21 + (await cookies()).set(COOKIE, s.token, cookieOptions(s.expiresAt));
22 + return NextResponse.json({ ok: true, created: r.created, user: { id: r.userId, email: r.email } });
23 + } catch (e) { return handle(e); }
24 +}
added src/app/api/me/favorites/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { and, eq, desc } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { favorites } from "@/db/schema";
6 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
7 +import { handle, parseBody } from "@/lib/api";
8 +export const dynamic = "force-dynamic";
9 +export async function GET() { try { const u = await requireUser(); const rows = await db().select({ uid: favorites.listingUid, createdAt: favorites.createdAt }).from(favorites).where(eq(favorites.userId, u.id)).orderBy(desc(favorites.createdAt)).limit(500); return NextResponse.json({ items: rows }); } catch (e) { return handle(e); } }
10 +const Body = z.object({ uids: z.array(z.string().min(3).max(200)).min(1).max(200) });
11 +export async function POST(req: NextRequest) { try { await assertSameOrigin(); const u = await requireUser(); const { uids } = await parseBody(req, Body); await db().insert(favorites).values(uids.map((listingUid) => ({ userId: u.id, listingUid }))).onConflictDoNothing(); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
12 +export async function DELETE(req: NextRequest) { try { await assertSameOrigin(); const u = await requireUser(); const { uids } = await parseBody(req, Body); for (const uid of uids) await db().delete(favorites).where(and(eq(favorites.userId, u.id), eq(favorites.listingUid, uid))); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
added src/app/api/me/route.ts +25 −0
@@ -0,0 +1,25 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { users, userProfiles } from "@/db/schema";
6 +import { assertSameOrigin, currentUser, requireUser } from "@/lib/auth/session";
7 +import { encrypt } from "@/lib/auth/crypto";
8 +import { audit } from "@/lib/auth/audit";
9 +import { handle, parseBody } from "@/lib/api";
10 +export const dynamic = "force-dynamic";
11 +export async function GET() {
12 + try { const u = await currentUser(); if (!u) return NextResponse.json({ user: null }); return NextResponse.json({ user: { id: u.id, email: u.email, displayName: u.displayName, publicName: u.publicName, sellerType: u.sellerType, roles: u.roles, prefs: u.prefs } }); } catch (e) { return handle(e); }
13 +}
14 +const Patch = z.object({ displayName: z.string().trim().min(1).max(80).nullable().optional(), publicName: z.string().trim().min(1).max(80).nullable().optional(), phone: z.string().trim().max(30).nullable().optional(), sellerType: z.enum(["OWNER", "AGENT"]).optional(), bio: z.string().max(600).nullable().optional(),
15 + prefs: z.object({ notifications: z.object({ inquiries: z.boolean(), viewings: z.boolean(), alerts: z.boolean(), marketing: z.boolean() }).partial().optional(), privacy: z.object({ showPhone: z.boolean().optional(), showNameOnListings: z.boolean().optional() }).optional() }).optional() });
16 +export async function PATCH(req: NextRequest) {
17 + try {
18 + await assertSameOrigin(); const u = await requireUser(); const b = await parseBody(req, Patch); const d = db();
19 + const prefs = b.prefs ? { ...u.prefs, notifications: { ...((u.prefs.notifications as object) ?? {}), ...(b.prefs.notifications ?? {}) }, privacy: { ...((u.prefs.privacy as object) ?? {}), ...(b.prefs.privacy ?? {}) } } : undefined;
20 + await d.update(users).set({ ...(b.displayName !== undefined ? { displayName: b.displayName } : {}), ...(b.phone !== undefined ? { phoneEncrypted: b.phone ? encrypt(b.phone) : null } : {}), ...(prefs ? { prefs } : {}), updatedAt: new Date() }).where(eq(users.id, u.id));
21 + await d.insert(userProfiles).values({ userId: u.id, publicName: b.publicName ?? null, sellerType: b.sellerType ?? "OWNER", bio: b.bio ?? null }).onConflictDoUpdate({ target: userProfiles.userId, set: { ...(b.publicName !== undefined ? { publicName: b.publicName } : {}), ...(b.sellerType ? { sellerType: b.sellerType } : {}), ...(b.bio !== undefined ? { bio: b.bio } : {}), updatedAt: new Date() } });
22 + await audit("me.update", { actorId: u.id, meta: { fields: Object.keys(b) } });
23 + return NextResponse.json({ ok: true });
24 + } catch (e) { return handle(e); }
25 +}
added src/app/api/me/sessions/[id]/route.ts +8 −0
@@ -0,0 +1,8 @@
1 +import { NextResponse } from "next/server";
2 +import { assertSameOrigin, requireUser, revokeSession } from "@/lib/auth/session";
3 +import { audit } from "@/lib/auth/audit";
4 +import { handle } from "@/lib/api";
5 +export const dynamic = "force-dynamic";
6 +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
7 + try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; await revokeSession(id, u.id); await audit("session.revoke", { actorId: u.id, objectId: id }); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
8 +}
added src/app/api/me/sessions/route.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { NextResponse } from "next/server";
2 +import { listSessions, requireUser } from "@/lib/auth/session";
3 +import { handle } from "@/lib/api";
4 +export const dynamic = "force-dynamic";
5 +export async function GET() { try { const u = await requireUser(); const rows = await listSessions(u.id); return NextResponse.json({ sessions: rows.map((s) => ({ ...s, current: s.id === u.sessionId })) }); } catch (e) { return handle(e); } }
added src/app/connexion/login-view.tsx +55 −0
@@ -0,0 +1,55 @@
1 +"use client";
2 +import { useEffect, useRef, useState } from "react";
3 +import Link from "next/link";
4 +import { useRouter, useSearchParams } from "next/navigation";
5 +import { Logo } from "@/components/logo";
6 +import { Notice } from "@/components/ui";
7 +
8 +export function LoginView() {
9 + const sp = useSearchParams(); const router = useRouter(); const next = sp.get("next") && sp.get("next")!.startsWith("/") ? sp.get("next")! : "/membre";
10 + const [step, setStep] = useState<"email" | "code">("email");
11 + const [email, setEmail] = useState(""); const [accept, setAccept] = useState(false); const [challengeId, setChallengeId] = useState<string | null>(null);
12 + const [code, setCode] = useState(""); const [err, setErr] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [cooldown, setCooldown] = useState(0);
13 + const [needTerms, setNeedTerms] = useState(false);
14 + const codeRef = useRef<HTMLInputElement>(null);
15 + useEffect(() => { if (cooldown <= 0) return; const t = setTimeout(() => setCooldown((c) => c - 1), 1000); return () => clearTimeout(t); }, [cooldown]);
16 + const request = async () => {
17 + setBusy(true); setErr(null);
18 + const r = await fetch("/api/auth/request-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); const d = await r.json(); setBusy(false);
19 + if (r.status === 400) { setErr(d.error?.message ?? "Adresse invalide"); return; }
20 + if (r.status === 429) { setErr(d.message); setCooldown(d.retryAfterSec ?? 60); if (challengeId) setStep("code"); return; }
21 + setChallengeId(d.challengeId); setCooldown(d.retryAfterSec ?? 60); setStep("code"); setTimeout(() => codeRef.current?.focus(), 50);
22 + };
23 + const verify = async (c = code) => {
24 + if (!challengeId || c.length !== 6) return;
25 + setBusy(true); setErr(null);
26 + const r = await fetch("/api/auth/verify-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId, code: c, acceptTerms: accept }) }); const d = await r.json(); setBusy(false);
27 + if (r.status === 409 && !accept) { setNeedTerms(true); setErr("Nouveau compte : acceptez les conditions puis confirmez le code."); return; }
28 + if (!r.ok) { setErr(d.error?.message ?? "Erreur"); if (["expired", "too_many_attempts", "not_found"].includes(d.error?.code)) { setStep("email"); setChallengeId(null); setCode(""); } return; }
29 + router.push(next); router.refresh();
30 + };
31 + return (
32 + <div className="mx-auto max-w-md px-4 py-10 md:py-16">
33 + <div className="text-center"><Logo /><h1 className="mt-4 text-2xl font-extrabold tracking-tight">{step === "email" ? "Connexion ou création de compte" : "Entrez votre code"}</h1><p className="mt-1 text-sm text-ink-2">{step === "email" ? "Une seule identité pour chercher, publier, échanger. Sans mot de passe : un code à usage unique vous est envoyé par courriel." : <>Un code à 6 chiffres a été envoyé à <strong>{email}</strong> s'il s'agit d'une adresse valide. Valable 10 minutes.</>}</p></div>
34 + <div className="tv-card p-5 mt-6">
35 + {step === "email" ? (
36 + <form onSubmit={(e) => { e.preventDefault(); request(); }} className="space-y-4">
37 + <label className="block"><span className="tv-label">Adresse courriel</span><input type="email" autoComplete="email" inputMode="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="tv-input !pl-4 mt-1" placeholder="vous@exemple.ca" /></label>
38 + <label className="flex gap-2 text-xs text-ink-2 items-start"><input type="checkbox" checked={accept} onChange={(e) => setAccept(e.target.checked)} className="mt-0.5 accent-[#e0263c]" /><span>Si je crée un compte, j'accepte les <Link href="/conditions" className="underline" target="_blank">conditions d'utilisation</Link> et la <Link href="/confidentialite" className="underline" target="_blank">politique de confidentialité</Link>. (Facultatif si vous avez déjà un compte.)</span></label>
39 + {err && <Notice tone="warn">{err}</Notice>}
40 + <button type="submit" disabled={busy || cooldown > 0 && !!challengeId} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Recevoir mon code"}</button>
41 + </form>
42 + ) : (
43 + <form onSubmit={(e) => { e.preventDefault(); verify(); }} className="space-y-4">
44 + <label className="block"><span className="tv-label">Code à 6 chiffres</span><input ref={codeRef} type="text" inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={code} onChange={(e) => { const v = e.target.value.replace(/\D/g, "").slice(0, 6); setCode(v); if (v.length === 6) verify(v); }} className="tv-input !pl-4 mt-1 text-center text-2xl tracking-[.4em] font-bold" placeholder="••••••" /></label>
45 + {needTerms && <label className="flex gap-2 text-xs text-ink-2 items-start"><input type="checkbox" checked={accept} onChange={(e) => setAccept(e.target.checked)} className="mt-0.5 accent-[#e0263c]" /><span>J'accepte les <Link href="/conditions" className="underline" target="_blank">conditions</Link> et la <Link href="/confidentialite" className="underline" target="_blank">politique de confidentialité</Link>.</span></label>}
46 + {err && <Notice tone="warn">{err}</Notice>}
47 + <button type="submit" disabled={busy || code.length !== 6} className="tv-btn tv-btn-primary w-full">{busy ? "Vérification…" : "Me connecter"}</button>
48 + <div className="flex justify-between text-xs text-ink-3"><button type="button" onClick={() => { setStep("email"); setCode(""); setErr(null); }} className="underline">Changer d'adresse</button><button type="button" disabled={cooldown > 0 || busy} onClick={request} className="underline disabled:no-underline disabled:opacity-60">{cooldown > 0 ? `Renvoyer dans ${cooldown} s` : "Renvoyer un code"}</button></div>
49 + </form>
50 + )}
51 + </div>
52 + <p className="mt-4 text-xs text-ink-3 text-center">Si vous n'avez rien demandé, ignorez le courriel : personne ne peut se connecter sans le code.</p>
53 + </div>
54 + );
55 +}
added src/app/connexion/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { LoginView } from "./login-view";
4 +export const metadata: Metadata = { title: "Connexion", robots: { index: false } };
5 +export default function Connexion() { return <Suspense><LoginView /></Suspense>; }
modified src/app/layout.tsx +14 −9
@@ -1,21 +1,26 @@
1 1 import type { Metadata, Viewport } from "next";
2 2 import { Inter } from "next/font/google";
3 3 import "./globals.css";
4 +import { dbAvailable, meta } from "@/lib/db";
4 5 import { SiteHeader } from "@/components/site-header";
5 6 import { SiteFooter } from "@/components/site-footer";
6 7 import { MobileNav } from "@/components/mobile-nav";
7 8
8 9 const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
9 10
10 −export const metadata: Metadata = {
11 − metadataBase: new URL("https://www.toveli.ca"),
12 − title: { default: "Toveli — L'immobilier, enfin réuni", template: "%s · Toveli" },
13 − description:
14 − "Achetez, louez et découvrez la juste valeur des propriétés au Québec : annonces à vendre et à louer, estimation de valeur, comparables, marché, financement.",
15 − applicationName: "Toveli",
16 − openGraph: { type: "website", locale: "fr_CA", siteName: "Toveli", url: "https://www.toveli.ca" },
17 − robots: { index: true, follow: true },
18 −};
11 +export async function generateMetadata(): Promise<Metadata> {
12 + const n = dbAvailable() ? Number(meta("listings_sale")) + Number(meta("listings_rent")) : 0;
13 + const count = n ? `${n.toLocaleString("fr-CA")} propriétés à vendre et à louer au Québec` : "Propriétés à vendre et à louer au Québec";
14 + return {
15 + metadataBase: new URL("https://www.toveli.ca"),
16 + title: { default: `Toveli — ${count}`, template: "%s · Toveli" },
17 + description: `${count}, réunies en un seul endroit : annonces, carte, estimation de valeur avec fourchette et confiance, comparables, marché, financement.`,
18 + applicationName: "Toveli",
19 + openGraph: { type: "website", locale: "fr_CA", siteName: "Toveli", url: "https://www.toveli.ca", title: `Toveli — ${count}`, description: "L'immobilier, enfin réuni. Acheter, louer, estimer, comparer, financer." },
20 + twitter: { card: "summary_large_image" },
21 + robots: { index: true, follow: true },
22 + };
23 +}
19 24
20 25 export const viewport: Viewport = { themeColor: "#e0263c", width: "device-width", initialScale: 1 };
21 26
added src/app/opengraph-image.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { ImageResponse } from "next/og";
2 +import { dbAvailable, meta } from "@/lib/db";
3 +export const runtime = "nodejs";
4 +export const alt = "Toveli — propriétés à vendre et à louer au Québec";
5 +export const size = { width: 1200, height: 630 };
6 +export const contentType = "image/png";
7 +export const dynamic = "force-dynamic";
8 +export default function Image() {
9 + const sale = dbAvailable() ? Number(meta("listings_sale")) : 0, rent = dbAvailable() ? Number(meta("listings_rent")) : 0; const total = sale + rent;
10 + const f = (n: number) => n.toLocaleString("fr-CA");
11 + return new ImageResponse(
12 + (
13 + <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", padding: 64, background: "linear-gradient(135deg,#ffffff 0%,#fdeef0 100%)", color: "#15181d", fontFamily: "Helvetica, Arial, sans-serif" }}>
14 + <div style={{ display: "flex", alignItems: "baseline", fontSize: 56, fontWeight: 800, letterSpacing: -2 }}>toveli<span style={{ color: "#e0263c" }}>.</span></div>
15 + <div style={{ display: "flex", flexDirection: "column" }}>
16 + <div style={{ display: "flex", alignItems: "baseline", gap: 20 }}><span style={{ fontSize: 132, fontWeight: 800, color: "#e0263c", letterSpacing: -4, lineHeight: 1 }}>{total ? f(total) : "—"}</span></div>
17 + <div style={{ fontSize: 44, fontWeight: 700, marginTop: 8 }}>propriétés à vendre et à louer au Québec</div>
18 + <div style={{ fontSize: 28, color: "#4b5260", marginTop: 12 }}>{total ? `${f(sale)} à vendre · ${f(rent)} à louer · mises à jour chaque nuit` : "L'immobilier, enfin réuni."}</div>
19 + </div>
20 + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 26, color: "#7a8190" }}><span>Trouver. Comprendre. Décider.</span><span>www.toveli.ca</span></div>
21 + </div>
22 + ), { ...size },
23 + );
24 +}
modified src/app/page.tsx +8 −1
@@ -20,10 +20,17 @@ export default function Home() {
20 20 <div className="absolute inset-0 -z-10 bg-[radial-gradient(1200px_500px_at_70%_-10%,#fdeef0_0%,transparent_60%)]" />
21 21 <div className="mx-auto max-w-7xl px-4 pt-10 pb-8 md:pt-16 md:pb-14 grid md:grid-cols-[1.1fr_1fr] gap-8 items-center">
22 22 <div>
23 + {ok && (
24 + <p className="mb-4 inline-flex flex-wrap items-baseline gap-x-2 rounded-card border border-line bg-white/80 px-4 py-2.5 shadow-card" aria-live="polite">
25 + <span className="tv-price text-3xl md:text-4xl text-brand">{fmtInt(nSale + nRent)}</span>
26 + <span className="text-sm md:text-base font-semibold text-ink">propriétés à vendre et à louer au Québec, réunies ici</span>
27 + <span className="basis-full text-xs text-ink-3">{fmtInt(nSale)} à vendre · {fmtInt(nRent)} à louer · mis à jour chaque nuit</span>
28 + </p>
29 + )}
23 30 <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight leading-[1.02]">L'immobilier,<br /><span className="text-brand">enfin réuni.</span></h1>
24 31 <p className="mt-4 text-ink-2 text-lg max-w-lg">Achetez, louez et découvrez la juste valeur des propriétés au Québec. Annonces, données de marché, comparables et estimation transparente, au même endroit.</p>
25 32 <div className="mt-6 tv-card p-3 md:p-4"><HomeTabs /></div>
26 − <p className="mt-3 text-xs text-ink-3">{ok ? <>{fmtInt(nSale)} propriétés à vendre · {fmtInt(nRent)} logements à louer · données mises à jour chaque nuit</> : "Données en cours de chargement…"}</p>
33 + {!ok && <p className="mt-3 text-xs text-ink-3">Données en cours de chargement…</p>}
27 34 </div>
28 35 <div className="hidden md:grid grid-cols-2 gap-3">
29 36 {sale.slice(0, 4).map((c, i) => <ListingCardView key={c.uid} c={c} priority={i < 2} />)}
added src/app/twitter-image.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { ImageResponse } from "next/og";
2 +import { dbAvailable, meta } from "@/lib/db";
3 +export const runtime = "nodejs";
4 +export const alt = "Toveli — propriétés à vendre et à louer au Québec";
5 +export const size = { width: 1200, height: 630 };
6 +export const contentType = "image/png";
7 +export const dynamic = "force-dynamic";
8 +export default function Image() {
9 + const sale = dbAvailable() ? Number(meta("listings_sale")) : 0, rent = dbAvailable() ? Number(meta("listings_rent")) : 0; const total = sale + rent;
10 + const f = (n: number) => n.toLocaleString("fr-CA");
11 + return new ImageResponse(
12 + (
13 + <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", padding: 64, background: "linear-gradient(135deg,#ffffff 0%,#fdeef0 100%)", color: "#15181d", fontFamily: "Helvetica, Arial, sans-serif" }}>
14 + <div style={{ display: "flex", alignItems: "baseline", fontSize: 56, fontWeight: 800, letterSpacing: -2 }}>toveli<span style={{ color: "#e0263c" }}>.</span></div>
15 + <div style={{ display: "flex", flexDirection: "column" }}>
16 + <div style={{ display: "flex", alignItems: "baseline", gap: 20 }}><span style={{ fontSize: 132, fontWeight: 800, color: "#e0263c", letterSpacing: -4, lineHeight: 1 }}>{total ? f(total) : "—"}</span></div>
17 + <div style={{ fontSize: 44, fontWeight: 700, marginTop: 8 }}>propriétés à vendre et à louer au Québec</div>
18 + <div style={{ fontSize: 28, color: "#4b5260", marginTop: 12 }}>{total ? `${f(sale)} à vendre · ${f(rent)} à louer · mises à jour chaque nuit` : "L'immobilier, enfin réuni."}</div>
19 + </div>
20 + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 26, color: "#7a8190" }}><span>Trouver. Comprendre. Décider.</span><span>www.toveli.ca</span></div>
21 + </div>
22 + ), { ...size },
23 + );
24 +}
modified src/components/listing-card.tsx +2 −2
@@ -34,8 +34,8 @@ export function ListingCardView({ c, compact = false, priority = false }: { c: C
34 34 </div>
35 35 )}
36 36 <div className={`p-3 ${compact ? "min-w-0 flex-1" : ""}`}>
37 − <div className="flex items-start justify-between gap-2">
38 − <p className={`tv-price text-ink ${compact ? "text-base" : "text-lg"}`}>{fmtPrice(c.price, { rent })}</p>
37 + <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
38 + <p className={`tv-price text-ink whitespace-nowrap ${compact ? "text-base" : "text-[17px] sm:text-lg"}`}>{fmtPrice(c.price, { rent })}</p>
39 39 {!compact && <VerdictPill verdict={c.verdict} ratio={c.ratio} confidence={c.confidence} small />}
40 40 </div>
41 41 <Link href={listingHref(c)} className="block mt-0.5 text-sm font-medium text-ink truncate hover:underline">{c.address ?? "Adresse non précisée"}</Link>
modified src/components/verdict-pill.tsx +5 −2
@@ -5,6 +5,9 @@ export function VerdictPill({ verdict, ratio, confidence, small = false }: { ver
5 5 const weak = confidence === "C" || confidence === "D";
6 6 const pct = ratio - 1; // estimation vs prix : +10 % = estimation 10 % au-dessus du prix demandé
7 7 const cls = verdict === "aligne" ? "bg-info-soft text-info" : verdict === "sous" ? "bg-good-soft text-good" : "bg-warn-soft text-warn";
8 − const label = verdict === "aligne" ? "Prix aligné sur l'estimation" : verdict === "sous" ? `Estimation ${fmtPct(pct)} vs prix` : `Estimation ${fmtPct(pct)} vs prix`;
9 − return <span className={`tv-badge ${cls} ${small ? "text-[11px]" : ""}`} title={weak ? "Confiance du modèle faible : à interpréter avec prudence" : "Écart entre l'estimation Toveli et le prix demandé"}>{label}{weak ? " · faible confiance" : ""}</span>;
8 + const full = verdict === "aligne" ? "Prix aligné sur l'estimation" : `Estimation ${fmtPct(pct)} vs prix`;
9 + const short = verdict === "aligne" ? "≈ estimation" : `${fmtPct(pct)} vs prix`;
10 + const title = `${full}${weak ? " · confiance du modèle faible : à interpréter avec prudence" : " · écart entre l'estimation Toveli et le prix demandé"}`;
11 + if (small) return <span className={`tv-badge ${cls} text-[11px] whitespace-nowrap`} title={title} aria-label={title}>{short}{weak ? " ·!" : ""}</span>;
12 + return <span className={`tv-badge ${cls}`} title={title}>{full}{weak ? " · faible confiance" : ""}</span>;
10 13 }
added src/lib/api.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { NextResponse } from "next/server";
2 +import { ZodError, type ZodType } from "zod";
3 +import { AuthError } from "@/lib/auth/session";
4 +/** Erreurs structurées et uniformes pour l'API. */
5 +export function apiError(status: number, code: string, message: string, extra?: Record<string, unknown>) { return NextResponse.json({ error: { code, message, ...extra } }, { status }); }
6 +export function handle(e: unknown): NextResponse {
7 + if (e instanceof AuthError) return apiError(e.status, e.status === 401 ? "unauthenticated" : "forbidden", e.message);
8 + if (e instanceof ZodError) return apiError(400, "invalid_request", "Données invalides", { issues: e.issues.map((i) => ({ path: i.path.join("."), message: i.message })) });
9 + if (e instanceof Error && e.message === "DATABASE_URL manquante") return apiError(503, "unavailable", "Service des comptes indisponible");
10 + console.error("[api]", e);
11 + return apiError(500, "internal", "Erreur interne");
12 +}
13 +export async function parseBody<T>(req: Request, schema: ZodType<T>): Promise<T> { const j = await req.json().catch(() => null); return schema.parse(j); }
modified src/lib/format.ts +1 −1
@@ -10,7 +10,7 @@ export function fmtPrice(v: number | null | undefined, opts?: { rent?: boolean;
10 10 if (v >= 1_000) return `${int.format(Math.round(v / 1000))} k$`;
11 11 }
12 12 const s = cad0.format(v);
13 − return opts?.rent ? `${s} / mois` : s;
13 + return opts?.rent ? `${s}/mois` : s;
14 14 }
15 15 export function fmtInt(v: number | null | undefined): string { return v == null || !Number.isFinite(v) ? "—" : int.format(v); }
16 16 export function fmtPct(v: number | null | undefined, digits = 1, signed = true): string {
added src/lib/listings/service.ts +100 −0
@@ -0,0 +1,100 @@
1 +import { and, eq, sql, desc, inArray } from "drizzle-orm";
2 +import { customAlphabet } from "nanoid";
3 +import { db } from "@/db/client";
4 +import { listings, properties, listingMedia, listingVersions } from "@/db/schema";
5 +import { ListingInput, publishProblems, spamFlags, type ListingInputT } from "./types";
6 +import { normRegion, displayCity } from "@/lib/normalize";
7 +import { slugify } from "@/lib/format";
8 +import { audit } from "@/lib/auth/audit";
9 +import { AuthError } from "@/lib/auth/session";
10 +
11 +const nano = customAlphabet("abcdefghjkmnpqrstuvwxyz23456789", 8);
12 +export class ConflictError extends Error { constructor(msg = "Version périmée : rechargez l'annonce") { super(msg); } }
13 +
14 +export type ListingFull = typeof listings.$inferSelect & { property: typeof properties.$inferSelect; media: (typeof listingMedia.$inferSelect)[] };
15 +
16 +export async function createListing(ownerId: string, input: ListingInputT): Promise<ListingFull> {
17 + const d = db(); const i = ListingInput.parse(input);
18 + const [p] = await d.insert(properties).values({ propertyType: i.propertyType ?? "autre", canonicalAddress: i.address ?? null, unit: i.unit ?? null, city: i.city ? displayCity(i.city) : null, region: i.region ?? normRegion(null, i.city), postalCode: i.postalCode?.toUpperCase().replace(/\s/g, "") ?? null, lat: i.lat ?? null, lng: i.lng ?? null, roleUnitId: i.roleUnitId ?? null, geoSource: i.roleUnitId ? `role_unit:${i.roleUnitId}` : i.lat ? "manual" : null, privacyLevel: i.privacyLevel ?? "EXACT", bedrooms: i.bedrooms != null ? String(i.bedrooms) : null, bathrooms: i.bathrooms != null ? String(i.bathrooms) : null, areaSqft: i.areaSqft ?? null, lotSqft: i.lotSqft ?? null, yearBuilt: i.yearBuilt ?? null }).returning();
19 + const [l] = await d.insert(listings).values({ slug: nano(), propertyId: p.id, ownerUserId: ownerId, listingType: i.listingType ?? "SALE", sourceType: i.sourceType ?? "DIRECT_OWNER", title: i.title ?? null, description: i.description ?? null, priceAmount: i.priceAmount != null ? String(i.priceAmount) : null, rentalPeriod: (i.listingType ?? "SALE") === "RENT" ? "MONTH" : null, availabilityDate: i.availabilityDate ?? null, furnished: i.furnished ?? null, petsPolicy: i.petsPolicy ?? null, smokingPolicy: i.smokingPolicy ?? null, parking: i.parking ?? null, leaseTerms: i.leaseTerms ?? null, inclusions: i.inclusions ?? [], features: i.features ?? {}, wizardStep: i.wizardStep ?? 1 }).returning();
20 + await audit("listing.create", { actorId: ownerId, objectType: "listing", objectId: l.id, meta: { listingType: l.listingType } });
21 + return { ...l, property: p, media: [] };
22 +}
23 +
24 +export async function getListingFull(id: string): Promise<ListingFull | null> {
25 + const d = db();
26 + const [row] = await d.select({ l: listings, p: properties }).from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.id, id)).limit(1);
27 + if (!row) return null;
28 + const media = await d.select().from(listingMedia).where(and(eq(listingMedia.listingId, id), eq(listingMedia.status, "READY"))).orderBy(listingMedia.position);
29 + return { ...row.l, property: row.p, media };
30 +}
31 +export async function getListingBySlug(slug: string) { const d = db(); const [r] = await d.select({ id: listings.id }).from(listings).where(eq(listings.slug, slug)).limit(1); return r ? getListingFull(r.id) : null; }
32 +
33 +export function assertOwner(l: ListingFull, userId: string, roles: string[] = []) { if (l.ownerUserId !== userId && !roles.includes("ADMIN") && !roles.includes("MODERATOR")) throw new AuthError(403, "Cette annonce ne vous appartient pas"); }
34 +
35 +export async function updateListing(id: string, actorId: string, roles: string[], input: ListingInputT): Promise<ListingFull> {
36 + const d = db(); const i = ListingInput.parse(input); const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles);
37 + if (["ARCHIVED", "SOLD", "RENTED"].includes(cur.status)) throw new AuthError(409, "Annonce clôturée : réactivez-la d'abord");
38 + if (i.version != null && i.version !== cur.version) throw new ConflictError();
39 + return d.transaction(async (tx) => {
40 + await tx.insert(listingVersions).values({ listingId: id, version: cur.version, actorId, snapshot: { listing: { ...cur, property: undefined, media: undefined }, property: cur.property } });
41 + const pset: Partial<typeof properties.$inferInsert> = {};
42 + if (i.propertyType !== undefined) pset.propertyType = i.propertyType;
43 + if (i.address !== undefined) pset.canonicalAddress = i.address; if (i.unit !== undefined) pset.unit = i.unit;
44 + if (i.city !== undefined) { pset.city = i.city ? displayCity(i.city) : null; pset.region = i.region ?? normRegion(null, i.city); }
45 + if (i.postalCode !== undefined) pset.postalCode = i.postalCode?.toUpperCase().replace(/\s/g, "") ?? null;
46 + if (i.lat !== undefined) pset.lat = i.lat; if (i.lng !== undefined) pset.lng = i.lng;
47 + if (i.roleUnitId !== undefined) { pset.roleUnitId = i.roleUnitId; pset.geoSource = i.roleUnitId ? `role_unit:${i.roleUnitId}` : "manual"; }
48 + if (i.privacyLevel !== undefined) pset.privacyLevel = i.privacyLevel;
49 + if (i.bedrooms !== undefined) pset.bedrooms = i.bedrooms != null ? String(i.bedrooms) : null; if (i.bathrooms !== undefined) pset.bathrooms = i.bathrooms != null ? String(i.bathrooms) : null;
50 + if (i.areaSqft !== undefined) pset.areaSqft = i.areaSqft; if (i.lotSqft !== undefined) pset.lotSqft = i.lotSqft; if (i.yearBuilt !== undefined) pset.yearBuilt = i.yearBuilt;
51 + if (Object.keys(pset).length) await tx.update(properties).set(pset).where(eq(properties.id, cur.propertyId));
52 + const lset: Partial<typeof listings.$inferInsert> = { version: cur.version + 1, updatedAt: new Date() };
53 + if (i.listingType !== undefined) { lset.listingType = i.listingType; lset.rentalPeriod = i.listingType === "RENT" ? "MONTH" : null; }
54 + if (i.sourceType !== undefined) lset.sourceType = i.sourceType;
55 + for (const k of ["title", "description", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (i[k] !== undefined) (lset as Record<string, unknown>)[k] = i[k];
56 + if (i.priceAmount !== undefined) lset.priceAmount = i.priceAmount != null ? String(i.priceAmount) : null;
57 + // Toute modification substantielle d'une annonce publiée repasse par la vérification automatique
58 + if (cur.status === "PUBLISHED" && (i.title !== undefined || i.description !== undefined || i.priceAmount !== undefined)) { const flags = spamFlags({ title: i.title ?? cur.title, description: i.description ?? cur.description, priceAmount: i.priceAmount ?? cur.priceAmount, listingType: i.listingType ?? cur.listingType }); if (flags.length) { lset.status = "PENDING_REVIEW"; lset.moderationState = "FLAGGED"; lset.moderationNote = flags.join(" ; "); } }
59 + await tx.update(listings).set(lset).where(eq(listings.id, id));
60 + await audit("listing.update", { actorId, objectType: "listing", objectId: id, meta: { fields: Object.keys(i) } });
61 + return (await getListingFull(id))!;
62 + });
63 +}
64 +
65 +export async function attest(id: string, actorId: string, roles: string[]) { const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles); await db().update(listings).set({ attestedAt: new Date(), updatedAt: new Date() }).where(eq(listings.id, id)); await audit("listing.attest", { actorId, objectType: "listing", objectId: id }); }
66 +
67 +export type Transition = "publish" | "pause" | "resume" | "archive" | "mark-sold" | "mark-rented" | "reopen";
68 +const ALLOWED: Record<Transition, string[]> = { publish: ["DRAFT", "PAUSED", "REJECTED", "PENDING_REVIEW"], pause: ["PUBLISHED"], resume: ["PAUSED"], archive: ["DRAFT", "PUBLISHED", "PAUSED", "REJECTED", "PENDING_REVIEW", "SOLD", "RENTED"], "mark-sold": ["PUBLISHED", "PAUSED"], "mark-rented": ["PUBLISHED", "PAUSED"], reopen: ["ARCHIVED", "SOLD", "RENTED"] };
69 +
70 +export async function transition(id: string, actorId: string, roles: string[], t: Transition): Promise<{ listing: ListingFull; problems: string[] }> {
71 + const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles);
72 + if (!ALLOWED[t].includes(cur.status)) throw new AuthError(409, `Transition « ${t} » impossible depuis l'état ${cur.status}`);
73 + const set: Partial<typeof listings.$inferInsert> = { updatedAt: new Date(), version: cur.version + 1 };
74 + let problems: string[] = [];
75 + if (t === "publish") {
76 + problems = publishProblems({ listingType: cur.listingType, propertyType: cur.property.propertyType, priceAmount: cur.priceAmount, title: cur.title, description: cur.description, city: cur.property.city, lat: cur.property.lat, mediaCount: cur.media.length, attestedAt: cur.attestedAt, bedrooms: cur.property.bedrooms, areaSqft: cur.property.areaSqft });
77 + if (problems.length) return { listing: cur, problems };
78 + const flags = spamFlags(cur);
79 + if (flags.length || cur.moderationState === "FLAGGED") { set.status = "PENDING_REVIEW"; set.moderationState = "FLAGGED"; set.moderationNote = flags.join(" ; ") || cur.moderationNote; }
80 + else { set.status = "PUBLISHED"; set.moderationState = cur.moderationState === "HUMAN_OK" ? "HUMAN_OK" : "AUTO_OK"; set.publicationAt = cur.publicationAt ?? new Date(); set.expiresAt = new Date(Date.now() + 90 * 86400000); }
81 + }
82 + if (t === "pause") set.status = "PAUSED";
83 + if (t === "resume") { set.status = "PUBLISHED"; set.expiresAt = new Date(Date.now() + 90 * 86400000); }
84 + if (t === "archive") set.status = "ARCHIVED";
85 + if (t === "mark-sold") set.status = "SOLD";
86 + if (t === "mark-rented") set.status = "RENTED";
87 + if (t === "reopen") set.status = "DRAFT";
88 + await db().update(listings).set(set).where(eq(listings.id, id));
89 + await audit(`listing.${t}`, { actorId, objectType: "listing", objectId: id, meta: { from: cur.status, to: set.status } });
90 + return { listing: (await getListingFull(id))!, problems };
91 +}
92 +
93 +export async function myListings(ownerId: string) {
94 + const d = db();
95 + const rows = await d.select({ l: listings, p: properties, mediaCount: sql<number>`(select count(*) from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY')::int`, cover: sql<string | null>`(select m.storage_key from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY' order by m.position limit 1)` })
96 + .from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.ownerUserId, ownerId)).orderBy(desc(listings.updatedAt));
97 + return rows;
98 +}
99 +export async function deleteDraft(id: string, actorId: string, roles: string[]) { const cur = await getListingFull(id); if (!cur) return; assertOwner(cur, actorId, roles); if (cur.status !== "DRAFT") throw new AuthError(409, "Seuls les brouillons peuvent être supprimés ; archivez l'annonce sinon"); await db().delete(listings).where(eq(listings.id, id)); await db().delete(properties).where(eq(properties.id, cur.propertyId)); await audit("listing.delete_draft", { actorId, objectType: "listing", objectId: id }); }
100 +export async function incrementViews(ids: string[]) { if (ids.length) await db().update(listings).set({ viewsCount: sql`${listings.viewsCount} + 1` }).where(inArray(listings.id, ids)); }
added src/lib/listings/types.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { z } from "zod";
2 +export const PROPERTY_TYPES = ["appartement", "condo", "maison", "jumele", "plex", "chalet", "terrain", "commercial", "autre"] as const;
3 +export const PROPERTY_TYPE_LABEL: Record<string, string> = { appartement: "Appartement", condo: "Condo", maison: "Maison", jumele: "Jumelé", plex: "Plex / multilogement", chalet: "Chalet", terrain: "Terrain", commercial: "Local commercial", autre: "Autre" };
4 +export const LISTING_STATUS = ["DRAFT", "PENDING_REVIEW", "PUBLISHED", "PAUSED", "REJECTED", "ARCHIVED", "SOLD", "RENTED"] as const;
5 +export const STATUS_LABEL: Record<string, string> = { DRAFT: "Brouillon", PENDING_REVIEW: "En vérification", PUBLISHED: "Publiée", PAUSED: "En pause", REJECTED: "Refusée", ARCHIVED: "Archivée", SOLD: "Vendue", RENTED: "Louée" };
6 +
7 +export const ListingInput = z.object({
8 + listingType: z.enum(["SALE", "RENT"]).optional(),
9 + sourceType: z.enum(["DIRECT_OWNER", "DIRECT_AGENT"]).optional(),
10 + propertyType: z.enum(PROPERTY_TYPES).optional(),
11 + address: z.string().trim().max(200).nullable().optional(),
12 + unit: z.string().trim().max(20).nullable().optional(),
13 + city: z.string().trim().max(100).nullable().optional(),
14 + region: z.string().trim().max(100).nullable().optional(),
15 + postalCode: z.string().trim().regex(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/, "Code postal canadien attendu").nullable().optional(),
16 + lat: z.number().min(44).max(63).nullable().optional(), lng: z.number().min(-80).max(-57).nullable().optional(),
17 + roleUnitId: z.string().max(40).nullable().optional(),
18 + privacyLevel: z.enum(["EXACT", "STREET_ONLY", "AREA_ONLY"]).optional(),
19 + bedrooms: z.number().min(0).max(30).nullable().optional(), bathrooms: z.number().min(0).max(20).nullable().optional(),
20 + areaSqft: z.number().int().min(50).max(200000).nullable().optional(), lotSqft: z.number().int().min(100).max(50000000).nullable().optional(), yearBuilt: z.number().int().min(1600).max(2030).nullable().optional(),
21 + title: z.string().trim().max(120).nullable().optional(),
22 + description: z.string().trim().max(6000).nullable().optional(),
23 + priceAmount: z.number().min(0).max(500000000).nullable().optional(),
24 + availabilityDate: z.string().regex(/^(now|\d{4}-\d{2}-\d{2})$/).nullable().optional(),
25 + furnished: z.boolean().nullable().optional(),
26 + petsPolicy: z.enum(["allowed", "not_allowed", "ask"]).nullable().optional(),
27 + smokingPolicy: z.enum(["allowed", "not_allowed"]).nullable().optional(),
28 + parking: z.string().trim().max(120).nullable().optional(),
29 + leaseTerms: z.string().trim().max(400).nullable().optional(),
30 + inclusions: z.array(z.string().trim().min(1).max(60)).max(40).optional(),
31 + features: z.record(z.string(), z.union([z.string().max(200), z.number(), z.boolean()])).optional(),
32 + wizardStep: z.number().int().min(0).max(8).optional(),
33 + version: z.number().int().min(1).optional(), // concurrence optimiste
34 +});
35 +export type ListingInputT = z.infer<typeof ListingInput>;
36 +
37 +/** Règles de publication : champs minimaux par type et par marché ; renvoie la liste des manques (français). */
38 +export function publishProblems(l: { listingType: string; propertyType: string; priceAmount: string | number | null; title: string | null; description: string | null; city: string | null; lat: number | null; mediaCount: number; attestedAt: Date | null; bedrooms: string | number | null; areaSqft: number | null }): string[] {
39 + const p: string[] = [];
40 + if (!l.city) p.push("Municipalité manquante");
41 + if (l.lat == null) p.push("Localisation manquante (adresse non géocodée)");
42 + if (l.priceAmount == null || Number(l.priceAmount) <= 0) p.push(l.listingType === "RENT" ? "Loyer mensuel manquant" : "Prix demandé manquant");
43 + if (l.listingType === "RENT" && Number(l.priceAmount) > 50000) p.push("Loyer mensuel invraisemblable");
44 + if (l.listingType === "SALE" && Number(l.priceAmount) < 10000) p.push("Prix de vente invraisemblable");
45 + if (!l.title || l.title.length < 8) p.push("Titre trop court (8 caractères minimum)");
46 + if (!l.description || l.description.length < 40) p.push("Description trop courte (40 caractères minimum)");
47 + if (l.mediaCount < 1) p.push("Au moins une photo requise");
48 + if (!["terrain", "commercial"].includes(l.propertyType) && l.bedrooms == null) p.push("Nombre de chambres manquant");
49 + if (!l.attestedAt) p.push("Attestation du droit de publier manquante");
50 + return p;
51 +}
52 +/** Détection prudente de spam/usurpation : la publication passe en vérification humaine au lieu d'être refusée. */
53 +export function spamFlags(l: { title: string | null; description: string | null; priceAmount: string | number | null; listingType: string }): string[] {
54 + const f: string[] = []; const txt = `${l.title ?? ""} ${l.description ?? ""}`;
55 + const links = (txt.match(/https?:\/\/|www\./gi) ?? []).length; if (links > 1) f.push("liens multiples dans le texte");
56 + if (/whatsapp|telegram|western union|virement.*d[ée]p[ôo]t|frais de r[ée]servation|cl[ée]s? par la poste/i.test(txt)) f.push("formulation typique d'arnaque locative");
57 + if (l.listingType === "RENT" && Number(l.priceAmount) > 0 && Number(l.priceAmount) < 350) f.push("loyer anormalement bas");
58 + if (/[A-Z]{12,}/.test(txt)) f.push("majuscules excessives");
59 + return f;
60 +}
61