This commit is contained in:
Bastien COIGNOUX
2026-05-04 06:02:10 +02:00
parent 7b3e50ff29
commit 7f94f83940
128 changed files with 415 additions and 39155 deletions

View File

@ -1,97 +1,63 @@
# Contexte projet — Application Marchand de Biens # Application Marchand de Biens — Contexte Cursor
## Qui utilise cette app ## Infrastructure
Marchand de biens professionnel en France. L'app est utilisée au quotidien sur mobile (iOS/Android) et navigateur web. Elle remplace un ensemble d'outils épars (notes, tableurs, contacts téléphoniques, etc.). - Backend : PocketBase dans Docker (docker/docker-compose.dev.yml)
- Binaire PocketBase : /usr/local/bin/pocketbase
- Données : /pb_data (flag --dir=/pb_data dans docker-compose)
- OS dev : Windows Git Bash → toujours utiliser MSYS_NO_PATHCONV=1 pour docker exec
- PocketBase version : v0.23+
- URL locale : http://localhost:8090
- URL prod : https://SOUS_DOMAINE.duckdns.org (NAS Synology)
## Stack technique ## Stack technique
- **Frontend** : React Native avec Expo (SDK 51+) - Frontend : React Native avec Expo SDK 51 + Expo Router
- **Navigation** : Expo Router (file-based routing) - SDK PocketBase : npm package "pocketbase"
- **Base de données** : Supabase (PostgreSQL) - UI : NativeWind (Tailwind pour React Native)
- **Auth** : Supabase Auth (email/password) - State : Zustand + React Query (TanStack)
- **Stockage fichiers** : Supabase Storage (photos, PDFs) - IA : API Anthropic Claude via PocketBase Hook (jamais côté client)
- **IA** : API Anthropic Claude (claude-sonnet-4-20250514) - Déploiement mobile : Expo EAS
- **UI** : NativeWind (Tailwind pour React Native) + React Native Paper pour les composants complexes
- **State** : Zustand pour le state global, React Query (TanStack) pour le cache serveur
- **Déploiement mobile** : Expo EAS
- **Déploiement web** : Vercel
## Conventions de code ## Client PocketBase — pattern obligatoire
- TypeScript strict partout, jamais de `any` // /services/pocketbase.ts — singleton, importer partout
- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants import PocketBase from 'pocketbase';
- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`) export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
- Les appels Supabase se font UNIQUEMENT dans les hooks, jamais dans les composants
- Les types TypeScript sont définis dans `/types/database.ts` (généré depuis Supabase)
- Les constantes métier sont dans `/constants/metier.ts`
- Toujours gérer les états de chargement et d'erreur
- Commentaires en français pour la logique métier, anglais pour le code technique
## Vocabulaire métier (utiliser ces termes précis) ## Collections PocketBase (toutes créées via migration)
- **Bien** : propriété immobilière prospectée ou acquise etapes_pipeline, contacts, biens, analyses_financieres,
- **Piste** : bien en cours d'analyse, pas encore d'offre visites, taches, notes_biens, documents_biens, devis_travaux
- **Dossier** : bien avec offre en cours ou acte signé
- **Fiche bien** : écran de détail d'un bien
- **Compromis** : avant-contrat de vente (SPC)
- **Acte** : acte authentique de vente chez notaire
- **Portage** : période entre achat et revente (coût = intérêts + taxes)
- **Marge brute** : prix revente - prix achat - travaux - frais notaire achat
- **Marge nette** : marge brute - frais de portage - frais d'agence vente - impôts
- **DPE** : Diagnostic de Performance Énergétique
- **Surface habitable** : surface loi Carrez pour appartements
- **Marchand de biens** = le user, l'utilisateur de cette app
## Modules de l'application ## Règles de code
1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente) - TypeScript strict, jamais de any
2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo) - Appels PocketBase UNIQUEMENT dans les hooks (/hooks/)
3. **Fiches biens** : dossier complet par bien (photos, docs, historique) - Jamais de nouvelle instance PocketBase, toujours importer pb
4. **Calculateur** : analyse de rentabilité financière - Commentaires métier en français, code en anglais
5. **Visites** : compte-rendus de visites avec check-list - Gérer loading + erreur partout
6. **Travaux** : suivi de chantier et devis
7. **Administratif** : documents, délais légaux, alertes
8. **Agenda** : tâches et rappels liés aux biens
9. **Dashboard** : vue globale et KPIs
## Structure des dossiers ## Vocabulaire métier
``` - Bien : propriété immobilière prospectée ou acquise
/app → écrans (Expo Router) - Piste : bien en phase d'analyse
/(tabs) → navigation principale - Portage : période entre achat et revente
/prospection - Marge brute : prix revente - prix achat - travaux - frais notaire
/annuaire - Marge nette : marge brute - portage - frais agence - impôts
/agenda
/dashboard
/bien/[id] → fiche bien
/visite/[id] → rapport de visite
/contact/[id] → fiche contact
/components → composants réutilisables
/ui → composants génériques (Button, Card, Input...)
/biens → composants spécifiques aux biens
/visites → composants spécifiques aux visites
/hooks → hooks personnalisés
/services → appels API (Supabase, Anthropic)
/supabase.ts → client Supabase
/ai.ts → appels Claude API
/types → types TypeScript
/constants → constantes et configuration
/utils → fonctions utilitaires
```
## Variables d'environnement nécessaires ## Formules financières
``` frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
EXPO_PUBLIC_SUPABASE_URL= frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
EXPO_PUBLIC_SUPABASE_ANON_KEY= travaux_total = budget_travaux * (1 + reserve_pct/100)
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client prix_revient = prix_achat + frais_notaire + frais_agence_achat + travaux_total + frais_portage_total
``` marge_brute = prix_revente - prix_revient
marge_nette = marge_brute - (prix_revente * frais_agence_vente_pct/100) - (marge_brute * taux_impot/100)
## Règles de sécurité importantes ## Structure dossiers
- La clé API Anthropic ne doit JAMAIS être dans le code client /docker → docker-compose dev + prod
- Créer une Supabase Edge Function pour les appels IA /pocketbase
- Row Level Security (RLS) activé sur toutes les tables Supabase /pb_data → données (dans .gitignore)
- Les photos et docs sont dans des buckets Supabase privés /pb_hooks → hooks JS côté serveur (IA)
/pb_migrations → migrations auto au démarrage
## Calculs financiers — formules exactes /app → code Expo React Native
``` /app → écrans Expo Router
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf) /components → composants UI
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage /hooks → hooks métier
marge_brute = prix_revente_cible - prix_revient /services → pocketbase.ts
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle /types → types TypeScript
taux_marge_brute = marge_brute / prix_revente_cible * 100 /constants → constantes métier
```

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# Dev local
EXPO_PUBLIC_PB_URL=http://localhost:8090
# Production NAS (décommenter)
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
# Clé IA (jamais dans Git)
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
# DuckDNS (production)
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
DUCKDNS_TOKEN=VOTRE_TOKEN

43
.gitignore vendored
View File

@ -1,41 +1,8 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files pocketbase/pb_data/
.env.local
# dependencies .env.production
*.env
docker/ssl/
node_modules/ node_modules/
# Expo
.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 .DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

1
.npmrc
View File

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

146
AGENTS.md
View File

@ -1,127 +1,23 @@
# AGENTS — Suivi des sessions Cursor # AGENTS — Suivi du projet
> Ce fichier est lu au début de chaque session Cursor pour reprendre le contexte. ## État actuel
> Mets à jour la section "État" après chaque session. - [x] Docker + PocketBase configuré et lancé
- [x] Migration collections créée
- [ ] Migration appliquée avec succès
- [ ] App Expo initialisée
- [ ] Auth fonctionnelle
- [ ] Navigation complète
- [ ] Module Prospection
- [ ] Module Fiche bien + Calculateur
- [ ] Module Contacts
- [ ] Module Visites + IA
- [ ] Module Agenda
- [ ] Dashboard
--- ## Infos techniques
- PocketBase : http://localhost:8090
## État global du projet - Admin : http://localhost:8090/_/ (admin@mdb.fr)
- Binaire : /usr/local/bin/pocketbase
- [ ] Agent 0 — Setup initial (Expo + Supabase) - Données : /pb_data
- [ ] Agent 1 — Schéma base de données + types TypeScript - OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
- [ ] Agent 2 — Navigation + écrans vides - PocketBase : v0.23+
- [ ] Agent 3 — Module Prospection (pipeline Kanban)
- [ ] Agent 4 — Module Fiche Bien
- [ ] Agent 5 — Calculateur financier
- [ ] Agent 6 — Module Annuaire contacts
- [ ] Agent 7 — Module Visites (avec IA)
- [ ] Agent 8 — Module Agenda & tâches
- [ ] Agent 9 — Dashboard & KPIs
- [ ] Agent 10 — Module Travaux
- [ ] Agent 11 — Module Administratif (alertes, docs)
- [ ] Agent 12 — Polish mobile (offline, notifications push)
---
## Agent 0 — Setup initial
**Statut** : ⬜ Non démarré
**Objectif** : Projet Expo fonctionnel connecté à Supabase avec auth
**Livrable** : App qui se lance, login qui fonctionne, navigation de base
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 1 — Base de données
**Statut** : ⬜ Non démarré
**Objectif** : Toutes les tables SQL créées dans Supabase avec RLS
**Livrable** : schema.sql exécuté, types TypeScript générés
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 2 — Navigation
**Statut** : ⬜ Non démarré
**Objectif** : Structure de navigation complète avec tous les onglets
**Livrable** : Tous les écrans existent (même vides), navigation fonctionnelle
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 3 — Prospection
**Statut** : ⬜ Non démarré
**Objectif** : Pipeline Kanban des biens
**Livrable** : Vue Kanban, création d'un bien, déplacement entre étapes
**Tables utilisées** : `biens`, `etapes_pipeline`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 4 — Fiche bien
**Statut** : ⬜ Non démarré
**Objectif** : Écran détail complet d'un bien
**Livrable** : Fiche avec infos, photos, documents, historique
**Tables utilisées** : `biens`, `photos_biens`, `documents_biens`, `notes_biens`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 5 — Calculateur
**Statut** : ⬜ Non démarré
**Objectif** : Calculateur de rentabilité intégré dans la fiche bien
**Livrable** : Formulaire avec calculs en temps réel, sauvegarde dans Supabase
**Tables utilisées** : `analyses_financieres`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 6 — Annuaire
**Statut** : ⬜ Non démarré
**Objectif** : Annuaire de contacts professionnels
**Livrable** : Liste, recherche, fiche contact, appel natif
**Tables utilisées** : `contacts`, `categories_contacts`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 7 — Visites
**Statut** : ⬜ Non démarré
**Objectif** : Module de visite avec CR généré par IA
**Livrable** : Check-list de visite, notes, génération CR via Claude API
**Tables utilisées** : `visites`, `items_checklist`, `rapports_visite`
**Edge Functions** : `generate-rapport-visite`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 8 — Agenda
**Statut** : ⬜ Non démarré
**Objectif** : Tâches et rappels liés aux biens
**Livrable** : Vue agenda, création tâches, notifications
**Tables utilisées** : `taches`, `rappels`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 9 — Dashboard
**Statut** : ⬜ Non démarré
**Objectif** : Vue d'ensemble et KPIs
**Livrable** : Chiffres clés, biens en cours, alertes urgentes
**Tables utilisées** : Toutes (requêtes agrégées)
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Notes techniques globales
_(Ajouter ici les décisions d'architecture prises en cours de projet)_
-

116
Makefile
View File

@ -1,116 +0,0 @@
# ============================================================
# Makefile — Raccourcis pour le projet mb-app
# Usage : make <commande>
# ============================================================
.PHONY: help dev dev-stop prod prod-stop logs status setup-nas deploy
# Affiche l'aide
help:
@echo ""
@echo " Développement local"
@echo " ─────────────────────────────────────────"
@echo " make dev → Lance PocketBase en local"
@echo " make dev-stop → Arrête le conteneur local"
@echo " make logs → Logs PocketBase en temps réel"
@echo " make status → État des conteneurs"
@echo ""
@echo " Production NAS"
@echo " ─────────────────────────────────────────"
@echo " make prod → Lance la stack complète NAS"
@echo " make prod-stop → Arrête tout"
@echo " make deploy → git pull + restart (sur le NAS)"
@echo ""
@echo " Setup"
@echo " ─────────────────────────────────────────"
@echo " make setup → Crée les dossiers manquants"
@echo " make ssl → Obtient le certificat SSL (1ère fois)"
@echo " make renew-ssl → Renouvelle le certificat SSL"
@echo ""
# ── DÉVELOPPEMENT LOCAL ──────────────────────────────────────
dev:
@echo "🚀 Lancement PocketBase en local..."
@[ -f .env.local ] || (echo "❌ Fichier .env.local manquant ! Copier .env.example" && exit 1)
docker compose -f docker/docker-compose.dev.yml up -d
@echo "✅ PocketBase : http://localhost:8090"
@echo "✅ Admin : http://localhost:8090/_/"
dev-stop:
docker compose -f docker/docker-compose.dev.yml down
dev-reset:
@echo "⚠️ Supprime les données locales de dev !"
@read -p "Confirmer ? (oui/non) : " c; [ "$$c" = "oui" ] || exit 1
docker compose -f docker/docker-compose.dev.yml down
rm -rf pocketbase/pb_data
@echo "✅ Données supprimées"
# ── PRODUCTION NAS ───────────────────────────────────────────
prod:
@echo "🚀 Lancement stack production..."
@[ -f .env.production ] || (echo "❌ Fichier .env.production manquant !" && exit 1)
docker compose -f docker/docker-compose.prod.yml up -d
@echo "✅ Stack lancée"
prod-stop:
docker compose -f docker/docker-compose.prod.yml down
deploy:
@echo "📦 Déploiement en cours..."
git pull origin main
docker compose -f docker/docker-compose.prod.yml restart pocketbase
@echo "✅ Déployé"
# ── LOGS & STATUS ────────────────────────────────────────────
logs:
docker compose -f docker/docker-compose.dev.yml logs -f pocketbase
logs-prod:
docker compose -f docker/docker-compose.prod.yml logs -f pocketbase
status:
docker compose -f docker/docker-compose.dev.yml ps
# ── SETUP ────────────────────────────────────────────────────
setup:
@echo "📁 Création des dossiers..."
mkdir -p pocketbase/pb_data
mkdir -p pocketbase/pb_hooks
mkdir -p pocketbase/pb_migrations
@[ -f .env.local ] || cp .env.example .env.local
@echo "✅ Structure prête"
@echo "👉 Éditer .env.local avec votre clé Anthropic"
setup-nas:
@echo "📁 Création des dossiers sur le NAS..."
mkdir -p /volume1/docker/mb-app/pb_data
mkdir -p /volume1/docker/mb-app/ssl
mkdir -p /volume1/docker/mb-app/duckdns
@[ -f .env.production ] || cp .env.example .env.production
@echo "✅ Dossiers NAS créés"
@echo "👉 Éditer .env.production"
ssl:
@echo "🔐 Obtention certificat SSL..."
@[ -f .env.production ] || (echo "❌ .env.production manquant" && exit 1)
@source .env.production && docker run --rm \
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
-v /tmp/certbot-webroot:/var/www/certbot \
-p 80:80 \
certbot/certbot certonly --standalone \
-d $$DUCKDNS_SUBDOMAINS.duckdns.org \
--non-interactive --agree-tos \
--email admin@example.com
@echo "✅ Certificat obtenu"
renew-ssl:
docker run --rm \
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
certbot/certbot renew --quiet
docker compose -f docker/docker-compose.prod.yml restart nginx
@echo "✅ Certificat renouvelé"

View File

@ -1,690 +0,0 @@
# PROMPTS CURSOR — Application Marchand de Biens
# Copier-coller chaque prompt dans Cursor Chat (Cmd+L ou Ctrl+L)
# Lire le fichier .cursorrules avant chaque session
---
## ═══════════════════════════════════════════
## AGENT 0 — Setup initial du projet
## ═══════════════════════════════════════════
## Durée estimée : 1-2h
```
Lis attentivement le fichier .cursorrules pour comprendre le contexte du projet.
Je veux créer une application React Native avec Expo pour un marchand de biens immobiliers.
Crée le projet de zéro avec cette configuration précise :
1. INITIALISATION DU PROJET :
- Initialise un nouveau projet Expo avec Expo Router : `npx create-expo-app@latest mb-app --template tabs`
- TypeScript strict
- NativeWind pour le styling (Tailwind CSS pour React Native)
2. DÉPENDANCES À INSTALLER :
- @supabase/supabase-js
- @tanstack/react-query
- zustand
- react-native-paper
- expo-image-picker
- expo-document-picker
- expo-file-system
- expo-notifications
- react-native-maps
- @react-native-async-storage/async-storage
- react-native-safe-area-context (si pas déjà inclus)
3. CONFIGURATION SUPABASE :
Crée le fichier `/services/supabase.ts` avec :
- Initialisation du client Supabase avec les variables d'env
- Export du client
- Gestion de la session (AsyncStorage pour mobile)
4. CONFIGURATION AUTH :
Crée un contexte d'authentification `/context/AuthContext.tsx` avec :
- Connexion par email/password
- Déconnexion
- État de l'utilisateur connecté
- Redirection automatique si non connecté
5. ÉCRANS D'AUTH :
- `/app/auth/login.tsx` : formulaire email + password, bouton connexion
- `/app/auth/register.tsx` : formulaire inscription (email, password, nom, prénom)
- Style sobre et professionnel, couleur principale #1D4ED8 (bleu)
6. FICHIER .env :
Crée `.env.local` avec les placeholders :
```
EXPO_PUBLIC_SUPABASE_URL=https://VOTRE_PROJET.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=VOTRE_ANON_KEY
```
7. VALIDATION :
L'app doit se lancer avec `npx expo start`, afficher l'écran de login, et permettre la connexion.
Ne génère PAS encore les écrans de contenu, seulement l'authentification qui fonctionne.
Mets à jour AGENTS.md : Agent 0 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 1 — Types TypeScript + connexion DB
## ═══════════════════════════════════════════
## Prérequis : Avoir exécuté schema.sql dans Supabase
```
Lis .cursorrules et AGENTS.md.
Le schéma SQL a été exécuté dans Supabase. Je dois maintenant créer les types TypeScript et les services de base.
1. TYPES TYPESCRIPT `/types/database.ts` :
Crée des interfaces TypeScript pour TOUTES les tables du schéma :
- Profile, Bien, EtapePipeline, AnalyseFinanciere
- Contact, BienContact
- Visite, ChecklistItem, ChecklistReponse
- PhotoBien, DocumentBien
- Tache, NoteBien, DevisTravaux
Pour chaque table, crée aussi un type "Insert" (sans id et dates auto) et "Update" (tout optionnel).
Exemple :
```typescript
export interface Bien { id: string; user_id: string; titre?: string; ... }
export type BienInsert = Omit<Bien, 'id' | 'created_at' | 'updated_at'>
export type BienUpdate = Partial<BienInsert>
```
2. CONSTANTES MÉTIER `/constants/metier.ts` :
- ETAPES_DEFAUT : noms des étapes du pipeline
- CATEGORIES_CONTACTS : liste avec labels français
- TYPES_BIENS : appartement, maison, immeuble, etc.
- CATEGORIES_CHECKLIST : avec emojis et labels
- TYPES_DOCUMENTS : compromis, acte, DPE, etc.
3. HOOK DE BASE `/hooks/useBiens.ts` :
- fetchBiens() : récupère tous les biens de l'utilisateur avec l'étape
- createBien(data) : crée un bien, retourne l'id
- updateBien(id, data) : met à jour
- deleteBien(id) : supprime
4. HOOK `/hooks/useContacts.ts` :
- fetchContacts() : avec filtre par catégorie optionnel
- createContact(data)
- updateContact(id, data)
5. REACT QUERY CONFIG `/services/queryClient.ts` :
- Initialisation de QueryClient
- Stale time : 5 minutes
- Retry : 1 fois
Mets à jour AGENTS.md : Agent 1 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 2 — Navigation complète
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée la structure de navigation complète de l'application avec Expo Router.
1. NAVIGATION PAR ONGLETS `/app/(tabs)/` :
5 onglets avec icônes Expo Vector Icons :
- `index.tsx` → Dashboard (icône: grid, label: "Vue d'ensemble")
- `prospection.tsx` → Prospection (icône: search, label: "Biens")
- `visites.tsx` → Visites (icône: clipboard, label: "Visites")
- `annuaire.tsx` → Annuaire (icône: people, label: "Contacts")
- `agenda.tsx` → Agenda (icône: calendar, label: "Agenda")
Couleur active : #1D4ED8, couleur inactive : #9CA3AF
2. ÉCRANS DE DÉTAIL (hors onglets) :
- `/app/bien/[id].tsx` → Fiche bien (en-tête avec titre du bien)
- `/app/bien/nouveau.tsx` → Création d'un bien
- `/app/contact/[id].tsx` → Fiche contact
- `/app/contact/nouveau.tsx` → Nouveau contact
- `/app/visite/[id].tsx` → Rapport de visite
- `/app/visite/nouvelle.tsx` → Nouvelle visite (prend un bien_id en param)
- `/app/calculateur/[bienId].tsx` → Calculateur financier
3. CHAQUE ÉCRAN doit avoir pour l'instant :
- Un titre visible
- Un texte "Module [Nom] — à venir"
- Le bouton de navigation retour (automatique avec Expo Router)
4. HEADER GLOBAL :
- Bouton profil en haut à droite sur les onglets
- Titre dynamique selon l'onglet actif
5. BOUTON FLOATING ACTION BUTTON (FAB) :
Sur l'onglet Prospection et Annuaire : bouton "+" en bas à droite pour ajouter un bien/contact.
Mets à jour AGENTS.md : Agent 2 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 3 — Module Prospection (Kanban)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Prospection complet : vue pipeline Kanban des biens.
1. ÉCRAN PROSPECTION `/app/(tabs)/prospection.tsx` :
Vue principale avec 2 modes switchables (boutons en haut) :
MODE KANBAN (défaut) :
- Colonnes horizontales scrollables (ScrollView horizontal)
- Chaque colonne = une étape du pipeline (depuis Supabase)
- Chaque carte de bien affiche : titre, ville, surface, prix achat, badge de priorité
- Nombre de biens par colonne dans le header de colonne
- Couleur de colonne selon la couleur définie dans etapes_pipeline
MODE LISTE :
- FlatList avec tri par date, ville, ou priorité
- Même info que les cartes Kanban + statut en badge
2. COMPOSANT CARTE BIEN `/components/biens/CarteBien.tsx` :
- Titre ou "Bien sans titre" si vide
- Ville + code postal
- Surface habitable (si renseignée)
- Prix d'achat formaté en €
- Indicateur priorité (rouge=haute, orange=normale, gris=basse)
- Badge source (off-market, agence, notaire...)
- onPress → navigation vers `/bien/[id]`
- onLongPress → modal rapide (changer étape, appeler contact, supprimer)
3. FORMULAIRE CRÉATION BIEN `/app/bien/nouveau.tsx` :
Formulaire en plusieurs étapes (step 1, step 2, step 3) :
Étape 1 — Localisation :
- Adresse (text input)
- Ville + code postal
- Type de bien (Select : appartement, maison, immeuble, terrain...)
Étape 2 — Caractéristiques :
- Surface habitable
- Nombre de pièces
- Prix d'achat estimé
- Source (Select)
Étape 3 — Résumé + validation
Bouton "Créer le bien" → POST Supabase → redirect vers la fiche bien
4. FILTRES ET RECHERCHE :
- Barre de recherche par ville ou titre
- Filtre rapide : Tous / Actifs / Abandonnés
5. HOOK `/hooks/usePipeline.ts` :
- fetchBiensParEtape() : retourne les biens groupés par étape
- moveToEtape(bienId, etapeId) : change l'étape d'un bien
- fetchEtapes() : récupère les étapes du pipeline
Mets à jour AGENTS.md : Agent 3 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 4 — Fiche Bien (détail complet)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée l'écran de fiche bien complet : le "dossier" central de l'app.
ÉCRAN `/app/bien/[id].tsx` :
Structure avec ScrollView et sections collapsibles :
1. EN-TÊTE :
- Photo principale (ou placeholder avec icône maison)
- Adresse complète
- Badge étape actuelle (avec bouton pour changer)
- Score d'opportunité visuel (étoiles ou barre)
- Boutons rapides : Appeler vendeur | Calculateur | Nouvelle visite
2. SECTION "INFOS" :
- Type de bien, surface, pièces, étages, année construction
- DPE : badge coloré (A=vert foncé → G=rouge)
- Source de la piste
3. SECTION "FINANCES" (résumé) :
- Prix d'achat, budget travaux estimé
- Marge cible (si analyse créée)
- Bouton → ouvre le calculateur
4. SECTION "VISITES" :
- Liste des visites avec date et avis
- Bouton "Nouvelle visite"
5. SECTION "CONTACTS" :
- Contacts liés (notaire, agent, artisans...)
- Bouton pour lier un contact existant
6. SECTION "DOCUMENTS" :
- Liste des documents (DPE, compromis, etc.)
- Bouton upload (expo-document-picker)
7. SECTION "NOTES" :
- Notes libres avec date
- Champ pour ajouter une note rapide
8. SECTION "TIMELINE" :
- Historique chronologique des actions (création, visite, offre, compromis...)
ÉDITION :
- Bouton "Modifier" en haut à droite → mode édition inline
- Chaque section modifiable directement
- Auto-save avec debounce (500ms)
HOOK `/hooks/useBienDetail.ts` :
- fetchBienComplet(id) : avec toutes les relations (visites, contacts, docs, notes)
- addNote(bienId, contenu)
- linkContact(bienId, contactId, role)
- uploadDocument(bienId, file, type)
Mets à jour AGENTS.md : Agent 4 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 5 — Calculateur financier
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le calculateur de rentabilité, le cœur financier de l'application.
ÉCRAN `/app/calculateur/[bienId].tsx` :
Ce calculateur utilise les formules exactes définies dans .cursorrules.
1. SECTION ACQUISITION :
- Prix d'achat (€) — input numérique
- Type de bien fiscal : Ancien (7,5%) / Neuf (2%) — toggle
- Frais de notaire : calculés automatiquement + possibilité de saisir manuellement
- Frais d'agence achat (€ ou %)
2. SECTION TRAVAUX :
- Budget travaux estimé (€)
- Réserve imprévus : slider 5-25% (défaut 10%)
- Budget total travaux = estimation + réserve
3. SECTION PORTAGE :
- Durée de portage prévue (slider 6-36 mois)
- Taux de crédit (%) — pré-rempli depuis le profil
- Taxe foncière annuelle (€)
- Charges mensuelles (€)
- Coût de portage total = calculé automatiquement
4. SECTION REVENTE :
- Prix de revente cible (€)
- Frais d'agence vente (% — défaut 5%)
- Taux d'imposition (% — défaut 25%)
5. RÉSULTATS EN TEMPS RÉEL (mis à jour à chaque keystroke) :
Affichés dans des cartes colorées :
- Prix de revient total (€) — gris
- Marge brute (€ et %) — vert si > 15%, orange si 8-15%, rouge si < 8%
- Marge nette après impôts (€ et %) — même code couleur
- ROI (%) — retour sur investissement
- Point mort : prix de revente minimum pour ne pas perdre
6. SCÉNARIOS :
Bouton "Voir les scénarios" → modal avec 3 colonnes :
- Pessimiste (-10% prix revente)
- Réaliste (prix saisi)
- Optimiste (+10% prix revente)
Chaque colonne affiche la marge nette correspondante.
7. SAUVEGARDE :
Bouton "Enregistrer l'analyse" → sauvegarde dans analyses_financieres
8. EXPORT :
Bouton "Partager" → génère un résumé texte formaté à partager par SMS/email
COMPOSANT RÉUTILISABLE `/components/biens/ResultatFinancier.tsx` :
Affiche les 4 KPIs financiers (utilisé aussi dans la fiche bien)
Mets à jour AGENTS.md : Agent 5 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 6 — Annuaire contacts
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Annuaire : carnet d'adresses professionnel du marchand de biens.
1. ÉCRAN ANNUAIRE `/app/(tabs)/annuaire.tsx` :
EN-TÊTE :
- Barre de recherche (prénom, nom, société, ville)
- Filtres par catégorie : tous | notaires | agents | artisans | banques | autres
- Sous-filtre artisans : par corps de métier (plomberie, élec, maçonnerie...)
LISTE :
- FlatList avec sections par catégorie (SectionList si filtre = "tous")
- Chaque item : avatar initiales coloré, nom, société, ville, note étoiles, bouton appel direct
- Swipe gauche pour supprimer, swipe droit pour "Favori"
- Section "Favoris" en haut de liste
2. FICHE CONTACT `/app/contact/[id].tsx` :
- Avatar grande taille avec initiales
- Badges : catégorie + spécialité
- Note (étoiles, modifiable par tap)
- Boutons actions : Appeler | SMS | Email | Copier
- Section "Biens associés" : liste des biens où ce contact intervient
- Section "Historique" : notes d'échanges avec date
- Champ "Notes" libre
- Infos artisan : taux horaire, remise habituelle
3. FORMULAIRE NOUVEAU CONTACT `/app/contact/nouveau.tsx` :
- Catégorie (obligatoire) — change les champs affichés
- Nom, prénom, société
- Téléphone (avec format FR automatique)
- Email
- Ville + zone d'intervention
- Spécialité (si artisan)
- Note initiale
4. FONCTIONNALITÉ APPEL :
Import `{ Linking } from 'react-native'`
`Linking.openURL('tel:' + telephone)` — fonctionne sur iOS et Android
5. HOOK `/hooks/useContacts.ts` (compléter celui de l'Agent 1) :
- searchContacts(query, categorie?)
- getFavoris()
- getBiensByContact(contactId)
- toggleFavori(contactId)
Mets à jour AGENTS.md : Agent 6 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 7 — Module Visites (avec IA Claude)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Visites avec génération de compte-rendu par IA Claude.
⚠️ IMPORTANT SÉCURITÉ : L'appel à l'API Anthropic doit se faire via une Supabase Edge Function,
jamais directement depuis le client mobile (la clé API ne doit pas être dans le code React Native).
1. EDGE FUNCTION SUPABASE `/supabase/functions/generate-rapport-visite/index.ts` :
```typescript
// Reçoit : { notes_brutes, checklist_reponses, bien_info }
// Appelle l'API Anthropic Claude
// Retourne : { rapport_structure }
const prompt = `Tu es assistant d'un marchand de biens immobiliers français.
À partir des notes de visite et de la check-list ci-dessous, génère un compte-rendu
de visite professionnel et structuré en français.
Informations du bien : ${JSON.stringify(bien_info)}
Notes prises pendant la visite : ${notes_brutes}
Résultats de la check-list : ${JSON.stringify(checklist_reponses)}
Génère un compte-rendu avec ces sections :
1. Résumé exécutif (3-4 phrases)
2. Points positifs (liste)
3. Points négatifs / travaux nécessaires (liste)
4. Estimation des travaux (si des éléments permettent de l'estimer)
5. Recommandation (Coup de cœur / Intéressant / À éviter + justification)
6. Prochaines étapes suggérées
Sois concis, professionnel, et orienté investissement.`;
```
2. ÉCRAN LISTE VISITES `/app/(tabs)/visites.tsx` :
- Visites à venir (avec date et bien concerné)
- Visites passées avec leur rapport
- Bouton "Planifier une visite"
3. ÉCRAN NOUVELLE VISITE `/app/visite/nouvelle.tsx` :
- Sélecteur de bien (liste ou scan QR code si bien a un QR)
- Date et heure de la visite
- Type : première visite / seconde visite / expert
- Redirect vers l'écran de visite en cours
4. ÉCRAN VISITE EN COURS `/app/visite/[id].tsx` :
TAB 1 — CHECK-LIST :
- Sections par catégorie (Structure, Toiture, Électricité, etc.)
- Chaque item avec 4 états : ✅ OK | ⚠️ Attention | ❌ Problème | — Non vérifié
- Champ note par item
- Progression visuelle (X/Y items vérifiés)
TAB 2 — NOTES LIBRES :
- Grande zone de texte pour notes rapides
- Bouton microphone : transcription vocale (expo-av)
- Bouton photo : ajouter une photo directement liée à la visite
TAB 3 — ESTIMATION :
- Budget travaux min/max estimé sur place
- Durée estimée
- Avis global (Coup de cœur / Intéressant / Neutre / À éviter)
- Score d'opportunité (slider 1-10)
BOUTON "GÉNÉRER LE RAPPORT IA" :
- Loading spinner avec message "Analyse en cours..."
- Appel à la Edge Function Supabase
- Affiche le rapport généré
- Bouton "Copier" et "Modifier" le rapport
- Sauvegarde automatique dans visites.rapport_genere
5. HOOK `/hooks/useVisite.ts` :
- startVisite(bienId, dateVisite)
- saveChecklist(visiteId, reponses)
- saveNotes(visiteId, notes)
- generateRapport(visiteId) → appel Edge Function
- fetchVisitesByBien(bienId)
Mets à jour AGENTS.md : Agent 7 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 8 — Agenda & tâches
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Agenda : gestion des tâches quotidiennes du marchand de biens.
1. ÉCRAN AGENDA `/app/(tabs)/agenda.tsx` :
VUE "AUJOURD'HUI" (défaut) :
- Tâches du jour groupées par bien
- Section "En retard" en rouge en haut
- Section "Aujourd'hui"
- Section "Cette semaine"
VUE CALENDRIER :
- react-native-calendars
- Points colorés sur les jours avec tâches
- Tap sur un jour → liste des tâches du jour
2. COMPOSANT TÂCHE `/components/agenda/CarteTache.tsx` :
- Checkbox (tap pour cocher = fait)
- Titre + description courte
- Badge bien associé (avec couleur de l'étape)
- Date + heure
- Badge priorité
- Icône type (téléphone, email, rendez-vous, admin...)
- Swipe droit : Snooze +1 jour
- Swipe gauche : Supprimer
3. CRÉATION TÂCHE :
Modal bottom sheet avec :
- Titre (obligatoire)
- Type de tâche (Select avec icônes)
- Bien associé (optionnel — searchable Select)
- Contact associé (optionnel)
- Date et heure d'échéance (DateTimePicker)
- Priorité (toggle 3 niveaux)
- Rappel (30min avant / 1h / 1 jour / pas de rappel)
4. TÂCHES AUTO-GÉNÉRÉES :
Créer une fonction `genererTachesDelaisLegaux(bienId)` qui génère automatiquement
les tâches importantes lors des changements d'étape :
- Compromis signé → "SRU 10 jours : fin le [date]"
- Compromis signé → "Déblocage financement : échéance [date+45j]"
- Acte signé → "Déclaration IS : rappel [date+3mois]"
5. NOTIFICATIONS :
Configurer expo-notifications pour :
- Rappel de tâche (heure configurée)
- Alerte délai légal (veille)
6. HOOK `/hooks/useTaches.ts` :
- fetchTachesJour(date)
- fetchTachesEnRetard()
- createTache(data)
- toggleTacheStatus(id)
- snooze(id, jours)
- genererTachesDelaisLegaux(bienId, etapeName)
Mets à jour AGENTS.md : Agent 8 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 9 — Dashboard
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le tableau de bord : vue d'ensemble de l'activité du marchand de biens.
ÉCRAN `/app/(tabs)/index.tsx` :
1. EN-TÊTE :
- "Bonjour [Prénom] 👋"
- Date du jour
- Météo rapide (optionnel, via Open-Meteo API gratuite)
2. ALERTES URGENTES (si existantes) :
Carte rouge en haut : tâches en retard + délais légaux proches (< 3 jours)
3. BARRE KPIs (scroll horizontal, 4 métriques) :
- Biens actifs : nombre total
- En compromis : nombre
- Marge prévue : somme des marges nettes des analyses
- Tâches du jour : nombre (avec badge rouge si en retard)
4. PIPELINE RÉSUMÉ :
Barre horizontale proportionnelle avec chaque étape et son nombre de biens
Tap sur une étape → filtre la liste en dessous
5. BIENS EN COURS :
FlatList horizontale des 5 derniers biens actifs
Chaque carte : titre, étape, date de la dernière action
Tap → fiche bien
6. TÂCHES DU JOUR :
3-5 tâches prioritaires
Bouton "Voir tout" → onglet Agenda
7. DERNIÈRES VISITES :
2-3 dernières visites avec score d'opportunité
Tap → rapport de visite
8. STATISTIQUES MENSUELLES (bottom) :
- Biens analysés ce mois
- Offres faites
- Taux de conversion piste → offre
- Biens vendus (si applicable)
HOOK `/hooks/useDashboard.ts` :
Utilise React Query pour fetcher en parallèle :
- getNombreBiensParEtape()
- getKPIsFinanciers()
- getTachesUrgentes()
- getDernieresVisites()
Toutes les données mises en cache 5 minutes, refresh au focus de l'écran.
Mets à jour AGENTS.md : Agent 9 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## PROMPT DE DEBUG UNIVERSEL
## (À utiliser quand quelque chose ne marche pas)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md pour le contexte.
J'ai une erreur dans le module [NOM DU MODULE] :
ERREUR : [coller le message d'erreur exact]
FICHIER CONCERNÉ : [nom du fichier]
CE QUI DEVRAIT SE PASSER : [description du comportement attendu]
CE QUI SE PASSE : [description du bug]
Diagnostique le problème et propose un fix. Tiens compte de la stack technique
définie dans .cursorrules (Expo + Supabase + TypeScript strict).
```
---
## ═══════════════════════════════════════════
## PROMPT D'AMÉLIORATION UI
## (Quand un écran fonctionne mais est laid)
## ═══════════════════════════════════════════
```
Lis .cursorrules.
L'écran [NOM] fonctionne mais l'interface n'est pas assez soignée pour une utilisation
professionnelle quotidienne sur mobile.
Améliore le design avec ces critères :
- Style sobre et professionnel (pas de couleurs vives inutiles)
- Lisible en extérieur (fort contraste)
- Actions principales facilement accessibles en une main (zone du pouce)
- Chargement : skeletons pendant le fetch (pas de spinner seul)
- États vides : message explicatif + bouton d'action (ex: "Aucune visite — Planifier une visite")
- Feedback tactile sur les boutons (Haptics.impactAsync)
- Gestion d'erreur visible (toast ou banner rouge en haut)
Couleurs de l'app :
- Primaire : #1D4ED8 (bleu professionnel)
- Succès : #16A34A
- Attention : #D97706
- Danger : #DC2626
- Fond : #F9FAFB
- Texte : #111827
```

163
README.md
View File

@ -1,163 +0,0 @@
# mb-app — Application Marchand de Biens
Application mobile et web pour la gestion quotidienne d'une activité de marchand de biens immobiliers.
**Stack :** React Native (Expo) + PocketBase (self-hosted sur NAS Synology) + IA Claude
---
## Démarrage rapide
### Prérequis
- Docker Desktop (Mac/Windows) ou Docker Engine (Linux/NAS)
- Node.js 18+
- Un compte DuckDNS (gratuit) pour l'accès distant
### 1. Cloner le projet
```bash
git clone https://github.com/VOUS/mb-app.git
cd mb-app
```
### 2. Setup initial
```bash
make setup
# → Crée les dossiers nécessaires
# → Copie .env.example en .env.local
```
Éditer `.env.local` :
```
EXPO_PUBLIC_PB_URL=http://localhost:8090
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
```
### 3. Lancer en développement
```bash
# Terminal 1 — Backend PocketBase
make dev
# → PocketBase sur http://localhost:8090
# → Admin sur http://localhost:8090/_/
# Terminal 2 — App Expo
cd app
npm install
npx expo start
```
**Première fois :** Aller sur http://localhost:8090/_/ → créer le compte admin
→ Settings → Import collections → coller le contenu de `pocketbase/pb_collections.json`
---
## Déploiement sur le NAS Synology
### 1. Cloner sur le NAS
```bash
# Se connecter en SSH au NAS
ssh admin@IP_DU_NAS
# Cloner le projet
git clone https://github.com/VOUS/mb-app.git /volume1/docker/mb-app
cd /volume1/docker/mb-app
```
### 2. Configurer l'environnement production
```bash
make setup-nas
# Puis éditer .env.production :
nano .env.production
```
```
EXPO_PUBLIC_PB_URL=https://mon-sous-domaine.duckdns.org
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
DUCKDNS_SUBDOMAINS=mon-sous-domaine
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
```
### 3. Ouvrir les ports sur votre box internet
- Port 80 → IP du NAS, port 80
- Port 443 → IP du NAS, port 443
### 4. Obtenir le certificat SSL
```bash
make ssl
```
### 5. Lancer la stack
```bash
make prod
```
### Mettre à jour après un git push
```bash
# Sur le NAS :
make deploy
# → git pull + restart PocketBase (les hooks sont rechargés)
```
---
## Workflow de développement
```
[Local] Coder + tester
git add . && git commit -m "feat: module visites"
git push origin main
[NAS] make deploy
```
Les **données** (`pb_data/`) restent sur chaque machine et ne sont jamais dans Git.
Le **code** (hooks, migrations, app) est versionné et déployé via Git.
---
## Structure du projet
```
mb-app/
├── app/ ← Code React Native (Expo Router)
├── pocketbase/
│ ├── pb_hooks/ ← Hooks JS côté serveur (IA, etc.)
│ ├── pb_migrations/ ← Migrations auto PocketBase
│ └── pb_collections.json ← Schéma des collections
├── docker/
│ ├── docker-compose.dev.yml
│ ├── docker-compose.prod.yml
│ └── nginx.prod.conf
├── .cursorrules ← Contexte pour Cursor AI
├── AGENTS.md ← Suivi des sessions de développement
└── Makefile ← Raccourcis commandes
```
---
## Commandes utiles
| Commande | Description |
|---|---|
| `make dev` | Lance PocketBase en local |
| `make dev-stop` | Arrête le dev |
| `make logs` | Logs en temps réel |
| `make prod` | Lance la stack NAS |
| `make deploy` | git pull + redémarre (sur NAS) |
| `make renew-ssl` | Renouvelle le certificat SSL |
---
## Backup des données
Les données PocketBase sont dans `pb_data/` (exclu du Git).
Configurer une tâche Synology Hyper Backup sur `/volume1/docker/mb-app/pb_data/`.

View File

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

View File

@ -1,48 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { colors } from '../../src/theme/colors';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: colors.bgCard },
headerTintColor: colors.text,
tabBarStyle: {
backgroundColor: colors.bgCard,
borderTopColor: colors.border,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dossiers',
tabBarIcon: ({ color, size }) => (
<Ionicons name="folder-open-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="investisseurs"
options={{
title: 'Investisseurs',
tabBarIcon: ({ color, size }) => (
<Ionicons name="people-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="reglages"
options={{
title: 'Réglages',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}

View File

@ -1,297 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Pressable,
SectionList,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import type { DealSourceRow, DossierRow } from '../../src/data/types';
import {
ensureNotificationPermission,
notifyGradeADealLocal,
useDealsSourcesGradeAAlerts,
} from '../../src/hooks/useDealsSourcesGradeAAlerts';
import { colors } from '../../src/theme/colors';
type SectionRow =
| { kind: 'deal'; deal: DealSourceRow }
| { kind: 'dossier'; dossier: DossierRow };
export default function DossiersListScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [scoutBusy, setScoutBusy] = useState(false);
const cloudNeedsAuth =
app.runtimeMode === 'cloud' && !app.user && app.supabase;
const needsSetup = app.runtimeMode === 'none';
const sortedDeals = useMemo(
() =>
[...app.dealSources].sort(
(a, b) => b.opportunity_score - a.opportunity_score,
),
[app.dealSources],
);
const sections = useMemo(() => {
const dealRows: SectionRow[] = sortedDeals.map((deal) => ({
kind: 'deal' as const,
deal,
}));
const dossierRows: SectionRow[] = app.dossiers.map((dossier) => ({
kind: 'dossier' as const,
dossier,
}));
return [
{ title: 'Flux opportunités (Scout)', data: dealRows },
{ title: 'Mes dossiers', data: dossierRows },
];
}, [sortedDeals, app.dossiers]);
useDealsSourcesGradeAAlerts(
app.supabase,
app.user?.id,
app.runtimeMode === 'cloud' && !!app.user && !!app.supabase,
);
useEffect(() => {
if (app.runtimeMode === 'cloud' && app.user) {
void ensureNotificationPermission();
}
}, [app.runtimeMode, app.user]);
return (
<View style={[styles.root, { paddingTop: 8 }]}>
{needsSetup ? (
<View style={styles.banner}>
<Text style={styles.bannerText}>
Choisissez le mode hors-ligne sur laccueil, ou configurez Supabase
dans Réglages.
</Text>
<PrimaryButton
title="Retour accueil"
onPress={() => router.replace('/')}
/>
</View>
) : null}
{cloudNeedsAuth ? (
<View style={styles.banner}>
<Text style={styles.bannerText}>
Connectez-vous pour charger vos dossiers Supabase.
</Text>
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
</View>
) : null}
<SectionList
sections={sections}
keyExtractor={(item, index) =>
item.kind === 'deal' ? `deal-${item.deal.id}` : `dossier-${item.dossier.id}-${index}`
}
stickySectionHeadersEnabled={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View style={{ marginBottom: 12 }}>
<PrimaryButton
title={scoutBusy ? 'Scout…' : 'Simuler ingest Scout (JSON)'}
loading={scoutBusy}
onPress={async () => {
if (!app.user) {
router.push('/auth/login');
return;
}
setScoutBusy(true);
const r = await app.runScoutSampleBatch();
setScoutBusy(false);
if ('error' in r) {
Alert.alert('Scout', r.error);
return;
}
if (app.runtimeMode === 'local' && r.gradeA > 0) {
notifyGradeADealLocal(
`${r.gradeA} opportunité(s) Grade A (Scout simulé)`,
);
}
Alert.alert(
'Scout',
`Insérés : ${r.inserted} — Grade A : ${r.gradeA}.`,
);
}}
/>
<Text style={styles.hint}>
Filtre : mots-clés succession / urgent / travaux important + prix/m²
sous moyenne simulée (3500 /m²). Cloud : RPC `scout_process_batch`
après migration SQL.
</Text>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<Text style={styles.sectionTitle}>
{title}
{title.startsWith('Flux') ? ` (${data.length})` : ` (${data.length})`}
</Text>
)}
renderItem={({ item }) =>
item.kind === 'deal' ? (
<DealSourceCard row={item.deal} />
) : (
<DossierRowCard row={item.dossier} />
)
}
ListEmptyComponent={null}
/>
<View style={[styles.fabWrap, { bottom: insets.bottom + 20 }]}>
<Pressable
accessibilityRole="button"
style={styles.fab}
onPress={async () => {
if (app.runtimeMode === 'none') {
router.replace('/');
return;
}
if (!app.user && app.runtimeMode === 'cloud') {
router.push('/auth/login');
return;
}
const id = await app.createDossier();
if (id) router.push(`/dossier/${id}`);
}}
>
<Ionicons name="add" size={32} color="#fff" />
</Pressable>
</View>
</View>
);
}
function DealSourceCard({ row }: { row: DealSourceRow }) {
const pm = Math.round(row.price_per_m2_eur);
const dotStyle =
row.grade === 'A' ? styles.badgeA : row.grade === 'B' ? styles.badgeB : styles.badgeC;
return (
<View style={styles.dealCard}>
<View style={styles.dealHead}>
<Text style={styles.badgeText}>Grade {row.grade}</Text>
<View style={[styles.badgeDot, dotStyle]} />
<Text style={styles.score}>{row.opportunity_score.toFixed(0)} pts</Text>
</View>
<Text style={styles.cardTitle}>{row.title}</Text>
<Text style={styles.cardSub}>
{row.price_eur != null
? `${row.price_eur.toLocaleString('fr-FR')} € · ${row.surface_m2} m² · ${pm} €/m²`
: `${row.surface_m2}`}
</Text>
{row.distress_keywords?.length ? (
<Text style={[styles.kw, { color: colors.flash }]}>
Mots-clés : {row.distress_keywords.join(', ')}
</Text>
) : null}
{row.source_name ? (
<Text style={styles.cardMeta}>Source : {row.source_name}</Text>
) : null}
</View>
);
}
function DossierRowCard({ row }: { row: DossierRow }) {
const city = [row.postal_code, row.city].filter(Boolean).join(' ');
return (
<Pressable
style={styles.card}
onPress={() => router.push(`/dossier/${row.id}`)}
>
<Text style={styles.cardTitle}>{row.title}</Text>
{city ? <Text style={styles.cardSub}>{city}</Text> : null}
<Text style={styles.cardMeta}>Statut : {row.status}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
banner: {
marginHorizontal: 16,
marginBottom: 12,
padding: 14,
borderRadius: 12,
backgroundColor: colors.bgCard,
borderWidth: 1,
borderColor: colors.border,
gap: 10,
},
bannerText: { color: colors.text, lineHeight: 20 },
hint: {
color: colors.textMuted,
fontSize: 12,
lineHeight: 17,
marginTop: 10,
},
sectionTitle: {
color: colors.text,
fontSize: 15,
fontWeight: '800',
marginTop: 8,
marginBottom: 8,
},
dealCard: {
backgroundColor: '#121a24',
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
dealHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, gap: 8 },
badgeDot: { width: 8, height: 8, borderRadius: 4 },
badgeA: { backgroundColor: '#3fb950' },
badgeB: { backgroundColor: '#d29922' },
badgeC: { backgroundColor: colors.textMuted },
badgeText: {
color: colors.text,
fontWeight: '900',
fontSize: 13,
marginRight: 4,
},
score: { color: colors.textMuted, fontSize: 12, marginLeft: 'auto' },
kw: { color: colors.flash ?? '#7ee787', marginTop: 6, fontSize: 12 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
cardTitle: { color: colors.text, fontSize: 17, fontWeight: '700' },
cardSub: { color: colors.textMuted, marginTop: 4 },
cardMeta: { color: colors.textMuted, marginTop: 8, fontSize: 12 },
fabWrap: {
position: 'absolute',
right: 20,
},
fab: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
},
});

View File

@ -1,234 +0,0 @@
import { useState } from 'react';
import {
Alert,
FlatList,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
import type { InvestisseurRow } from '../../src/data/types';
import { router } from 'expo-router';
function parseNum(s: string): number | null {
const v = Number(s.replace(',', '.').replace(/\s/g, ''));
return Number.isFinite(v) ? v : null;
}
export default function InvestisseursScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<InvestisseurRow | null>(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [minMargin, setMinMargin] = useState('12');
const [maxTicket, setMaxTicket] = useState('');
const [zones, setZones] = useState('');
const cloudNeedsAuth = app.runtimeMode === 'cloud' && !app.user;
const openNew = () => {
setEditing(null);
setName('');
setEmail('');
setPhone('');
setMinMargin('12');
setMaxTicket('');
setZones('');
setOpen(true);
};
const openEdit = (row: InvestisseurRow) => {
setEditing(row);
setName(row.display_name);
setEmail(row.email ?? '');
setPhone(row.phone ?? '');
setMinMargin(String(row.min_margin_pct));
setMaxTicket(row.max_ticket_eur != null ? String(row.max_ticket_eur) : '');
setZones((row.zones ?? []).join(', '));
setOpen(true);
};
const save = async () => {
if (!app.user) {
router.push('/auth/login');
return;
}
const uid = app.user.id;
const mm = parseNum(minMargin) ?? 12;
const mt = maxTicket.trim() ? parseNum(maxTicket) : null;
const z = zones
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await app.upsertInvestisseur({
id: editing?.id,
user_id: uid,
display_name: name.trim() || 'Investisseur',
email: email.trim() || null,
phone: phone.trim() || null,
min_margin_pct: mm,
max_ticket_eur: mt,
zones: z.length ? z : null,
strategies: null,
notes: null,
});
setOpen(false);
};
if (cloudNeedsAuth) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.muted}>Connectez-vous pour gérer vos investisseurs.</Text>
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
</View>
);
}
return (
<View style={styles.root}>
<FlatList
data={app.investisseurs}
keyExtractor={(i) => i.id}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 80,
}}
ListEmptyComponent={
<Text style={styles.muted}>
Ajoutez des profils pour le module « Investisseur flash » (matching
marge / ticket / zones).
</Text>
}
renderItem={({ item }) => (
<Pressable style={styles.card} onPress={() => openEdit(item)}>
<Text style={styles.name}>{item.display_name}</Text>
<Text style={styles.meta}>
Marge mini {item.min_margin_pct}% ticket max{' '}
{item.max_ticket_eur != null
? `${item.max_ticket_eur.toLocaleString('fr-FR')}`
: '—'}
</Text>
{item.zones?.length ? (
<Text style={styles.meta}>Zones : {item.zones.join(', ')}</Text>
) : null}
</Pressable>
)}
/>
<View style={[styles.fabRow, { bottom: insets.bottom + 16 }]}>
<PrimaryButton title="Nouvel investisseur" onPress={openNew} />
</View>
<Modal visible={open} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { paddingBottom: insets.bottom + 16 }]}>
<Text style={styles.modalTitle}>
{editing ? 'Modifier investisseur' : 'Nouvel investisseur'}
</Text>
<LabeledField label="Nom" value={name} onChangeText={setName} />
<LabeledField
label="E-mail"
autoCapitalize="none"
value={email}
onChangeText={setEmail}
/>
<LabeledField label="Téléphone" value={phone} onChangeText={setPhone} />
<LabeledField
label="Marge nette minimum (%)"
keyboardType="decimal-pad"
value={minMargin}
onChangeText={setMinMargin}
/>
<LabeledField
label="Ticket max (€) — optionnel"
keyboardType="number-pad"
value={maxTicket}
onChangeText={setMaxTicket}
/>
<LabeledField
label="Zones (ville ou CP, séparés par des virgules)"
value={zones}
onChangeText={setZones}
/>
<PrimaryButton title="Enregistrer" onPress={() => void save()} />
{editing ? (
<PrimaryButton
title="Supprimer"
variant="danger"
containerStyle={{ marginTop: 10 }}
onPress={() => {
Alert.alert(
'Supprimer',
'Confirmer la suppression ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => {
void app.deleteInvestisseur(editing.id).then(() =>
setOpen(false),
);
},
},
],
);
}}
/>
) : null}
<PrimaryButton
title="Fermer"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={() => setOpen(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
muted: { color: colors.textMuted, textAlign: 'center', lineHeight: 20 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
name: { color: colors.text, fontSize: 17, fontWeight: '700' },
meta: { color: colors.textMuted, marginTop: 6, fontSize: 13 },
fabRow: { position: 'absolute', left: 16, right: 16 },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.55)',
justifyContent: 'flex-end',
},
modalCard: {
backgroundColor: colors.bgCard,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
padding: 20,
borderWidth: 1,
borderColor: colors.border,
},
modalTitle: {
color: colors.text,
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
},
});

View File

@ -1,112 +0,0 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function ReglagesScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [url, setUrl] = useState('');
const [key, setKey] = useState('');
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
return (
<ScrollView
contentContainerStyle={{
padding: 20,
paddingTop: 12,
paddingBottom: insets.bottom + 32,
backgroundColor: colors.bg,
}}
>
<Text style={styles.h2}>Mode actuel</Text>
<Text style={styles.p}>
{app.runtimeMode === 'local' && 'Hors-ligne — données stockées sur lappareil.'}
{app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'}
{app.runtimeMode === 'none' && 'Non initialisé.'}
</Text>
{app.user ? (
<Text style={styles.p}>
Compte : {app.user.email ?? app.user.id}
</Text>
) : null}
<Text style={[styles.h2, { marginTop: 24 }]}>Projet Supabase</Text>
<Text style={styles.p}>
URL et clé « anon » (Settings API). Exécutez aussi la migration SQL du
dépôt sur votre projet.
</Text>
<LabeledField
label="URL du projet"
autoCapitalize="none"
value={url}
onChangeText={setUrl}
placeholder="https://xxxx.supabase.co"
/>
<LabeledField
label="Clé anon (public)"
autoCapitalize="none"
value={key}
onChangeText={setKey}
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
/>
{msg ? <Text style={styles.msg}>{msg}</Text> : null}
<PrimaryButton
title="Enregistrer et passer en mode cloud"
loading={loading}
onPress={async () => {
setMsg(null);
if (!url.trim() || !key.trim()) {
setMsg('Renseignez URL et clé.');
return;
}
setLoading(true);
try {
await app.saveCloudConfig({
supabaseUrl: url.trim(),
supabaseAnonKey: key.trim(),
});
setMsg('Configuration enregistrée. Connectez-vous ou créez un compte.');
router.push('/auth/login');
} catch {
setMsg('Erreur lors de lenregistrement.');
} finally {
setLoading(false);
}
}}
/>
<PrimaryButton
title="Activer le mode hors-ligne"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={async () => {
await app.enterLocalMode();
setMsg('Mode hors-ligne activé.');
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Déconnexion / quitter la session"
variant="ghost"
containerStyle={{ marginTop: 24 }}
onPress={async () => {
await app.signOut();
router.replace('/');
}}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
h2: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 8 },
p: { color: colors.textMuted, lineHeight: 20, marginBottom: 8 },
msg: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});

View File

@ -1,29 +0,0 @@
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 { AppProvider } from '../src/context/AppContext';
import { colors } from '../src/theme/colors';
export default function RootLayout() {
return (
<SafeAreaProvider>
<AppProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgCard },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ title: 'Connexion' }} />
<Stack.Screen name="auth/register" options={{ title: 'Inscription' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="dossier/[id]" options={{ title: 'Dossier' }} />
</Stack>
</AppProvider>
</SafeAreaProvider>
);
}

View File

@ -1,95 +0,0 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function LoginScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
if (app.runtimeMode !== 'cloud' || !app.supabase) {
return (
<View style={[styles.box, { paddingTop: insets.top }]}>
<Text style={styles.err}>
Configurez dabord Supabase dans Réglages, puis revenez ici.
</Text>
<PrimaryButton
title="Ouvrir Réglages"
onPress={() => router.replace('/(tabs)/reglages')}
/>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
padding: 20,
paddingTop: insets.top + 12,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField
label="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<LabeledField
label="Mot de passe"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{err ? <Text style={styles.err}>{err}</Text> : null}
<PrimaryButton
title="Connexion"
loading={loading}
onPress={async () => {
setErr(null);
setLoading(true);
const r = await app.signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setErr(r.error);
return;
}
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Créer un compte"
variant="ghost"
onPress={() => router.push('/auth/register')}
containerStyle={{ marginTop: 12 }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
err: { color: colors.danger, marginBottom: 12 },
});

View File

@ -1,103 +0,0 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function RegisterScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<string | null>(null);
if (app.runtimeMode !== 'cloud' || !app.supabase) {
return (
<View style={[styles.box, { paddingTop: insets.top }]}>
<Text style={styles.err}>
Configurez dabord Supabase dans Réglages.
</Text>
<PrimaryButton
title="Ouvrir Réglages"
onPress={() => router.replace('/(tabs)/reglages')}
/>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
padding: 20,
paddingTop: insets.top + 12,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField label="Nom affiché" value={name} onChangeText={setName} />
<LabeledField
label="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<LabeledField
label="Mot de passe"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{err ? <Text style={styles.err}>{err}</Text> : null}
{info ? <Text style={styles.info}>{info}</Text> : null}
<PrimaryButton
title="Sinscrire"
loading={loading}
onPress={async () => {
setErr(null);
setInfo(null);
setLoading(true);
const r = await app.signUp(email.trim(), password, name.trim());
setLoading(false);
if (r.error) {
setErr(r.error);
return;
}
setInfo(
'Si la confirmation e-mail est activée sur votre projet, vérifiez votre boîte avant de vous connecter.',
);
}}
/>
<PrimaryButton
title="Jai déjà un compte"
variant="ghost"
onPress={() => router.back()}
containerStyle={{ marginTop: 12 }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
err: { color: colors.danger, marginBottom: 12 },
info: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});

View File

@ -1,567 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useLayoutEffect, useMemo, useState, useEffect } from 'react';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp, useVisitFindings } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
import type {
DossierRow,
DossierVisitFindingRow,
VisitFindingDefinitionRow,
} from '../../src/data/types';
import { useDossierJuge } from '../../src/hooks/useDossierJuge';
import { matchInvestisseurs } from '../../src/services/matchInvestors';
import { shareTeaserPdf } from '../../src/services/teaserPdf';
import { MIN_NET_MARGIN_PCT } from '../../src/core/juge';
type TabKey = 'dash' | 'money' | 'visit' | 'flash';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'dash', label: 'Feu' },
{ key: 'money', label: 'Finances' },
{ key: 'visit', label: 'Visite' },
{ key: 'flash', label: 'Flash' },
];
export default function DossierDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const dossierId = typeof id === 'string' ? id : id?.[0];
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const app = useApp();
const [tab, setTab] = useState<TabKey>('dash');
const dossier = useMemo(
() => app.dossiers.find((d) => d.id === dossierId),
[app.dossiers, dossierId],
);
const findings = useVisitFindings(dossierId);
const juge = useDossierJuge(dossier, findings, app.definitions);
useLayoutEffect(() => {
navigation.setOptions({
title: dossier?.title ?? 'Dossier',
headerRight: () => (
<Pressable
accessibilityRole="button"
hitSlop={12}
onPress={() => {
if (!dossierId) return;
Alert.alert(
'Supprimer le dossier',
'Cette action est irréversible.',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => {
void app.deleteDossier(dossierId).then(() => router.back());
},
},
],
);
}}
>
<Ionicons name="trash-outline" size={22} color={colors.danger} />
</Pressable>
),
});
}, [navigation, dossier?.title, dossierId, app.deleteDossier]);
if (!dossierId || !dossier || !juge) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.muted}>Dossier introuvable.</Text>
<PrimaryButton title="Retour" onPress={() => router.back()} />
</View>
);
}
const matches = matchInvestisseurs(dossier, juge.result, app.investisseurs);
const dashBg =
juge.result.trafficLight === 'red'
? '#2d1418'
: juge.result.trafficLight === 'orange'
? '#2a2310'
: juge.result.trafficLight === 'green_flash_dvf'
? '#102a18'
: '#10221c';
return (
<View style={styles.root}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabBar}
contentContainerStyle={styles.tabBarInner}
>
{TABS.map((t) => (
<Pressable
key={t.key}
onPress={() => setTab(t.key)}
style={[styles.tabChip, tab === t.key && styles.tabChipOn]}
>
<Text style={[styles.tabText, tab === t.key && styles.tabTextOn]}>
{t.label}
</Text>
</Pressable>
))}
</ScrollView>
{tab === 'dash' ? (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<View style={[styles.hero, { backgroundColor: dashBg }]}>
<Text style={styles.heroLabel}>Score deal</Text>
<Text style={styles.heroScore}>{juge.result.scoreDeal}</Text>
<Text style={styles.heroSub}>
Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil
achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %)
</Text>
<Text style={styles.heroSub}>
Feu : {juge.result.trafficLight} DVF flash :{' '}
{juge.result.dvfUnderMarketFlash ? 'oui' : 'non'}
</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Synthèse</Text>
<Row label="Investi (est.)" value={`${Math.round(juge.result.totalInvested).toLocaleString('fr-FR')}`} />
<Row label="Produit net revente" value={`${Math.round(juge.result.netResaleProceeds).toLocaleString('fr-FR')}`} />
<Row label="TVA sur marge (est.)" value={`${Math.round(juge.result.vatOnMargin).toLocaleString('fr-FR')}`} />
<Row label="Marge nette" value={`${Math.round(juge.result.netMarginAfterVat).toLocaleString('fr-FR')}`} />
<Row
label="Break-even revente"
value={
Number.isFinite(juge.result.breakEvenResalePrice)
? `${Math.round(juge.result.breakEvenResalePrice).toLocaleString('fr-FR')}`
: '—'
}
/>
</View>
<PrimaryButton
title="Marquer « sous promesse »"
variant="ghost"
onPress={() => void app.setDossierStatus(dossier.id, 'under_promise')}
containerStyle={{ marginTop: 8 }}
/>
</ScrollView>
) : null}
{tab === 'money' ? (
<FinancesEditor dossier={dossier} onSave={(patch) => void app.updateDossier(dossier.id, patch)} />
) : null}
{tab === 'visit' ? (
<VisiteTab
definitions={app.definitions}
findings={findings}
onToggle={(code, checked) =>
void app.toggleFinding(dossier.id, code, checked)
}
checklistEUR={juge.checklistWorks}
maxPurchase={juge.maxPurchase}
/>
) : null}
{tab === 'flash' ? (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
{dossier.status !== 'under_promise' ? (
<Text style={styles.muted}>
Verrouillez le dossier (« sous promesse ») depuis longlet Feu pour
activer le teaser investisseur.
</Text>
) : (
<>
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
{matches.length === 0 ? (
<Text style={styles.muted}>Aucun match ajustez critères ou dossier.</Text>
) : (
matches.map((m) => (
<View key={m.id} style={styles.matchRow}>
<Text style={styles.matchName}>{m.display_name}</Text>
{m.email ? (
<Text style={styles.muted}>{m.email}</Text>
) : null}
</View>
))
)}
<PrimaryButton
title="Générer & partager le teaser PDF"
containerStyle={{ marginTop: 16 }}
onPress={() =>
void shareTeaserPdf(
dossier,
juge.result,
matches.map((m) => m.display_name),
)
}
/>
</>
)}
</ScrollView>
) : null}
</View>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
function FinancesEditor({
dossier,
onSave,
}: {
dossier: DossierRow;
onSave: (patch: Partial<DossierRow>) => void;
}) {
const insets = useSafeAreaInsets();
const [title, setTitle] = useState(dossier.title);
const [address, setAddress] = useState(dossier.address_line ?? '');
const [city, setCity] = useState(dossier.city ?? '');
const [postal, setPostal] = useState(dossier.postal_code ?? '');
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
const [deficitFoncier, setDeficitFoncier] = useState(
dossier.deficit_foncier_candidate,
);
useEffect(() => {
setTitle(dossier.title);
setAddress(dossier.address_line ?? '');
setCity(dossier.city ?? '');
setPostal(dossier.postal_code ?? '');
setSurface(String(dossier.surface_m2 ?? ''));
setPurchase(String(dossier.purchase_price_target ?? ''));
setResale(String(dossier.resale_price_estimate ?? ''));
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
setWorks(String(dossier.works_estimate_total ?? ''));
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
setMiscS(String(dossier.misc_sale_cost ?? ''));
setCarryM(String(dossier.carrying_months ?? 6));
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
setDpe(dossier.dpe_class ?? '');
setPluZone(dossier.plu_zone_code ?? '');
setPluNotes(dossier.plu_notes ?? '');
setParcelDiv(dossier.parcel_subdivision_candidate);
setDeficitFoncier(dossier.deficit_foncier_candidate);
}, [
dossier.id,
dossier.updated_at,
dossier.title,
dossier.address_line,
dossier.city,
dossier.postal_code,
dossier.surface_m2,
dossier.purchase_price_target,
dossier.resale_price_estimate,
dossier.dvf_reference_price_m2,
dossier.works_estimate_total,
dossier.misc_acquisition_cost,
dossier.misc_sale_cost,
dossier.carrying_months,
dossier.carrying_annual_rate,
dossier.dpe_class,
dossier.plu_zone_code,
dossier.plu_notes,
dossier.parcel_subdivision_candidate,
dossier.deficit_foncier_candidate,
]);
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
<LabeledField label="Ville" value={city} onChangeText={setCity} />
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
<LabeledField
label="Surface (m²)"
keyboardType="decimal-pad"
value={surface}
onChangeText={setSurface}
/>
<LabeledField
label="Prix d'achat cible (€)"
keyboardType="number-pad"
value={purchase}
onChangeText={setPurchase}
/>
<LabeledField
label="Prix de revente estimé (€)"
keyboardType="number-pad"
value={resale}
onChangeText={setResale}
/>
<LabeledField
label="DVF réf. (€/m²)"
keyboardType="decimal-pad"
value={dvf}
onChangeText={setDvf}
/>
<LabeledField
label="Travaux estimés hors checklist (€)"
keyboardType="number-pad"
value={works}
onChangeText={setWorks}
/>
<LabeledField
label="Frais d'achat divers (€)"
keyboardType="number-pad"
value={miscA}
onChangeText={setMiscA}
/>
<LabeledField
label="Frais de vente divers (€)"
keyboardType="number-pad"
value={miscS}
onChangeText={setMiscS}
/>
<LabeledField
label="Portage (mois)"
keyboardType="number-pad"
value={carryM}
onChangeText={setCarryM}
/>
<LabeledField
label="Taux portage annuel (ex: 0.055)"
keyboardType="decimal-pad"
value={carryR}
onChangeText={setCarryR}
/>
<LabeledField
label="DPE (AG)"
autoCapitalize="characters"
maxLength={1}
value={dpe}
onChangeText={(t) => setDpe(t.toUpperCase())}
/>
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
<LabeledField
label="Zone PLU (libellé ou code)"
value={pluZone}
onChangeText={setPluZone}
/>
<LabeledField
label="Notes urbanisme (servitude, COS…)"
value={pluNotes}
onChangeText={setPluNotes}
multiline
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
</View>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
</View>
<PrimaryButton
title="Enregistrer les finances"
onPress={() => {
onSave({
title: title.trim(),
address_line: address.trim() || null,
city: city.trim() || null,
postal_code: postal.trim() || null,
surface_m2: parseNum(surface) || null,
purchase_price_target: parseNum(purchase) || null,
resale_price_estimate: parseNum(resale) || null,
dvf_reference_price_m2: parseNum(dvf) || null,
works_estimate_total: parseNum(works) || null,
misc_acquisition_cost: parseNum(miscA) || null,
misc_sale_cost: parseNum(miscS) || null,
carrying_months: Math.round(parseNum(carryM) || 6),
carrying_annual_rate: parseNum(carryR) || 0.05,
dpe_class: dpe || null,
plu_zone_code: pluZone.trim() || null,
plu_notes: pluNotes.trim() || null,
parcel_subdivision_candidate: parcelDiv,
deficit_foncier_candidate: deficitFoncier,
});
}}
/>
</ScrollView>
);
}
function VisiteTab({
definitions,
findings,
onToggle,
checklistEUR,
maxPurchase,
}: {
definitions: VisitFindingDefinitionRow[];
findings: DossierVisitFindingRow[];
onToggle: (code: string, checked: boolean) => void;
checklistEUR: number;
maxPurchase: number;
}) {
const insets = useSafeAreaInsets();
const rows = definitions.map((def) => {
const f = findings.find((x) => x.finding_code === def.code);
return { def, f };
});
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<View style={styles.card}>
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
<Text style={styles.muted}>
Cochez les points noirs : lapp ajoute les travaux associés et recalcule
le prix dachat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
% de marge nette.
</Text>
<Text style={styles.highlight}>
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')}
</Text>
<Text style={styles.highlight}>
Prix dachat max (cible marge) :{' '}
{maxPurchase.toLocaleString('fr-FR')}
</Text>
</View>
{rows.map(({ def, f }) => {
const checked = f?.checked ?? false;
return (
<View key={def.code} style={styles.visitRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.visitLabel}>{def.label}</Text>
<Text style={styles.muted}>
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
si coché
</Text>
</View>
<Switch
value={checked}
onValueChange={(v) => onToggle(def.code, v)}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
muted: { color: colors.textMuted, lineHeight: 20 },
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
tabChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 999,
backgroundColor: colors.bgCard,
marginRight: 8,
borderWidth: 1,
borderColor: colors.border,
},
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextOn: { color: colors.text },
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
heroSub: { color: colors.textMuted, marginTop: 4 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
gap: 12,
},
rowLabel: { color: colors.textMuted, flex: 1 },
rowValue: { color: colors.text, fontWeight: '600' },
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
visitRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
visitLabel: { color: colors.text, fontWeight: '600' },
matchRow: {
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
sectionLabel: {
color: colors.textMuted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.06,
marginTop: 8,
marginBottom: 6,
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
paddingVertical: 4,
},
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
});

View File

@ -1,112 +0,0 @@
import { router } from 'expo-router';
import { useEffect } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PrimaryButton } from '../src/components/PrimaryButton';
import { useApp } from '../src/context/AppContext';
import { colors } from '../src/theme/colors';
export default function WelcomeScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
useEffect(() => {
if (!app.ready) return;
if (app.user) {
router.replace('/(tabs)');
}
}, [app.ready, app.user]);
if (!app.ready) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color={colors.accent} />
<Text style={styles.muted}>Chargement</Text>
</View>
);
}
if (app.user) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return (
<ScrollView
contentContainerStyle={[
styles.scroll,
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24 },
]}
>
<Text style={styles.brand}>MDB-Turbo</Text>
<Text style={styles.tagline}>
Prospection marchand de biens : marge, visite, investisseurs sur le
terrain.
</Text>
<PrimaryButton
title="Continuer hors-ligne (données sur lappareil)"
onPress={() => {
void app.enterLocalMode().then(() => router.replace('/(tabs)'));
}}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Se connecter (Supabase)"
variant="ghost"
onPress={() => router.push('/auth/login')}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Configurer Supabase"
variant="ghost"
onPress={() => router.push('/(tabs)/reglages')}
containerStyle={styles.btn}
/>
<Text style={styles.hint}>
Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs
appareils, renseignez votre projet Supabase dans Réglages puis
connectez-vous.
</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
backgroundColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
muted: { color: colors.textMuted },
scroll: { paddingHorizontal: 22, backgroundColor: colors.bg },
brand: {
fontSize: 34,
fontWeight: '800',
color: colors.text,
marginBottom: 8,
},
tagline: {
fontSize: 16,
color: colors.textMuted,
lineHeight: 24,
marginBottom: 28,
},
btn: { marginBottom: 12, width: '100%' },
hint: {
marginTop: 20,
fontSize: 13,
color: colors.textMuted,
lineHeight: 20,
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@ -1,32 +0,0 @@
version: '3.8'
# ============================================================
# DÉVELOPPEMENT LOCAL — docker compose -f docker/docker-compose.dev.yml up
# PocketBase accessible sur http://localhost:8090
# Admin PocketBase : http://localhost:8090/_/
# ============================================================
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: mb-pocketbase-dev
restart: unless-stopped
ports:
- "8090:8090" # Accès direct sans Nginx en dev
volumes:
# Données locales (dans .gitignore)
- ../pocketbase/pb_data:/pb/pb_data
# Hooks JS versionnés dans Git ✅
- ../pocketbase/pb_hooks:/pb/pb_hooks
# Migrations versionnées dans Git ✅
- ../pocketbase/pb_migrations:/pb/pb_migrations
env_file:
- ../.env.local # Clé Anthropic en local
environment:
- TZ=Europe/Paris
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 15s
timeout: 5s
retries: 3

View File

@ -1,59 +0,0 @@
version: '3.8'
# ============================================================
# PRODUCTION NAS — docker compose -f docker/docker-compose.prod.yml up -d
# Accessible sur https://VOTRE_SOUS_DOMAINE.duckdns.org
# ============================================================
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: mb-pocketbase
restart: unless-stopped
# Pas de port exposé directement : Nginx fait le proxy
expose:
- "8090"
volumes:
# Données persistantes NAS (dans .gitignore)
- /volume1/docker/mb-app/pb_data:/pb/pb_data
# Hooks et migrations versionnés dans Git ✅
- ../pocketbase/pb_hooks:/pb/pb_hooks
- ../pocketbase/pb_migrations:/pb/pb_migrations
env_file:
- ../.env.production
environment:
- TZ=Europe/Paris
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:alpine
container_name: mb-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
- /volume1/docker/mb-app/ssl:/etc/nginx/ssl:ro
depends_on:
pocketbase:
condition: service_healthy
duckdns:
image: lscr.io/linuxserver/duckdns:latest
container_name: mb-duckdns
restart: unless-stopped
environment:
- TZ=Europe/Paris
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
- TOKEN=${DUCKDNS_TOKEN}
- LOG_FILE=true
env_file:
- ../.env.production
volumes:
- /volume1/docker/mb-app/duckdns:/config

View File

@ -0,0 +1,18 @@
version: '3.8'
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: mdb-pocketbase-dev
restart: unless-stopped
command: --dir=/pb_data
ports:
- "8090:8090"
volumes:
- ../pocketbase/pb_data:/pb_data
- ../pocketbase/pb_hooks:/pb_hooks
- ../pocketbase/pb_migrations:/pb_migrations
env_file:
- ../.env.local
environment:
- TZ=Europe/Paris

View File

@ -0,0 +1,43 @@
version: '3.8'
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: mdb-pocketbase
restart: unless-stopped
expose:
- "8090"
volumes:
- /volume1/docker/mdb/pocketbase/pb_data:/pb/pb_data
- ../pocketbase/pb_hooks:/pb/pb_hooks
- ../pocketbase/pb_migrations:/pb/pb_migrations
env_file:
- ../.env.production
environment:
- TZ=Europe/Paris
nginx:
image: nginx:alpine
container_name: mdb-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
- /volume1/docker/mdb/ssl:/etc/nginx/ssl:ro
depends_on:
- pocketbase
duckdns:
image: lscr.io/linuxserver/duckdns:latest
container_name: mdb-duckdns
restart: unless-stopped
environment:
- TZ=Europe/Paris
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
- TOKEN=${DUCKDNS_TOKEN}
env_file:
- ../.env.production
volumes:
- /volume1/docker/mdb/duckdns:/config

View File

@ -1,20 +0,0 @@
# ============================================================
# .env.example — Copier en .env.local (dev) ou .env.production
# NE PAS mettre de vraies valeurs dans ce fichier
# ============================================================
# ── URL PocketBase ───────────────────────────────────────────
# Dev local :
EXPO_PUBLIC_PB_URL=http://localhost:8090
# Production NAS :
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
# ── PocketBase (côté serveur Docker) ─────────────────────────
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE_ICI
# ── DuckDNS (production uniquement) ──────────────────────────
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
# ── Expo (optionnel, pour EAS Build) ─────────────────────────
# EXPO_TOKEN=

41
mb-app/.gitignore vendored
View File

@ -1,41 +0,0 @@
# 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

View File

@ -1 +0,0 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

View File

@ -1,7 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@ -1,45 +0,0 @@
{
"expo": {
"name": "mb-app",
"slug": "mb-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mbapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"color": "#1D4ED8"
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@ -1,59 +0,0 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router';
import { Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
headerRight: () => (
<Link href="/modal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -1,38 +0,0 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@ -1,40 +0,0 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

View File

@ -1,88 +0,0 @@
import 'react-native-gesture-handler';
import '../global.css';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { QueryClientProvider } from '@tanstack/react-query';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { AuthProvider } from '@/context/AuthContext';
import { queryClient } from '@/lib/query-client';
export { ErrorBoundary } from 'expo-router';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
void SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
const navigationTheme = colorScheme === 'dark' ? DarkTheme : DefaultTheme;
const paperTheme =
colorScheme === 'dark'
? {
...MD3DarkTheme,
colors: {
...MD3DarkTheme.colors,
primary: '#3B82F6',
primaryContainer: '#1E3A8A',
},
}
: {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
primary: '#1D4ED8',
primaryContainer: '#DBEAFE',
},
};
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={paperTheme}>
<AuthProvider>
<ThemeProvider value={navigationTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="auth" />
<Stack.Screen
name="modal"
options={{ presentation: 'modal', headerShown: true, title: 'Info' }}
/>
</Stack>
</ThemeProvider>
</AuthProvider>
</PaperProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

View File

@ -1,12 +0,0 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'fade',
}}
/>
);
}

View File

@ -1,96 +0,0 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
import { useUiStore } from '@/stores/ui-store';
export default function LoginScreen() {
const { signIn } = useAuth();
const lastEmail = useUiStore((s) => s.lastAuthEmail);
const [email, setEmail] = useState(lastEmail);
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow justify-center px-6 py-10"
>
<View className="mb-10">
<Text className="text-3xl font-bold text-slate-900">Connexion</Text>
<Text className="mt-2 text-base text-slate-600">
Espace marchand de biens accès sécurisé
</Text>
</View>
{error ? (
<Text className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
className="mb-6 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setLoading(true);
const r = await signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
useUiStore.getState().setLastAuthEmail(email.trim());
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
Se connecter
</Button>
<View className="mt-8 items-center">
<Text className="text-slate-600">Pas encore de compte ?</Text>
<Link href="/auth/register" asChild>
<Pressable className="mt-2 py-2">
<Text className="font-semibold text-primary">Créer un compte</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -1,127 +0,0 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
export default function RegisterScreen() {
const { signUp } = useAuth();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow px-6 py-8"
>
<View className="mb-8">
<Text className="text-3xl font-bold text-slate-900">Inscription</Text>
<Text className="mt-2 text-base text-slate-600">
Créez votre accès professionnel
</Text>
</View>
{error ? (
<Text className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
{info ? (
<Text className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
{info}
</Text>
) : null}
<TextInput
label="Prénom"
mode="outlined"
value={firstName}
onChangeText={setFirstName}
autoComplete="given-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Nom"
mode="outlined"
value={lastName}
onChangeText={setLastName}
autoComplete="family-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
className="mb-6"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setInfo(null);
setLoading(true);
const r = await signUp({
email: email.trim(),
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
setInfo(
'Si la confirmation e-mail est activée sur votre projet Supabase, vérifiez votre boîte avant de vous connecter.',
);
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
S&apos;inscrire
</Button>
<View className="mt-8 items-center">
<Link href="/auth/login" asChild>
<Pressable className="py-2">
<Text className="font-semibold text-primary">Retour à la connexion</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -1,21 +0,0 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { useAuth } from '@/context/AuthContext';
export default function Index() {
const { initialized, session } = useAuth();
if (!initialized) {
return (
<View className="flex-1 items-center justify-center bg-slate-50">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
);
}
if (session?.user) {
return <Redirect href="/(tabs)" />;
}
return <Redirect href="/auth/login" />;
}

View File

@ -1,35 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@ -1,77 +0,0 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@ -1,24 +0,0 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@ -1,5 +0,0 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View File

@ -1,45 +0,0 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -1,10 +0,0 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -1,4 +0,0 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@ -1,12 +0,0 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View File

@ -1,8 +0,0 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@ -1,19 +0,0 @@
const tintColorLight = '#2f95dc';
const tintColorDark = '#fff';
export default {
light: {
text: '#000',
background: '#fff',
tint: tintColorLight,
tabIconDefault: '#ccc',
tabIconSelected: tintColorLight,
},
dark: {
text: '#fff',
background: '#000',
tint: tintColorDark,
tabIconDefault: '#ccc',
tabIconSelected: tintColorDark,
},
};

View File

@ -1,4 +0,0 @@
/** Constantes métier marchand de biens (France) — à enrichir avec le métier. */
export const FRAIS_NOTAIRE_ANCIEN_RATIO = 0.075;
export const FRAIS_NOTAIRE_NEUF_RATIO = 0.02;

View File

@ -1,121 +0,0 @@
import type { Session, User } from '@supabase/supabase-js';
import { useRouter, useSegments } from 'expo-router';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { supabase } from '@/services/supabase';
type AuthContextValue = {
initialized: boolean;
session: Session | null;
user: User | null;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signUp: (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [initialized, setInitialized] = useState(false);
const [session, setSession] = useState<Session | null>(null);
const segments = useSegments();
const router = useRouter();
useEffect(() => {
let cancelled = false;
void supabase.auth.getSession().then(({ data }) => {
if (!cancelled) {
setSession(data.session ?? null);
setInitialized(true);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
});
return () => {
cancelled = true;
subscription.unsubscribe();
};
}, []);
useEffect(() => {
if (!initialized) return;
const root = segments[0];
if (!session?.user && root === '(tabs)') {
router.replace('/auth/login');
return;
}
if (session?.user && root === 'auth') {
router.replace('/(tabs)');
}
}, [initialized, session?.user, segments, router]);
const signIn = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
return {};
}, []);
const signUp = useCallback(
async (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => {
const { error } = await supabase.auth.signUp({
email: params.email,
password: params.password,
options: {
data: {
first_name: params.firstName,
last_name: params.lastName,
full_name: `${params.firstName} ${params.lastName}`.trim(),
},
},
});
if (error) return { error: error.message };
return {};
},
[],
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
}, []);
const value = useMemo<AuthContextValue>(
() => ({
initialized,
session,
user: session?.user ?? null,
signIn,
signUp,
signOut,
}),
[initialized, session, signIn, signUp, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return ctx;
}

6
mb-app/env.d.ts vendored
View File

@ -1,6 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_SUPABASE_URL?: string;
EXPO_PUBLIC_SUPABASE_ANON_KEY?: string;
}
}

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,10 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});

View File

@ -1,6 +0,0 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

View File

@ -1 +0,0 @@
/// <reference types="nativewind/types" />

11091
mb-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,52 +0,0 @@
{
"name": "mb-app",
"main": "expo-router/entry",
"version": "1.0.0",
"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",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.105.1",
"@tanstack/react-query": "^5.100.9",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.2.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-paper": "^5.15.1",
"react-native-reanimated": "~4.1.1",
"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",
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "~19.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -1,22 +0,0 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import Constants from 'expo-constants';
const supabaseUrl =
process.env.EXPO_PUBLIC_SUPABASE_URL ??
Constants.expoConfig?.extra?.supabaseUrl ??
'';
const supabaseAnonKey =
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ??
Constants.expoConfig?.extra?.supabaseAnonKey ??
'';
export const supabase: SupabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

View File

@ -1,11 +0,0 @@
import { create } from 'zustand';
type UiState = {
lastAuthEmail: string;
setLastAuthEmail: (email: string) => void;
};
export const useUiStore = create<UiState>((set) => ({
lastAuthEmail: '',
setLastAuthEmail: (email) => set({ lastAuthEmail: email }),
}));

View File

@ -1,20 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./context/**/*.{js,jsx,ts,tsx}',
'./services/**/*.{js,jsx,ts,tsx}',
'./stores/**/*.{js,jsx,ts,tsx}',
'./lib/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
primary: '#1D4ED8',
},
},
},
plugins: [],
};

View File

@ -1,19 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts",
"env.d.ts"
]
}

View File

@ -1,11 +0,0 @@
/**
* Types générés Supabase — remplacer par `supabase gen types typescript`
* une fois le schéma défini (cf. .cursorrules).
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[];

View File

@ -1,41 +0,0 @@
# 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

View File

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

View File

@ -1,32 +0,0 @@
{
"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

@ -1,26 +0,0 @@
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

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

View File

@ -1,146 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
{
"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

@ -1,63 +0,0 @@
/**
* 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

@ -1,28 +0,0 @@
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

@ -1,150 +0,0 @@
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

@ -1,148 +0,0 @@
/**
* 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

@ -1,15 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,17 +0,0 @@
/**
* 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

@ -1,16 +0,0 @@
/**
* 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

@ -1,32 +0,0 @@
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

@ -1,10 +0,0 @@
/** 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

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

View File

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

View File

@ -1,100 +0,0 @@
-- 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

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

Some files were not shown because too many files have changed in this diff Show More