This commit is contained in:
Bastien COIGNOUX
2026-05-03 20:18:33 +02:00
parent ffc2e6b895
commit bd325fe456
113 changed files with 29532 additions and 220 deletions

41
mdb-predator/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

1
mdb-predator/.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

32
mdb-predator/app.json Normal file
View File

@ -0,0 +1,32 @@
{
"expo": {
"name": "MDB-PREDATOR",
"slug": "mdb-predator",
"scheme": "mdb-predator",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#0a0a0a"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0a0a0a"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router", "expo-font"]
}
}

View File

@ -0,0 +1,26 @@
import 'react-native-gesture-handler';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { colors } from '../src/theme/colors';
export default function RootLayout() {
return (
<SafeAreaProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" options={{ title: 'MDB-PREDATOR' }} />
<Stack.Screen
name="field"
options={{ title: 'Field visit' }}
/>
</Stack>
</SafeAreaProvider>
);
}

View File

@ -0,0 +1,7 @@
import { FieldVisitChecklist } from '../src/components/FieldVisitChecklist';
const DEMO_PROPERTY_ID = 'local-demo';
export default function FieldScreen() {
return <FieldVisitChecklist propertyId={DEMO_PROPERTY_ID} />;
}

146
mdb-predator/app/index.tsx Normal file
View File

@ -0,0 +1,146 @@
import { router } from 'expo-router';
import { useMemo, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { runFinancialAgent } from '../src/agents/orchestrator';
import { analyzeDeal, RED_FLAG_MARGIN_PCT, type DealAnalysisInput } from '../src/core/dealAnalysis';
import { sharePurchaseOfferPdf } from '../src/services/offerPdf';
import { colors } from '../src/theme/colors';
const DEMO_PROPERTY_ID = 'local-demo';
export default function RedFlagDashboard() {
const [asking, setAsking] = useState('185000');
const [resale, setResale] = useState('268000');
const [surface, setSurface] = useState('88');
const [works, setWorks] = useState('42000');
const input: DealAnalysisInput = useMemo(
() => ({
resaleEstimateTtc: Number(resale.replace(/\s/g, '')) || 0,
surfaceM2: Number(surface.replace(',', '.')) || 1,
worksTotalEur: Number(works.replace(/\s/g, '')) || 0,
notaryRateOnPurchase: 0.077,
saleAgencyRateOnResale: 0.05,
miscAcquisitionEur: 2500,
miscSaleEur: 1800,
holdingMonths: 6,
holdingAnnualRateOnPrincipal: 0.055,
}),
[resale, surface, works],
);
const analysis = useMemo(() => analyzeDeal(input), [input]);
const askNum = Number(asking.replace(/\s/g, '')) || 0;
const fin = useMemo(
() => runFinancialAgent(input, askNum),
[input, askNum],
);
const red = analysis.isRedFlag(askNum);
return (
<ScrollView contentContainerStyle={styles.scroll}>
<View style={[styles.banner, red ? styles.bannerBad : styles.bannerOk]}>
<Text style={styles.bannerTitle}>{red ? 'RED FLAG' : 'GO'}</Text>
<Text style={styles.bannerSub}>
Marge nette à prix affiché : {(analysis.netMarginPct(askNum) * 100).toFixed(1)} %
{' — '}seuil {(RED_FLAG_MARGIN_PCT * 100).toFixed(0)} %
</Text>
<Text style={styles.bannerSub}>
Prix dachat max (algo) :{' '}
{fin.maxBuyingPriceEur.toLocaleString('fr-FR')}
</Text>
</View>
<Text style={styles.h}>Hypothèses deal</Text>
<Field label="Prix affiché / offre actuelle (€)" value={asking} onChange={setAsking} />
<Field label="Prix de revente estimé TTC (€)" value={resale} onChange={setResale} />
<Field label="Surface (m²)" value={surface} onChange={setSurface} />
<Field label="Travaux estimés (€)" value={works} onChange={setWorks} />
<Pressable style={styles.btn} onPress={() => router.push('/field')}>
<Text style={styles.btnText}>Field visit checklist</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnGhost]}
onPress={() =>
void sharePurchaseOfferPdf({
propertyTitle: 'Bien cible',
address: 'À compléter',
maxBuyPriceEur: fin.maxBuyingPriceEur,
})
}
>
<Text style={styles.btnTextGhost}>One-click offer (PDF)</Text>
</Pressable>
<Text style={styles.foot}>
Agents SCOUT / ENGINEER / APIs : brancher Edge Functions + clés serveur.
Dossier checklist : id « {DEMO_PROPERTY_ID} ».
</Text>
</ScrollView>
);
}
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (s: string) => void;
}) {
return (
<View style={{ marginBottom: 12 }}>
<Text style={styles.lab}>{label}</Text>
<TextInput
keyboardType="decimal-pad"
value={value}
onChangeText={onChange}
style={styles.inp}
placeholderTextColor={colors.muted}
/>
</View>
);
}
const styles = StyleSheet.create({
scroll: { padding: 16, paddingBottom: 48 },
banner: { borderRadius: 16, padding: 18, marginBottom: 20 },
bannerBad: { backgroundColor: '#3a121c' },
bannerOk: { backgroundColor: '#0f2a1c' },
bannerTitle: { color: colors.text, fontSize: 28, fontWeight: '900' },
bannerSub: { color: colors.muted, marginTop: 8, lineHeight: 20 },
h: { color: colors.text, fontSize: 16, fontWeight: '700', marginBottom: 10 },
lab: { color: colors.muted, fontSize: 12, marginBottom: 6 },
inp: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
padding: 12,
color: colors.text,
backgroundColor: colors.card,
},
btn: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 10,
},
btnGhost: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
btnTextGhost: { color: colors.text, fontWeight: '700', fontSize: 16 },
foot: { color: colors.muted, marginTop: 24, fontSize: 12, lineHeight: 18 },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['expo-router/babel'],
};
};

9378
mdb-predator/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
mdb-predator/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "mdb-predator",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@supabase/supabase-js": "^2.49.1",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.12",
"expo-print": "~15.0.8",
"expo-router": "~6.0.23",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -0,0 +1,63 @@
/**
* Orchestration multi-agents — appels OpenAI / Edge Functions à brancher ici.
* Les implémentations réelles ne doivent pas exposer la clé API dans l'app mobile.
*/
import type {
DealMatch,
EngineerEstimate,
FinancialSnapshot,
ScoutSignal,
} from './types';
import { analyzeDeal, type DealAnalysisInput } from '../core/dealAnalysis';
const DISTRESS = [
'succession',
'urgent',
'travaux',
'dpe g',
'sous compromis',
'divorce',
];
export function runScoutStub(listingText: string): ScoutSignal {
const lower = listingText.toLowerCase();
const hits = DISTRESS.filter((k) => lower.includes(k));
return {
source: 'manual',
distressKeywords: hits,
score: Math.min(100, hits.length * 25 + 10),
};
}
export function runEngineerStub(surfaceM2: number, tier: 'light' | 'medium' | 'heavy'): EngineerEstimate {
const perM2 = tier === 'light' ? 450 : tier === 'medium' ? 950 : 1800;
const base = surfaceM2 * perM2;
return {
photoRefs: [],
notes: tier,
worksLowEur: Math.round(base * 0.85),
worksHighEur: Math.round(base * 1.15),
costPerM2Assumption: perM2,
};
}
export function runFinancialAgent(
input: DealAnalysisInput,
askingPriceTtc: number,
): FinancialSnapshot {
const r = analyzeDeal(input);
return {
maxBuyingPriceEur: r.maxBuyingPriceEur,
netMarginPctAtAsk: r.netMarginPct(askingPriceTtc),
redFlag: r.isRedFlag(askingPriceTtc),
};
}
export function runDealMakerStub(buyers: { id: string; name: string }[]): DealMatch[] {
return buyers.slice(0, 5).map((b, i) => ({
buyerId: b.id,
buyerName: b.name,
fitScore: 90 - i * 5,
}));
}

View File

@ -0,0 +1,28 @@
export type AgentId = 'SCOUT' | 'ENGINEER' | 'FINANCIAL' | 'DEAL_MAKER';
export interface ScoutSignal {
source: 'apify' | 'manual' | 'zendesk';
listingUrl?: string;
distressKeywords: string[];
score: number;
}
export interface EngineerEstimate {
photoRefs: string[];
notes: string;
worksLowEur: number;
worksHighEur: number;
costPerM2Assumption: number;
}
export interface FinancialSnapshot {
maxBuyingPriceEur: number;
netMarginPctAtAsk: number;
redFlag: boolean;
}
export interface DealMatch {
buyerId: string;
buyerName: string;
fitScore: number;
}

View File

@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import {
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { loadFieldVisit, saveFieldVisit } from '../offline/fieldVisitStorage';
import { colors } from '../theme/colors';
export type FieldItemKey = 'roof' | 'structure' | 'humidity' | 'electricity';
export interface FieldChecklistState {
items: Record<FieldItemKey, boolean>;
notes: string;
updatedAt: string;
}
const ITEMS: { key: FieldItemKey; label: string; hint: string }[] = [
{ key: 'roof', label: 'Toiture', hint: 'Fuites, charpente, couverture' },
{ key: 'structure', label: 'Structure', hint: 'Murs porteurs, plancher, fissures' },
{ key: 'humidity', label: 'Humidité', hint: 'Infiltrations, remontées, cave' },
{ key: 'electricity', label: 'Électricité', hint: 'Tableau, mise aux normes' },
];
const defaultState = (): FieldChecklistState => ({
items: {
roof: false,
structure: false,
humidity: false,
electricity: false,
},
notes: '',
updatedAt: new Date().toISOString(),
});
type Props = {
propertyId: string;
};
export function FieldVisitChecklist({ propertyId }: Props) {
const [state, setState] = useState<FieldChecklistState>(defaultState);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const saved = await loadFieldVisit(propertyId);
if (!cancelled) {
if (saved) setState(saved);
setHydrated(true);
}
})();
return () => {
cancelled = true;
};
}, [propertyId]);
const persist = useCallback(
async (next: FieldChecklistState) => {
const stamped = { ...next, updatedAt: new Date().toISOString() };
setState(stamped);
await saveFieldVisit(propertyId, stamped);
},
[propertyId],
);
const toggle = (key: FieldItemKey, value: boolean) => {
void persist({
...state,
items: { ...state.items, [key]: value },
});
};
if (!hydrated) {
return (
<View style={styles.center}>
<Text style={styles.muted}>Chargement checklist</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.scroll}>
<Text style={styles.title}>Field mode</Text>
<Text style={styles.sub}>
Hors-ligne : les coches sont enregistrées sur lappareil (AsyncStorage).
</Text>
{ITEMS.map((row) => (
<View key={row.key} style={styles.row}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.label}>{row.label}</Text>
<Text style={styles.hint}>{row.hint}</Text>
</View>
<Switch
value={state.items[row.key]}
onValueChange={(v) => toggle(row.key, v)}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>
))}
<Text style={styles.section}>Notes terrain</Text>
<TextInput
style={styles.notes}
multiline
placeholder="Observations, photos référencées…"
placeholderTextColor={colors.muted}
value={state.notes}
onChangeText={(t) => void persist({ ...state, notes: t })}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
muted: { color: colors.muted },
scroll: { padding: 16, paddingBottom: 40 },
title: { color: colors.text, fontSize: 22, fontWeight: '800', marginBottom: 6 },
sub: { color: colors.muted, marginBottom: 20, lineHeight: 20 },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
label: { color: colors.text, fontWeight: '700', fontSize: 16 },
hint: { color: colors.muted, fontSize: 13, marginTop: 4 },
section: {
color: colors.muted,
marginTop: 20,
marginBottom: 8,
textTransform: 'uppercase',
fontSize: 12,
letterSpacing: 0.08,
},
notes: {
minHeight: 120,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
padding: 12,
color: colors.text,
backgroundColor: colors.card,
textAlignVertical: 'top',
},
});

View File

@ -0,0 +1,148 @@
/**
* MDB-PREDATOR — Deal Analysis (Financial agent logic, client-side).
* Résout le prix d'achat maximum pour tenir une marge nette cible après
* frais de notaire, travaux, portage, TVA sur marge (estimation) et frais de vente.
*/
export const RED_FLAG_MARGIN_PCT = 0.15;
export const DEFAULT_VAT_ON_MARGIN_RATE = 0.2;
export interface DealAnalysisInput {
/** Prix de cession estimé (TTC marché). */
resaleEstimateTtc: number;
surfaceM2: number;
/** Travaux + imprévus (hors checklist terrain = déjà inclus ici si besoin). */
worksTotalEur: number;
notaryRateOnPurchase: number;
saleAgencyRateOnResale: number;
miscAcquisitionEur: number;
miscSaleEur: number;
holdingMonths: number;
holdingAnnualRateOnPrincipal: number;
vatOnMarginRate?: number;
}
export interface DealAnalysisResult {
/** Coût total engagé pour un prix d'achat donné (hors achat = frais annexes). */
totalCashOutForPurchase: (purchaseTtc: number) => number;
/** Marge nette après TVA sur marge (€). */
netProfitEur: (purchaseTtc: number) => number;
/** Marge nette / investissement total. */
netMarginPct: (purchaseTtc: number) => number;
/** true si marge < 15 % (RED FLAG dashboard). */
isRedFlag: (purchaseTtc: number) => boolean;
/** Prix d'achat max pour respecter RED_FLAG_MARGIN_PCT sur l'enveloppe investie. */
maxBuyingPriceEur: number;
/** Prix d'achat au m² implicite pour maxBuyingPriceEur. */
maxBuyingPricePerM2: number;
/** Seuil de revente TTC break-even simplifié (hors effet TVA marginale). */
breakEvenResaleTtc: (purchaseTtc: number) => number;
}
function holdingCostEur(
principal: number,
months: number,
annualRate: number,
): number {
return principal * annualRate * (months / 12);
}
function netResaleTtc(input: DealAnalysisInput): number {
const agency = input.resaleEstimateTtc * input.saleAgencyRateOnResale;
return input.resaleEstimateTtc - agency - input.miscSaleEur;
}
/**
* Pour un prix d'achat candidat P :
* investissement = P + notaire(P) + travaux + frais achat + portage(P)
* marge brute avant TVA = produit_net_revente - investissement
* TVA sur marge ≈ taux × max(0, marge_brute)
* marge nette = marge_brute - TVA
*/
export function analyzeDeal(input: DealAnalysisInput): DealAnalysisResult {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
const resaleNet = netResaleTtc(input);
const totalCashOutForPurchase = (P: number): number => {
const notary = P * input.notaryRateOnPurchase;
const carry = holdingCostEur(
P,
input.holdingMonths,
input.holdingAnnualRateOnPrincipal,
);
return P + notary + input.worksTotalEur + input.miscAcquisitionEur + carry;
};
const grossBeforeVat = (P: number) => resaleNet - totalCashOutForPurchase(P);
const vatOnMargin = (P: number) => Math.max(0, grossBeforeVat(P)) * vatRate;
const netProfitEur = (P: number) => grossBeforeVat(P) - vatOnMargin(P);
const netMarginPct = (P: number) => {
const inv = totalCashOutForPurchase(P);
return inv > 0 ? netProfitEur(P) / inv : 0;
};
const isRedFlag = (P: number) => netMarginPct(P) < RED_FLAG_MARGIN_PCT;
const maxBuyingPriceEur = solveMaxPurchaseForMargin(
input,
vatRate,
resaleNet,
RED_FLAG_MARGIN_PCT,
);
const maxBuyingPricePerM2 =
input.surfaceM2 > 0 ? maxBuyingPriceEur / input.surfaceM2 : 0;
const breakEvenResaleTtc = (purchaseTtc: number): number => {
const inv = totalCashOutForPurchase(purchaseTtc);
const coeff = 1 - input.saleAgencyRateOnResale;
if (coeff <= 0) return Number.POSITIVE_INFINITY;
return (inv + input.miscSaleEur) / coeff;
};
return {
totalCashOutForPurchase,
netProfitEur,
netMarginPct,
isRedFlag,
maxBuyingPriceEur,
maxBuyingPricePerM2,
breakEvenResaleTtc,
};
}
function solveMaxPurchaseForMargin(
input: DealAnalysisInput,
vatRate: number,
resaleNet: number,
targetNetMarginPct: number,
): number {
let low = 0;
let high = Math.max(input.resaleEstimateTtc * 1.2, 1);
for (let i = 0; i < 64; i++) {
const mid = (low + high) / 2;
const notary = mid * input.notaryRateOnPurchase;
const carry = holdingCostEur(
mid,
input.holdingMonths,
input.holdingAnnualRateOnPrincipal,
);
const inv =
mid +
notary +
input.worksTotalEur +
input.miscAcquisitionEur +
carry;
const gross = resaleNet - inv;
const vat = Math.max(0, gross) * vatRate;
const net = gross - vat;
const margin = inv > 0 ? net / inv : 0;
if (margin >= targetNetMarginPct) {
low = mid;
} else {
high = mid;
}
}
return Math.max(0, Math.round(low));
}

View File

@ -0,0 +1,15 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const url = process.env.EXPO_PUBLIC_SUPABASE_URL ?? '';
const key = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '';
export const supabase = createClient(url, key, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

View File

@ -0,0 +1,21 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { FieldChecklistState } from '../components/FieldVisitChecklist';
const key = (propertyId: string) => `mdb-predator:field:${propertyId}`;
export async function loadFieldVisit(propertyId: string): Promise<FieldChecklistState | null> {
const raw = await AsyncStorage.getItem(key(propertyId));
if (!raw) return null;
try {
return JSON.parse(raw) as FieldChecklistState;
} catch {
return null;
}
}
export async function saveFieldVisit(
propertyId: string,
state: FieldChecklistState,
): Promise<void> {
await AsyncStorage.setItem(key(propertyId), JSON.stringify(state));
}

View File

@ -0,0 +1,17 @@
/**
* Apify actors (SeLoger / PAP / LBC) — déclencher depuis n8n ou Edge Function.
* Retourne uniquement des annonces pré-scorées côté cloud.
*/
export interface ScrapedListing {
url: string;
title: string;
priceEur: number;
city: string;
rawText: string;
grade: 'A' | 'B' | 'C';
}
export async function pullGradeADealsStub(): Promise<ScrapedListing[]> {
return [];
}

View File

@ -0,0 +1,16 @@
/**
* DVF (data.gouv) — à appeler depuis une Edge Function Supabase pour éviter CORS
* et mutualiser le cache. Signature prête pour lAPI tabulaire.
*/
export interface DvfStreetContext {
inseeCode: string;
streetNormalized: string;
yearMin?: number;
}
export async function fetchDvfMedianPriceM2Stub(
_ctx: DvfStreetContext,
): Promise<number | null> {
return null;
}

View File

@ -0,0 +1,32 @@
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { Platform } from 'react-native';
export async function sharePurchaseOfferPdf(params: {
propertyTitle: string;
address: string;
maxBuyPriceEur: number;
sellerName?: string;
}): Promise<void> {
const html = `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"/>
<style>body{font-family:system-ui;padding:32px;color:#111}
h1{font-size:22px} .box{border:1px solid #ccc;padding:16px;border-radius:8px;margin-top:16px}
</style></head><body>
<h1>Offre d'achat — ${escape(params.propertyTitle)}</h1>
<p>${escape(params.address)}</p>
<div class="box">
<p>Montant de l'offre : <strong>${params.maxBuyPriceEur.toLocaleString('fr-FR')} €</strong></p>
<p>(${params.sellerName ? `Destinataire : ${escape(params.sellerName)}` : 'À compléter'})</p>
</div>
<p style="margin-top:24px;font-size:11px;color:#666">MDB-PREDATOR — modèle indicatif, valider avec votre notaire / juriste.</p>
</body></html>`;
const { uri } = await Print.printToFileAsync({ html });
if (Platform.OS === 'web') return;
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: 'application/pdf', dialogTitle: 'Offre dachat' });
}
}
function escape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,10 @@
/** Nominatim / OSM — respecter la politique dusage ; préférer backend pour prod. */
export interface GeoPoint {
lat: number;
lon: number;
}
export async function reverseGeocodeStub(_p: GeoPoint): Promise<string | null> {
return null;
}

View File

@ -0,0 +1,5 @@
/** Pappers — surveillance SCI / procédures (clé API côté serveur uniquement). */
export async function searchCompanySignalsStub(_siren: string): Promise<unknown[]> {
return [];
}

View File

@ -0,0 +1,10 @@
export const colors = {
bg: '#070b10',
card: '#101820',
border: '#1f2a36',
text: '#f2f6fb',
muted: '#8b9bb0',
accent: '#ff4d6d',
danger: '#ff3355',
ok: '#3ecf8e',
};

View File

@ -0,0 +1,100 @@
-- MDB-PREDATOR — cœur données (Supabase)
create type public.property_status as enum (
'sourcing',
'visited',
'under_offer',
'sold'
);
create table if not exists public.properties (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
title text not null default 'Property',
address_line text,
city text,
postal_code text,
insee_code text,
latitude double precision,
longitude double precision,
surface_m2 numeric(12, 2),
status public.property_status not null default 'sourcing',
asking_price_eur numeric(14, 2),
resale_estimate_eur numeric(14, 2),
dvf_street_median_m2 numeric(14, 2),
works_estimate_eur numeric(14, 2) not null default 0,
distress_score smallint,
scout_payload jsonb,
engineer_payload jsonb,
financial_payload jsonb,
deal_maker_payload jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists properties_user_idx on public.properties (user_id);
create index if not exists properties_status_idx on public.properties (status);
create table if not exists public.renovation_templates (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
label text not null,
tier text not null check (tier in ('light', 'medium', 'heavy')),
cost_per_m2_eur numeric(12, 2) not null,
default_misc_eur numeric(12, 2) not null default 0,
sort_order int not null default 0
);
insert into public.renovation_templates (slug, label, tier, cost_per_m2_eur, default_misc_eur, sort_order)
values
('cosmetic', 'Rafraîchissement / léger', 'light', 450, 5000, 10),
('standard', 'Rénovation standard', 'medium', 950, 12000, 20),
('structural', 'Lourd / structure + toiture', 'heavy', 1800, 35000, 30)
on conflict (slug) do nothing;
create table if not exists public.buyers_portfolio (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
display_name text not null,
budget_min_eur numeric(14, 2),
budget_max_eur numeric(14, 2),
sectors jsonb,
min_yield_pct numeric(6, 3),
min_net_margin_pct numeric(5, 2) default 12,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists buyers_user_idx on public.buyers_portfolio (user_id);
create or replace function public.predator_set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists tr_properties_updated on public.properties;
create trigger tr_properties_updated
before update on public.properties
for each row execute procedure public.predator_set_updated_at();
drop trigger if exists tr_buyers_updated on public.buyers_portfolio;
create trigger tr_buyers_updated
before update on public.buyers_portfolio
for each row execute procedure public.predator_set_updated_at();
alter table public.properties enable row level security;
alter table public.buyers_portfolio enable row level security;
alter table public.renovation_templates enable row level security;
create policy properties_owner on public.properties
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy buyers_owner on public.buyers_portfolio
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy renovation_read on public.renovation_templates
for select to authenticated using (true);

View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}