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>
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 | ||