diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..039bd20 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,97 @@ +# Contexte projet — Application Marchand de Biens + +## Qui utilise cette app +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.). + +## Stack technique +- **Frontend** : React Native avec Expo (SDK 51+) +- **Navigation** : Expo Router (file-based routing) +- **Base de données** : Supabase (PostgreSQL) +- **Auth** : Supabase Auth (email/password) +- **Stockage fichiers** : Supabase Storage (photos, PDFs) +- **IA** : API Anthropic Claude (claude-sonnet-4-20250514) +- **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 +- TypeScript strict partout, jamais de `any` +- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants +- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`) +- 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) +- **Bien** : propriété immobilière prospectée ou acquise +- **Piste** : bien en cours d'analyse, pas encore d'offre +- **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 +1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente) +2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo) +3. **Fiches biens** : dossier complet par bien (photos, docs, historique) +4. **Calculateur** : analyse de rentabilité financière +5. **Visites** : compte-rendus de visites avec check-list +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 +``` +/app → écrans (Expo Router) + /(tabs) → navigation principale + /prospection + /annuaire + /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 +``` +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= +ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client +``` + +## Règles de sécurité importantes +- La clé API Anthropic ne doit JAMAIS être dans le code client +- Créer une Supabase Edge Function pour les appels IA +- Row Level Security (RLS) activé sur toutes les tables Supabase +- Les photos et docs sont dans des buckets Supabase privés + +## Calculs financiers — formules exactes +``` +frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf) +prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage +marge_brute = prix_revente_cible - prix_revient +frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle +taux_marge_brute = marge_brute / prix_revente_cible * 100 +``` diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..14bab1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# AGENTS — Suivi des sessions Cursor + +> Ce fichier est lu au début de chaque session Cursor pour reprendre le contexte. +> Mets à jour la section "État" après chaque session. + +--- + +## État global du projet + +- [ ] Agent 0 — Setup initial (Expo + Supabase) +- [ ] Agent 1 — Schéma base de données + types TypeScript +- [ ] Agent 2 — Navigation + écrans vides +- [ ] 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)_ + +- diff --git a/App.tsx b/App.tsx deleted file mode 100644 index 0329d0c..0000000 --- a/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; - -export default function App() { - return ( - - Open up App.tsx to start working on your app! - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/PROMPTS_CURSOR.md b/PROMPTS_CURSOR.md new file mode 100644 index 0000000..55a8a4d --- /dev/null +++ b/PROMPTS_CURSOR.md @@ -0,0 +1,690 @@ +# 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 + export type BienUpdate = Partial + ``` + +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 +``` diff --git a/app.json b/app.json index 7373a34..d8a461e 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,8 @@ { "expo": { - "name": "mdb", - "slug": "mdb", + "name": "MDB-Turbo", + "slug": "mdb-turbo", + "scheme": "mdb-turbo", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -25,6 +26,17 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": [ + "expo-router", + "expo-font", + [ + "expo-notifications", + { + "icon": "./assets/icon.png", + "color": "#3d8bfd" + } + ] + ] } } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..bfbd683 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,48 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Tabs } from 'expo-router'; +import { colors } from '../../src/theme/colors'; + +export default function TabsLayout() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..0b51338 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,297 @@ +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 ( + + {needsSetup ? ( + + + Choisissez le mode hors-ligne sur l’accueil, ou configurez Supabase + dans Réglages. + + router.replace('/')} + /> + + ) : null} + {cloudNeedsAuth ? ( + + + Connectez-vous pour charger vos dossiers Supabase. + + router.push('/auth/login')} /> + + ) : null} + + + item.kind === 'deal' ? `deal-${item.deal.id}` : `dossier-${item.dossier.id}-${index}` + } + stickySectionHeadersEnabled={false} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingBottom: insets.bottom + 100, + }} + ListHeaderComponent={ + + { + 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}.`, + ); + }} + /> + + 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. + + + } + renderSectionHeader={({ section: { title, data } }) => ( + + {title} + {title.startsWith('Flux') ? ` (${data.length})` : ` (${data.length})`} + + )} + renderItem={({ item }) => + item.kind === 'deal' ? ( + + ) : ( + + ) + } + ListEmptyComponent={null} + /> + + + { + 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}`); + }} + > + + + + + ); +} + +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 ( + + + Grade {row.grade} + + {row.opportunity_score.toFixed(0)} pts + + {row.title} + + {row.price_eur != null + ? `${row.price_eur.toLocaleString('fr-FR')} € · ${row.surface_m2} m² · ${pm} €/m²` + : `${row.surface_m2} m²`} + + {row.distress_keywords?.length ? ( + + Mots-clés : {row.distress_keywords.join(', ')} + + ) : null} + {row.source_name ? ( + Source : {row.source_name} + ) : null} + + ); +} + +function DossierRowCard({ row }: { row: DossierRow }) { + const city = [row.postal_code, row.city].filter(Boolean).join(' '); + return ( + router.push(`/dossier/${row.id}`)} + > + {row.title} + {city ? {city} : null} + Statut : {row.status} + + ); +} + +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 }, + }, +}); diff --git a/app/(tabs)/investisseurs.tsx b/app/(tabs)/investisseurs.tsx new file mode 100644 index 0000000..e75891d --- /dev/null +++ b/app/(tabs)/investisseurs.tsx @@ -0,0 +1,234 @@ +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(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 ( + + Connectez-vous pour gérer vos investisseurs. + router.push('/auth/login')} /> + + ); + } + + return ( + + i.id} + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 80, + }} + ListEmptyComponent={ + + Ajoutez des profils pour le module « Investisseur flash » (matching + marge / ticket / zones). + + } + renderItem={({ item }) => ( + openEdit(item)}> + {item.display_name} + + Marge mini {item.min_margin_pct}% — ticket max{' '} + {item.max_ticket_eur != null + ? `${item.max_ticket_eur.toLocaleString('fr-FR')} €` + : '—'} + + {item.zones?.length ? ( + Zones : {item.zones.join(', ')} + ) : null} + + )} + /> + + + + + + + + + {editing ? 'Modifier investisseur' : 'Nouvel investisseur'} + + + + + + + + void save()} /> + {editing ? ( + { + Alert.alert( + 'Supprimer', + 'Confirmer la suppression ?', + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Supprimer', + style: 'destructive', + onPress: () => { + void app.deleteInvestisseur(editing.id).then(() => + setOpen(false), + ); + }, + }, + ], + ); + }} + /> + ) : null} + setOpen(false)} + /> + + + + + ); +} + +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, + }, +}); diff --git a/app/(tabs)/reglages.tsx b/app/(tabs)/reglages.tsx new file mode 100644 index 0000000..d51bb1a --- /dev/null +++ b/app/(tabs)/reglages.tsx @@ -0,0 +1,112 @@ +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(null); + const [loading, setLoading] = useState(false); + + return ( + + Mode actuel + + {app.runtimeMode === 'local' && 'Hors-ligne — données stockées sur l’appareil.'} + {app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'} + {app.runtimeMode === 'none' && 'Non initialisé.'} + + {app.user ? ( + + Compte : {app.user.email ?? app.user.id} + + ) : null} + + Projet Supabase + + URL et clé « anon » (Settings → API). Exécutez aussi la migration SQL du + dépôt sur votre projet. + + + + {msg ? {msg} : null} + { + 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 l’enregistrement.'); + } finally { + setLoading(false); + } + }} + /> + + { + await app.enterLocalMode(); + setMsg('Mode hors-ligne activé.'); + router.replace('/(tabs)'); + }} + /> + + { + await app.signOut(); + router.replace('/'); + }} + /> + + ); +} + +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 }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..9bd8d91 --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,29 @@ +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 ( + + + + + + + + + + + + + ); +} diff --git a/app/auth/login.tsx b/app/auth/login.tsx new file mode 100644 index 0000000..34e92f9 --- /dev/null +++ b/app/auth/login.tsx @@ -0,0 +1,95 @@ +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(null); + const [loading, setLoading] = useState(false); + + if (app.runtimeMode !== 'cloud' || !app.supabase) { + return ( + + + Configurez d’abord Supabase dans Réglages, puis revenez ici. + + router.replace('/(tabs)/reglages')} + /> + + ); + } + + return ( + + + + + {err ? {err} : null} + { + setErr(null); + setLoading(true); + const r = await app.signIn(email.trim(), password); + setLoading(false); + if (r.error) { + setErr(r.error); + return; + } + router.replace('/(tabs)'); + }} + /> + router.push('/auth/register')} + containerStyle={{ marginTop: 12 }} + /> + + + ); +} + +const styles = StyleSheet.create({ + box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 }, + err: { color: colors.danger, marginBottom: 12 }, +}); diff --git a/app/auth/register.tsx b/app/auth/register.tsx new file mode 100644 index 0000000..fd74ab3 --- /dev/null +++ b/app/auth/register.tsx @@ -0,0 +1,103 @@ +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(null); + const [loading, setLoading] = useState(false); + const [info, setInfo] = useState(null); + + if (app.runtimeMode !== 'cloud' || !app.supabase) { + return ( + + + Configurez d’abord Supabase dans Réglages. + + router.replace('/(tabs)/reglages')} + /> + + ); + } + + return ( + + + + + + {err ? {err} : null} + {info ? {info} : null} + { + 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.', + ); + }} + /> + router.back()} + containerStyle={{ marginTop: 12 }} + /> + + + ); +} + +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 }, +}); diff --git a/app/dossier/[id].tsx b/app/dossier/[id].tsx new file mode 100644 index 0000000..7202379 --- /dev/null +++ b/app/dossier/[id].tsx @@ -0,0 +1,567 @@ +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('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: () => ( + { + 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()); + }, + }, + ], + ); + }} + > + + + ), + }); + }, [navigation, dossier?.title, dossierId, app.deleteDossier]); + + if (!dossierId || !dossier || !juge) { + return ( + + Dossier introuvable. + router.back()} /> + + ); + } + + 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 ( + + + {TABS.map((t) => ( + setTab(t.key)} + style={[styles.tabChip, tab === t.key && styles.tabChipOn]} + > + + {t.label} + + + ))} + + + {tab === 'dash' ? ( + + + Score deal + {juge.result.scoreDeal} + + Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil + achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %) + + + Feu : {juge.result.trafficLight} — DVF flash :{' '} + {juge.result.dvfUnderMarketFlash ? 'oui' : 'non'} + + + + Synthèse + + + + + + + void app.setDossierStatus(dossier.id, 'under_promise')} + containerStyle={{ marginTop: 8 }} + /> + + ) : null} + + {tab === 'money' ? ( + void app.updateDossier(dossier.id, patch)} /> + ) : null} + + {tab === 'visit' ? ( + + void app.toggleFinding(dossier.id, code, checked) + } + checklistEUR={juge.checklistWorks} + maxPurchase={juge.maxPurchase} + /> + ) : null} + + {tab === 'flash' ? ( + + {dossier.status !== 'under_promise' ? ( + + Verrouillez le dossier (« sous promesse ») depuis l’onglet Feu pour + activer le teaser investisseur. + + ) : ( + <> + Investisseurs (top 5 match) + {matches.length === 0 ? ( + Aucun match — ajustez critères ou dossier. + ) : ( + matches.map((m) => ( + + {m.display_name} + {m.email ? ( + {m.email} + ) : null} + + )) + )} + + void shareTeaserPdf( + dossier, + juge.result, + matches.map((m) => m.display_name), + ) + } + /> + + )} + + ) : null} + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function FinancesEditor({ + dossier, + onSave, +}: { + dossier: DossierRow; + onSave: (patch: Partial) => 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 ( + + + + + + + + + + + + + + + setDpe(t.toUpperCase())} + /> + Urbanisme & stratégie + + + + Piste division parcellaire + + + + Piste déficit foncier (passoire) + + + { + 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, + }); + }} + /> + + ); +} + +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 ( + + + Anti-erreur visite + + Cochez les points noirs : l’app ajoute les travaux associés et recalcule + le prix d’achat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '} + % de marge nette. + + + Travaux checklist : {checklistEUR.toLocaleString('fr-FR')} € + + + Prix d’achat max (cible marge) :{' '} + {maxPurchase.toLocaleString('fr-FR')} € + + + {rows.map(({ def, f }) => { + const checked = f?.checked ?? false; + return ( + + + {def.label} + + +{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '} + € si coché + + + onToggle(def.code, v)} + trackColor={{ true: colors.accent, false: colors.border }} + /> + + ); + })} + + ); +} + +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 }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..2056d77 --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,112 @@ +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 ( + + + Chargement… + + ); + } + + if (app.user) { + return ( + + + + ); + } + + return ( + + MDB-Turbo + + Prospection marchand de biens : marge, visite, investisseurs — sur le + terrain. + + { + void app.enterLocalMode().then(() => router.replace('/(tabs)')); + }} + containerStyle={styles.btn} + /> + router.push('/auth/login')} + containerStyle={styles.btn} + /> + router.push('/(tabs)/reglages')} + containerStyle={styles.btn} + /> + + Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs + appareils, renseignez votre projet Supabase dans Réglages puis + connectez-vous. + + + ); +} + +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, + }, +}); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..e1cbf6f --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['expo-router/babel'], + }; +}; diff --git a/index.ts b/index.ts deleted file mode 100644 index 1d6e981..0000000 --- a/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerRootComponent } from 'expo'; - -import App from './App'; - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); diff --git a/mb-app/.gitignore b/mb-app/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/mb-app/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/mb-app/.vscode/extensions.json b/mb-app/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/mb-app/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/mb-app/.vscode/settings.json b/mb-app/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/mb-app/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/mb-app/app.json b/mb-app/app.json new file mode 100644 index 0000000..6b2d7cf --- /dev/null +++ b/mb-app/app.json @@ -0,0 +1,45 @@ +{ + "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 + } + } +} diff --git a/mb-app/app/(tabs)/_layout.tsx b/mb-app/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..30914fb --- /dev/null +++ b/mb-app/app/(tabs)/_layout.tsx @@ -0,0 +1,59 @@ +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['name']; + color: string; +}) { + return ; +} + +export default function TabLayout() { + const colorScheme = useColorScheme(); + + return ( + + , + headerRight: () => ( + + + {({ pressed }) => ( + + )} + + + ), + }} + /> + , + }} + /> + + ); +} diff --git a/mb-app/app/(tabs)/index.tsx b/mb-app/app/(tabs)/index.tsx new file mode 100644 index 0000000..6cbee6d --- /dev/null +++ b/mb-app/app/(tabs)/index.tsx @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; + +import EditScreenInfo from '@/components/EditScreenInfo'; +import { Text, View } from '@/components/Themed'; + +export default function TabOneScreen() { + return ( + + Tab One + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 20, + fontWeight: 'bold', + }, + separator: { + marginVertical: 30, + height: 1, + width: '80%', + }, +}); diff --git a/mb-app/app/(tabs)/two.tsx b/mb-app/app/(tabs)/two.tsx new file mode 100644 index 0000000..f2ea47e --- /dev/null +++ b/mb-app/app/(tabs)/two.tsx @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; + +import EditScreenInfo from '@/components/EditScreenInfo'; +import { Text, View } from '@/components/Themed'; + +export default function TabTwoScreen() { + return ( + + Tab Two + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 20, + fontWeight: 'bold', + }, + separator: { + marginVertical: 30, + height: 1, + width: '80%', + }, +}); diff --git a/mb-app/app/+html.tsx b/mb-app/app/+html.tsx new file mode 100644 index 0000000..cb31090 --- /dev/null +++ b/mb-app/app/+html.tsx @@ -0,0 +1,38 @@ +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 ( + + + + + + + {/* + 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. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} + +

Offre d'achat — ${escape(params.propertyTitle)}

+

${escape(params.address)}

+
+

Montant de l'offre : ${params.maxBuyPriceEur.toLocaleString('fr-FR')} €

+

(${params.sellerName ? `Destinataire : ${escape(params.sellerName)}` : 'À compléter'})

+
+

MDB-PREDATOR — modèle indicatif, valider avec votre notaire / juriste.

+ `; + const { uri } = await Print.printToFileAsync({ html }); + if (Platform.OS === 'web') return; + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(uri, { mimeType: 'application/pdf', dialogTitle: 'Offre d’achat' }); + } +} + +function escape(s: string): string { + return s.replace(/&/g, '&').replace(/ { + return null; +} diff --git a/mdb-predator/src/services/pappers.ts b/mdb-predator/src/services/pappers.ts new file mode 100644 index 0000000..9f78ba0 --- /dev/null +++ b/mdb-predator/src/services/pappers.ts @@ -0,0 +1,5 @@ +/** Pappers — surveillance SCI / procédures (clé API côté serveur uniquement). */ + +export async function searchCompanySignalsStub(_siren: string): Promise { + return []; +} diff --git a/mdb-predator/src/theme/colors.ts b/mdb-predator/src/theme/colors.ts new file mode 100644 index 0000000..a6d2375 --- /dev/null +++ b/mdb-predator/src/theme/colors.ts @@ -0,0 +1,10 @@ +export const colors = { + bg: '#070b10', + card: '#101820', + border: '#1f2a36', + text: '#f2f6fb', + muted: '#8b9bb0', + accent: '#ff4d6d', + danger: '#ff3355', + ok: '#3ecf8e', +}; diff --git a/mdb-predator/supabase/migrations/20260429190000_mdb_predator_core.sql b/mdb-predator/supabase/migrations/20260429190000_mdb_predator_core.sql new file mode 100644 index 0000000..baefe7e --- /dev/null +++ b/mdb-predator/supabase/migrations/20260429190000_mdb_predator_core.sql @@ -0,0 +1,100 @@ +-- MDB-PREDATOR — cœur données (Supabase) + +create type public.property_status as enum ( + 'sourcing', + 'visited', + 'under_offer', + 'sold' +); + +create table if not exists public.properties ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + title text not null default 'Property', + address_line text, + city text, + postal_code text, + insee_code text, + latitude double precision, + longitude double precision, + surface_m2 numeric(12, 2), + status public.property_status not null default 'sourcing', + asking_price_eur numeric(14, 2), + resale_estimate_eur numeric(14, 2), + dvf_street_median_m2 numeric(14, 2), + works_estimate_eur numeric(14, 2) not null default 0, + distress_score smallint, + scout_payload jsonb, + engineer_payload jsonb, + financial_payload jsonb, + deal_maker_payload jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists properties_user_idx on public.properties (user_id); +create index if not exists properties_status_idx on public.properties (status); + +create table if not exists public.renovation_templates ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + label text not null, + tier text not null check (tier in ('light', 'medium', 'heavy')), + cost_per_m2_eur numeric(12, 2) not null, + default_misc_eur numeric(12, 2) not null default 0, + sort_order int not null default 0 +); + +insert into public.renovation_templates (slug, label, tier, cost_per_m2_eur, default_misc_eur, sort_order) +values + ('cosmetic', 'Rafraîchissement / léger', 'light', 450, 5000, 10), + ('standard', 'Rénovation standard', 'medium', 950, 12000, 20), + ('structural', 'Lourd / structure + toiture', 'heavy', 1800, 35000, 30) +on conflict (slug) do nothing; + +create table if not exists public.buyers_portfolio ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + display_name text not null, + budget_min_eur numeric(14, 2), + budget_max_eur numeric(14, 2), + sectors jsonb, + min_yield_pct numeric(6, 3), + min_net_margin_pct numeric(5, 2) default 12, + notes text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists buyers_user_idx on public.buyers_portfolio (user_id); + +create or replace function public.predator_set_updated_at() +returns trigger language plpgsql as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists tr_properties_updated on public.properties; +create trigger tr_properties_updated +before update on public.properties +for each row execute procedure public.predator_set_updated_at(); + +drop trigger if exists tr_buyers_updated on public.buyers_portfolio; +create trigger tr_buyers_updated +before update on public.buyers_portfolio +for each row execute procedure public.predator_set_updated_at(); + +alter table public.properties enable row level security; +alter table public.buyers_portfolio enable row level security; +alter table public.renovation_templates enable row level security; + +create policy properties_owner on public.properties + for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create policy buyers_owner on public.buyers_portfolio + for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create policy renovation_read on public.renovation_templates + for select to authenticated using (true); diff --git a/mdb-predator/tsconfig.json b/mdb-predator/tsconfig.json new file mode 100644 index 0000000..b9567f6 --- /dev/null +++ b/mdb-predator/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/package-lock.json b/package-lock.json index f58bf0f..cdb0e18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,34 @@ { - "name": "mdb", + "name": "mdb-turbo", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mdb", + "name": "mdb-turbo", "version": "1.0.0", "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", + "@supabase/supabase-js": "^2.105.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-notifications": "~0.32.17", + "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-native": "0.81.5" + "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", @@ -1519,6 +1536,18 @@ "node": ">=6.9.0" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2233,6 +2262,17 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/ws-tunnel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", @@ -2355,6 +2395,12 @@ "node": ">=8" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2704,6 +2750,205 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -2964,6 +3209,141 @@ "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", "license": "MIT" }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.15.11", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.11.tgz", + "integrity": "sha512-+WtNbd6fJgbViDNjmBUUP7eTgGH+zBtrl3jHuNnfUfXTs9YGuI5q3SiHIc9a5gY3voBOxbOXEiHJyW4xea7nAw==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.15", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz", + "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/core/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-navigation/core/node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.15.tgz", + "integrity": "sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz", + "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.2", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.14.12", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.12.tgz", + "integrity": "sha512-dUfpkrVeVKKV8iqXsmoUp3Rv0iH3YaB3eZwScru/FlcqAp/r3/qA6zEXkGX9hZK+/ziWAPFrf1frBSNbgOYSFQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.15", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/native/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -2988,6 +3368,113 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.1.tgz", + "integrity": "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.1.tgz", + "integrity": "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.1.tgz", + "integrity": "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.1.tgz", + "integrity": "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.1", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.1.tgz", + "integrity": "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz", + "integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.105.1", + "@supabase/functions-js": "2.105.1", + "@supabase/postgrest-js": "2.105.1", + "@supabase/realtime-js": "2.105.1", + "@supabase/storage-js": "2.105.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3038,6 +3525,12 @@ "@types/node": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3075,7 +3568,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3087,6 +3580,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3274,18 +3776,58 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3530,6 +4072,49 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, + "node_modules/babel-preset-expo": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", + "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -3546,6 +4131,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3751,6 +4342,53 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3892,6 +4530,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3915,6 +4559,19 @@ "node": ">=0.8" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3930,6 +4587,34 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -4048,6 +4733,15 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4062,11 +4756,20 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -4086,6 +4789,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4116,6 +4828,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -4125,6 +4854,23 @@ "node": ">=8" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4153,6 +4899,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4180,6 +4932,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4225,6 +4991,15 @@ "stackframe": "^1.3.4" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -4234,6 +5009,18 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4341,6 +5128,57 @@ } } }, + "node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "14.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", + "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-linking": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.13", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -4440,6 +5278,324 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "0.32.17", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", + "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-print": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-15.0.8.tgz", + "integrity": "sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-router": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", + "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "^6.1.2", + "@expo/schema-utils": "^0.1.8", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/native": "^7.1.8", + "@react-navigation/native-stack": "^7.3.16", + "client-only": "^0.0.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-server": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.8", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.1.6", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "use-latest-callback": "^0.2.1", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/metro-runtime": "^6.1.2", + "@react-navigation/drawer": "^7.5.0", + "@testing-library/react-native": ">= 12.0.0", + "expo": "*", + "expo-constants": "^18.0.13", + "expo-linking": "^8.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "*", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "license": "MIT", + "dependencies": { + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-server": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", @@ -4449,6 +5605,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", @@ -4621,17 +5786,6 @@ } } }, - "node_modules/expo/node_modules/@expo/vector-icons": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", - "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", - "license": "MIT", - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4647,49 +5801,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", - "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.81.5", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - } - } - }, "node_modules/expo/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4769,20 +5880,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "19.0.22", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", @@ -4793,20 +5890,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-font": { - "version": "14.0.11", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", - "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", - "license": "MIT", - "dependencies": { - "fontfaceobserver": "^2.1.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -4907,6 +5990,12 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4922,6 +6011,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4934,6 +6053,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -4992,6 +6120,21 @@ "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "license": "BSD-2-Clause" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/freeport-async": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -5039,6 +6182,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5057,6 +6209,39 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -5066,6 +6251,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -5092,6 +6290,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5107,6 +6317,45 @@ "node": ">=4" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -5134,6 +6383,21 @@ "hermes-estree": "0.32.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -5194,6 +6458,21 @@ "node": ">= 14" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5270,6 +6549,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -5279,6 +6567,40 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -5318,6 +6640,41 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5327,6 +6684,48 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -6219,12 +7618,33 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6746,6 +8166,26 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -6818,6 +8258,51 @@ "node": ">=0.10.0" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7103,6 +8588,15 @@ "node": ">=4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -7131,6 +8625,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -7226,6 +8726,24 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -7278,6 +8796,36 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7341,6 +8889,21 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", @@ -7351,6 +8914,75 @@ "react-native": "*" } }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", + "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "react-native-is-edge-to-edge": "^1.2.1", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-url-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz", + "integrity": "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==", + "license": "MIT", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", @@ -7450,6 +9082,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -7692,6 +9393,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -7821,12 +9539,56 @@ "node": ">= 0.8" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7877,6 +9639,15 @@ "plist": "^3.0.5" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7938,6 +9709,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -8001,6 +9781,15 @@ "node": ">= 0.10.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8042,6 +9831,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -8358,12 +10153,24 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8386,7 +10193,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8396,6 +10203,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -8490,6 +10323,80 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8526,6 +10433,196 @@ "node": ">= 0.8" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -8541,6 +10638,12 @@ "makeerror": "1.0.12" } }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -8565,6 +10668,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -8579,6 +10692,12 @@ "node": ">=10" } }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8594,6 +10713,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wonka": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", diff --git a/package.json b/package.json index db76f1a..2d64a8f 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,38 @@ { - "name": "mdb", + "name": "mdb-turbo", "version": "1.0.0", - "main": "index.ts", + "main": "expo-router/entry", "scripts": { "start": "expo start", + "start:tunnel": "expo start --tunnel --port 8082", + "start:lan": "expo start --lan --port 8082", + "start:web": "expo start --web --port 8082", "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.105.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-notifications": "~0.32.17", + "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-native": "0.81.5" + "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", diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..2131a15 --- /dev/null +++ b/schema.sql @@ -0,0 +1,489 @@ +-- ============================================================ +-- SCHÉMA SUPABASE — Application Marchand de Biens +-- À exécuter dans Supabase > SQL Editor +-- ============================================================ + +-- Extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "unaccent"; + +-- ============================================================ +-- TABLES PRINCIPALES +-- ============================================================ + +-- Profil utilisateur (complète auth.users de Supabase) +CREATE TABLE public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + nom TEXT, + prenom TEXT, + telephone TEXT, + raison_sociale TEXT, -- Nom de la société (SCI, SARL, etc.) + siret TEXT, + taux_credit_defaut DECIMAL(5,3) DEFAULT 3.5, -- Taux crédit par défaut en % + taux_impot_defaut DECIMAL(5,3) DEFAULT 25.0, -- Taux IS par défaut en % + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- MODULE PROSPECTION / BIENS +-- ============================================================ + +-- Étapes du pipeline (customisables) +CREATE TABLE public.etapes_pipeline ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + ordre INTEGER NOT NULL, + couleur TEXT DEFAULT '#6B7280', + is_terminal BOOLEAN DEFAULT FALSE, -- Étape finale (Vendu, Abandonné) + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Biens immobiliers +CREATE TABLE public.biens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + etape_id UUID REFERENCES public.etapes_pipeline(id), + + -- Identification + titre TEXT, -- Nom court ex: "T3 Bordeaux Caudéran" + reference TEXT UNIQUE, -- Référence interne auto-générée + type_bien TEXT, -- appartement, maison, immeuble, terrain, local_commercial, parking + + -- Localisation + adresse TEXT, + code_postal TEXT, + ville TEXT, + departement TEXT, + latitude DECIMAL(10,7), + longitude DECIMAL(10,7), + + -- Caractéristiques physiques + surface_habitable DECIMAL(8,2), -- m² loi Carrez + surface_totale DECIMAL(8,2), -- m² totaux + nb_pieces INTEGER, + nb_chambres INTEGER, + nb_etages INTEGER, + etage INTEGER, + annee_construction INTEGER, + dpe_lettre TEXT, -- A, B, C, D, E, F, G + dpe_valeur INTEGER, -- kWh/m²/an + ges_lettre TEXT, + ges_valeur INTEGER, + + -- Source + source TEXT, -- particulier, agence, notaire, tribunal, succession, autre + source_contact_id UUID, -- Lien vers contact (notaire, agent...) + url_annonce TEXT, + + -- Statut + statut TEXT DEFAULT 'actif', -- actif, abandonné, vendu + priorite INTEGER DEFAULT 2, -- 1=haute, 2=normale, 3=basse + is_off_market BOOLEAN DEFAULT FALSE, + + -- Dates importantes + date_premiere_visite DATE, + date_offre DATE, + date_compromis DATE, + date_acte DATE, + date_mise_en_vente DATE, + date_acte_revente DATE, + + -- Notes + description TEXT, + points_forts TEXT, + points_faibles TEXT, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Analyse financière par bien +CREATE TABLE public.analyses_financieres ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Acquisition + prix_achat DECIMAL(12,2), + type_bien_fiscal TEXT DEFAULT 'ancien', -- ancien (7.5%) ou neuf (2%) + frais_notaire DECIMAL(12,2), -- calculé ou saisi manuellement + frais_agence_achat DECIMAL(12,2), + frais_agence_achat_pct DECIMAL(5,2), + + -- Financement + apport DECIMAL(12,2), + montant_emprunt DECIMAL(12,2), + taux_credit DECIMAL(5,3), + duree_credit_mois INTEGER, + mensualite DECIMAL(10,2), + + -- Travaux + budget_travaux DECIMAL(12,2), + reserve_imprevus_pct DECIMAL(5,2) DEFAULT 10, + + -- Portage + duree_portage_mois INTEGER DEFAULT 12, + taxe_fonciere_annuelle DECIMAL(10,2), + charges_copropriete_mensuelle DECIMAL(10,2), + assurance_mensuelle DECIMAL(10,2), + frais_divers_mensuel DECIMAL(10,2), + + -- Revente + prix_revente_cible DECIMAL(12,2), + frais_agence_vente_pct DECIMAL(5,2) DEFAULT 5, + frais_agence_vente DECIMAL(12,2), + taux_impot DECIMAL(5,2) DEFAULT 25, + + -- Résultats calculés (mis à jour automatiquement) + prix_revient DECIMAL(12,2), + frais_portage_total DECIMAL(12,2), + marge_brute DECIMAL(12,2), + marge_brute_pct DECIMAL(7,2), + marge_nette DECIMAL(12,2), + marge_nette_pct DECIMAL(7,2), + + -- Scénarios + scenario_pessimiste_prix DECIMAL(12,2), + scenario_optimiste_prix DECIMAL(12,2), + + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- MODULE CONTACTS / ANNUAIRE +-- ============================================================ + +CREATE TABLE public.contacts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Identité + nom TEXT NOT NULL, + prenom TEXT, + societe TEXT, + poste TEXT, + + -- Catégorie + categorie TEXT NOT NULL, -- notaire, agent_immo, artisan_gros_oeuvre, artisan_second_oeuvre, + -- artisan_finitions, banquier, courtier, diagnostiqueur, + -- geometre, avocat, comptable, vendeur, acheteur, autre + specialite TEXT, -- ex: "Plomberie chauffage", "Électricité", "Charpente" + + -- Coordonnées + telephone TEXT, + telephone_2 TEXT, + email TEXT, + adresse TEXT, + code_postal TEXT, + ville TEXT, + zone_intervention TEXT, -- ex: "Bordeaux 33000-33100" + + -- Qualité + note INTEGER CHECK (note BETWEEN 1 AND 5), -- Note de 1 à 5 étoiles + fiabilite TEXT, -- excellent, bon, moyen, mauvais + recommande BOOLEAN DEFAULT FALSE, + + -- Tarification artisans + taux_horaire DECIMAL(8,2), + remise_habituelle_pct DECIMAL(5,2), + + notes TEXT, + is_favori BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Lien bien ↔ contact (ex: notaire assigné à ce dossier) +CREATE TABLE public.biens_contacts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES public.contacts(id) ON DELETE CASCADE, + role TEXT, -- notaire_vendeur, notaire_acheteur, agent_vendeur, artisan_principal, banque + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(bien_id, contact_id, role) +); + +-- ============================================================ +-- MODULE VISITES +-- ============================================================ + +CREATE TABLE public.visites ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + date_visite TIMESTAMPTZ NOT NULL, + duree_minutes INTEGER DEFAULT 60, + type_visite TEXT DEFAULT 'premiere', -- premiere, seconde, expert, contradiction + + -- Notes brutes pendant la visite + notes_brutes TEXT, + + -- Rapport généré par IA + rapport_genere TEXT, + rapport_genere_at TIMESTAMPTZ, + + -- Estimations sur place + estimation_travaux_min DECIMAL(12,2), + estimation_travaux_max DECIMAL(12,2), + estimation_duree_travaux_mois INTEGER, + + -- Avis global + avis_global TEXT, -- coup_de_coeur, interessant, neutre, a_eviter + score_opportunite INTEGER CHECK (score_opportunite BETWEEN 1 AND 10), + + contacts_presents TEXT[], -- Noms des personnes présentes + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Check-list de visite (items configurables) +CREATE TABLE public.checklist_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + categorie TEXT NOT NULL, -- structure, toiture, electricite, plomberie, isolation, humidite, exterieur, administratif + question TEXT NOT NULL, + description TEXT, -- Aide / explication + ordre INTEGER DEFAULT 0, + is_actif BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Réponses à la check-list pour une visite +CREATE TABLE public.checklist_reponses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + visite_id UUID NOT NULL REFERENCES public.visites(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES public.checklist_items(id), + reponse TEXT, -- ok, attention, probleme, non_verifie + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(visite_id, item_id) +); + +-- ============================================================ +-- MODULE DOCUMENTS & MÉDIAS +-- ============================================================ + +CREATE TABLE public.photos_biens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + visite_id UUID REFERENCES public.visites(id), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + storage_path TEXT NOT NULL, -- chemin dans Supabase Storage + nom TEXT, + legende TEXT, + categorie TEXT, -- facade, interieur, travaux, avant, apres + ordre INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE public.documents_biens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + storage_path TEXT NOT NULL, + nom TEXT NOT NULL, + type_document TEXT, -- compromis, acte, dpe, diagnostics, devis, facture, titre_propriete, autre + date_document DATE, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- MODULE AGENDA / TÂCHES +-- ============================================================ + +CREATE TABLE public.taches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bien_id UUID REFERENCES public.biens(id) ON DELETE SET NULL, + contact_id UUID REFERENCES public.contacts(id) ON DELETE SET NULL, + + titre TEXT NOT NULL, + description TEXT, + type_tache TEXT, -- visite, appel, email, relance, administratif, travaux, banque, autre + priorite INTEGER DEFAULT 2, + statut TEXT DEFAULT 'a_faire', -- a_faire, en_cours, fait, annule + + date_echeance TIMESTAMPTZ, + date_rappel TIMESTAMPTZ, + date_realisation TIMESTAMPTZ, + + recurrence TEXT, -- null, quotidien, hebdomadaire, mensuel + is_urgent BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Notes libres +CREATE TABLE public.notes_biens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + contenu TEXT NOT NULL, + type_note TEXT DEFAULT 'libre', -- libre, contact_vendeur, negociation, info_marche + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- MODULE TRAVAUX +-- ============================================================ + +CREATE TABLE public.devis_travaux ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE, + contact_id UUID REFERENCES public.contacts(id), -- L'artisan + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + lot TEXT NOT NULL, -- Gros œuvre, Électricité, Plomberie, Menuiserie, etc. + description TEXT, + montant_ht DECIMAL(12,2), + taux_tva DECIMAL(5,2) DEFAULT 10, + montant_ttc DECIMAL(12,2), + + statut TEXT DEFAULT 'en_attente', -- en_attente, refuse, accepte, en_cours, termine, paye + date_devis DATE, + date_debut_prevu DATE, + date_fin_prevu DATE, + date_fin_reel DATE, + + storage_path_pdf TEXT, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- DONNÉES PAR DÉFAUT +-- ============================================================ + +-- Étapes pipeline par défaut (seront copiées pour chaque nouvel utilisateur) +-- (géré via trigger ou Edge Function à l'inscription) + +-- Check-list de visite par défaut +INSERT INTO public.checklist_items (user_id, categorie, question, description, ordre) +VALUES + -- Sera remplacé par un trigger, voici la structure des items par défaut + -- Ces items sont créés via la Edge Function on-user-create + ('00000000-0000-0000-0000-000000000000', 'structure', 'Fissures visibles sur les murs porteurs ?', 'Observer les fissures en H ou diagonales, signe de mouvement de structure', 1), + ('00000000-0000-0000-0000-000000000000', 'toiture', 'État général de la toiture ?', 'Tuiles manquantes, mousse, faîtage, chéneaux', 2), + ('00000000-0000-0000-0000-000000000000', 'electricite', 'Tableau électrique aux normes ?', 'Vérifier disjoncteurs, présence de terre, type de tableau', 3), + ('00000000-0000-0000-0000-000000000000', 'plomberie', 'Type de canalisations (plomb, cuivre, PVC) ?', 'Canalisations en plomb = remplacement obligatoire', 4), + ('00000000-0000-0000-0000-000000000000', 'humidite', 'Traces d''humidité ou de moisissures ?', 'Vérifier les angles, sous les fenêtres, la cave', 5), + ('00000000-0000-0000-0000-000000000000', 'isolation', 'Type et état de l''isolation ?', 'Combles, murs, sol. DPE cohérent avec le ressenti', 6), + ('00000000-0000-0000-0000-000000000000', 'exterieur', 'État des menuiseries extérieures ?', 'Simple ou double vitrage, état des joints, volets', 7), + ('00000000-0000-0000-0000-000000000000', 'administratif', 'Charges de copropriété et procédures en cours ?', 'Demander les 3 derniers PV d''AG, montant des charges', 8); + +-- ============================================================ +-- ROW LEVEL SECURITY (RLS) +-- ============================================================ + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.biens ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.etapes_pipeline ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.analyses_financieres ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.biens_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.visites ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.checklist_items ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.checklist_reponses ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.photos_biens ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.documents_biens ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.taches ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.notes_biens ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.devis_travaux ENABLE ROW LEVEL SECURITY; + +-- Politique : chaque utilisateur voit uniquement ses propres données +CREATE POLICY "user_own_data" ON public.profiles FOR ALL USING (auth.uid() = id); +CREATE POLICY "user_own_data" ON public.biens FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.etapes_pipeline FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.analyses_financieres FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.contacts FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.visites FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.taches FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.notes_biens FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.devis_travaux FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.photos_biens FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.documents_biens FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "user_own_data" ON public.checklist_items FOR ALL USING (auth.uid() = user_id); + +-- biens_contacts : visible si l'utilisateur possède le bien +CREATE POLICY "user_own_biens_contacts" ON public.biens_contacts FOR ALL + USING (EXISTS (SELECT 1 FROM public.biens WHERE id = bien_id AND user_id = auth.uid())); + +-- checklist_reponses : visible si l'utilisateur possède la visite +CREATE POLICY "user_own_checklist_reponses" ON public.checklist_reponses FOR ALL + USING (EXISTS (SELECT 1 FROM public.visites WHERE id = visite_id AND user_id = auth.uid())); + +-- ============================================================ +-- TRIGGER : updated_at automatique +-- ============================================================ + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at = NOW(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.biens FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.visites FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.taches FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.analyses_financieres FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.devis_travaux FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================================ +-- TRIGGER : Créer profil + données par défaut à l'inscription +-- ============================================================ + +CREATE OR REPLACE FUNCTION handle_new_user() +RETURNS TRIGGER AS $$ +DECLARE + etape_ids UUID[]; +BEGIN + -- Créer le profil + INSERT INTO public.profiles (id) VALUES (NEW.id); + + -- Créer les étapes du pipeline par défaut + INSERT INTO public.etapes_pipeline (user_id, nom, ordre, couleur) VALUES + (NEW.id, 'Piste', 1, '#6B7280'), + (NEW.id, 'En analyse', 2, '#3B82F6'), + (NEW.id, 'Offre faite', 3, '#F59E0B'), + (NEW.id, 'Compromis signé', 4, '#8B5CF6'), + (NEW.id, 'Acte signé', 5, '#10B981'), + (NEW.id, 'En travaux', 6, '#F97316'), + (NEW.id, 'En vente', 7, '#EC4899'), + (NEW.id, 'Vendu', 8, '#22C55E'), + (NEW.id, 'Abandonné', 9, '#EF4444'); + + -- Copier la check-list de visite par défaut + INSERT INTO public.checklist_items (user_id, categorie, question, description, ordre) + SELECT NEW.id, categorie, question, description, ordre + FROM public.checklist_items + WHERE user_id = '00000000-0000-0000-0000-000000000000'; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION handle_new_user(); + +-- ============================================================ +-- INDEX pour les performances +-- ============================================================ + +CREATE INDEX idx_biens_user_id ON public.biens(user_id); +CREATE INDEX idx_biens_etape_id ON public.biens(etape_id); +CREATE INDEX idx_biens_ville ON public.biens(ville); +CREATE INDEX idx_contacts_user_id ON public.contacts(user_id); +CREATE INDEX idx_contacts_categorie ON public.contacts(categorie); +CREATE INDEX idx_taches_user_id ON public.taches(user_id); +CREATE INDEX idx_taches_date_echeance ON public.taches(date_echeance); +CREATE INDEX idx_visites_bien_id ON public.visites(bien_id); diff --git a/src/components/LabeledField.tsx b/src/components/LabeledField.tsx new file mode 100644 index 0000000..22a09ba --- /dev/null +++ b/src/components/LabeledField.tsx @@ -0,0 +1,40 @@ +import { StyleSheet, Text, TextInput, View, type TextInputProps } from 'react-native'; +import { colors } from '../theme/colors'; + +type Props = TextInputProps & { + label: string; +}; + +export function LabeledField({ label, style, ...rest }: Props) { + return ( + + {label} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { marginBottom: 14 }, + label: { + color: colors.textMuted, + fontSize: 12, + marginBottom: 6, + textTransform: 'uppercase', + letterSpacing: 0.06, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + color: colors.text, + backgroundColor: colors.bgCard, + fontSize: 16, + }, +}); diff --git a/src/components/PrimaryButton.tsx b/src/components/PrimaryButton.tsx new file mode 100644 index 0000000..c7d9a78 --- /dev/null +++ b/src/components/PrimaryButton.tsx @@ -0,0 +1,77 @@ +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + type PressableProps, + type ViewStyle, +} from 'react-native'; +import { colors } from '../theme/colors'; + +type Props = Omit & { + title: string; + variant?: 'primary' | 'danger' | 'ghost'; + loading?: boolean; + containerStyle?: ViewStyle; +}; + +export function PrimaryButton({ + title, + variant = 'primary', + loading, + disabled, + containerStyle, + ...rest +}: Props) { + const dim = disabled || loading; + return ( + [ + styles.base, + variant === 'primary' && styles.primary, + variant === 'danger' && styles.danger, + variant === 'ghost' && styles.ghost, + (pressed || dim) && styles.dim, + containerStyle, + ]} + disabled={dim} + {...rest} + > + {loading ? ( + + ) : ( + + {title} + + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + paddingVertical: 14, + paddingHorizontal: 18, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + primary: { backgroundColor: colors.accent }, + danger: { backgroundColor: colors.danger }, + ghost: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.border, + }, + dim: { opacity: 0.55 }, + text: { color: '#fff', fontWeight: '600', fontSize: 16 }, + textGhost: { color: colors.text }, + textDanger: { color: '#fff' }, +}); diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx new file mode 100644 index 0000000..d4a2ed7 --- /dev/null +++ b/src/context/AppContext.tsx @@ -0,0 +1,655 @@ +import type { Session, SupabaseClient } from '@supabase/supabase-js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { makeSupabase } from '../lib/supabaseFactory'; +import { + newDossierTemplate, + seedVisitRowsForDossier, + LOCAL_USER_ID, +} from '../data/defaults'; +import { + SCOUT_SAMPLE_JSON, + scoutFilterListings, +} from '../data/dealSource'; +import type { + DealSourceRow, + DossierRow, + DossierVisitFindingRow, + InvestisseurRow, + LocalDbSnapshot, + VisitFindingDefinitionRow, +} from '../data/types'; +import { VISIT_FINDING_SEED } from '../data/visitWorks'; +import { + readCloudConfig, + readLocalDb, + readStoredMode, + writeCloudConfig, + writeLocalDb, + writeStoredMode, + type StoredCloudConfig, +} from './persistence'; +import { randomUuid } from '../lib/uuid'; + +export type AppRuntimeMode = 'none' | 'local' | 'cloud'; + +type AppUser = { id: string; email: string | null }; + +interface AppContextValue { + ready: boolean; + runtimeMode: AppRuntimeMode; + user: AppUser | null; + supabase: SupabaseClient | null; + dossiers: DossierRow[]; + investisseurs: InvestisseurRow[]; + definitions: VisitFindingDefinitionRow[]; + findingTick: number; + refreshAll: () => Promise; + enterLocalMode: () => Promise; + saveCloudConfig: (cfg: StoredCloudConfig) => Promise; + signIn: (email: string, password: string) => Promise<{ error?: string }>; + signUp: ( + email: string, + password: string, + fullName: string, + ) => Promise<{ error?: string }>; + signOut: () => Promise; + createDossier: () => Promise; + updateDossier: (id: string, patch: Partial) => Promise; + deleteDossier: (id: string) => Promise; + setDossierStatus: (id: string, status: DossierRow['status']) => Promise; + listFindings: (dossierId: string) => DossierVisitFindingRow[]; + toggleFinding: ( + dossierId: string, + code: string, + checked: boolean, + ) => Promise; + upsertInvestisseur: ( + row: Omit & { + id?: string; + }, + ) => Promise; + deleteInvestisseur: (id: string) => Promise; + dealSources: DealSourceRow[]; + runScoutSampleBatch: () => Promise< + { inserted: number; gradeA: number } | { error: string } + >; +} + +const Ctx = createContext(null); + +function normalizeDefinitions( + rows: VisitFindingDefinitionRow[] | null | undefined, +): VisitFindingDefinitionRow[] { + if (rows && rows.length) { + return [...rows].sort((a, b) => a.sort_order - b.sort_order); + } + return VISIT_FINDING_SEED; +} + +export function AppProvider({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const [runtimeMode, setRuntimeMode] = useState('none'); + const [supabase, setSupabase] = useState(null); + const [session, setSession] = useState(null); + const [dossiers, setDossiers] = useState([]); + const [investisseurs, setInvestisseurs] = useState([]); + const [definitions, setDefinitions] = useState( + VISIT_FINDING_SEED, + ); + const [localDb, setLocalDb] = useState({ + dossiers: [], + dossier_visit_findings: [], + investisseurs: [], + deals_sources: [], + }); + const [findingTick, setFindingTick] = useState(0); + const [dealSources, setDealSources] = useState([]); + + const user = useMemo(() => { + if (runtimeMode === 'local') { + return { id: LOCAL_USER_ID, email: 'hors-ligne@mdb-turbo' }; + } + if (runtimeMode === 'cloud' && session?.user) { + return { id: session.user.id, email: session.user.email ?? null }; + } + return null; + }, [runtimeMode, session]); + + const refreshAll = useCallback(async () => { + if (runtimeMode === 'local') { + const db = await readLocalDb(); + setLocalDb(db); + setDossiers(db.dossiers); + setInvestisseurs(db.investisseurs); + setDealSources(db.deals_sources ?? []); + setDefinitions(VISIT_FINDING_SEED); + return; + } + if (!supabase || !session?.user) { + setDossiers([]); + setInvestisseurs([]); + setDealSources([]); + return; + } + const uid = session.user.id; + const [dRes, iRes, defRes, dealsRes] = await Promise.all([ + supabase + .from('dossiers') + .select('*') + .eq('user_id', uid) + .order('updated_at', { ascending: false }), + supabase.from('investisseurs').select('*').eq('user_id', uid), + supabase + .from('visit_finding_definitions') + .select('*') + .order('sort_order', { ascending: true }), + supabase + .from('deals_sources') + .select('*') + .eq('user_id', uid) + .order('opportunity_score', { ascending: false }), + ]); + setDossiers((dRes.data as DossierRow[]) ?? []); + setInvestisseurs((iRes.data as InvestisseurRow[]) ?? []); + setDefinitions(normalizeDefinitions(defRes.data as VisitFindingDefinitionRow[])); + setDealSources((dealsRes.data as DealSourceRow[]) ?? []); + }, [runtimeMode, supabase, session]); + + useEffect(() => { + let cancelled = false; + (async () => { + const mode = await readStoredMode(); + const cfg = await readCloudConfig(); + const local = await readLocalDb(); + if (cancelled) return; + + if (mode === 'local') { + setRuntimeMode('local'); + setLocalDb(local); + setDossiers(local.dossiers); + setInvestisseurs(local.investisseurs); + setDealSources(local.deals_sources ?? []); + setDefinitions(VISIT_FINDING_SEED); + setSupabase(null); + setSession(null); + setReady(true); + return; + } + + if (mode === 'cloud' && cfg?.supabaseUrl && cfg.supabaseAnonKey) { + const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey); + const { data } = await client.auth.getSession(); + if (cancelled) return; + setSupabase(client); + setSession(data.session ?? null); + setRuntimeMode('cloud'); + setReady(true); + return; + } + + setRuntimeMode('none'); + setSupabase(null); + setSession(null); + setReady(true); + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!supabase) return; + const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => { + setSession(sess); + }); + return () => sub.subscription.unsubscribe(); + }, [supabase]); + + useEffect(() => { + if (!ready) return; + void refreshAll(); + }, [ready, runtimeMode, session?.user?.id, refreshAll]); + + const persistLocal = useCallback(async (next: LocalDbSnapshot) => { + setLocalDb(next); + setDossiers(next.dossiers); + setInvestisseurs(next.investisseurs); + setDealSources(next.deals_sources ?? []); + await writeLocalDb(next); + setFindingTick((t) => t + 1); + }, []); + + const enterLocalMode = useCallback(async () => { + await writeStoredMode('local'); + const existing = await readLocalDb(); + setRuntimeMode('local'); + setSupabase(null); + setSession(null); + setLocalDb(existing); + setDossiers(existing.dossiers); + setInvestisseurs(existing.investisseurs); + setDealSources(existing.deals_sources ?? []); + setDefinitions(VISIT_FINDING_SEED); + }, []); + + const saveCloudConfig = useCallback(async (cfg: StoredCloudConfig) => { + await writeCloudConfig(cfg); + await writeStoredMode('cloud'); + const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey); + const { data } = await client.auth.getSession(); + setSupabase(client); + setSession(data.session ?? null); + setRuntimeMode('cloud'); + }, []); + + const signIn = useCallback( + async (email: string, password: string) => { + if (!supabase) return { error: 'Supabase non configuré.' }; + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) return { error: error.message }; + return {}; + }, + [supabase], + ); + + const signUp = useCallback( + async (email: string, password: string, fullName: string) => { + if (!supabase) return { error: 'Supabase non configuré.' }; + const { error } = await supabase.auth.signUp({ + email, + password, + options: { data: { full_name: fullName } }, + }); + if (error) return { error: error.message }; + return {}; + }, + [supabase], + ); + + const signOut = useCallback(async () => { + if (runtimeMode === 'cloud' && supabase) { + await supabase.auth.signOut(); + return; + } + if (runtimeMode === 'local') { + await writeStoredMode('none'); + setRuntimeMode('none'); + setSession(null); + setSupabase(null); + setDossiers([]); + setInvestisseurs([]); + setDealSources([]); + setLocalDb({ + dossiers: [], + dossier_visit_findings: [], + investisseurs: [], + deals_sources: [], + }); + } + }, [runtimeMode, supabase]); + + const createDossier = useCallback(async (): Promise => { + const uid = user?.id; + if (!uid) return null; + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const row = newDossierTemplate(uid); + const findings = seedVisitRowsForDossier(row.id); + const next: LocalDbSnapshot = { + dossiers: [row, ...snap.dossiers], + dossier_visit_findings: [...findings, ...snap.dossier_visit_findings], + investisseurs: snap.investisseurs, + deals_sources: snap.deals_sources ?? [], + }; + await persistLocal(next); + return row.id; + } + if (!supabase) return null; + const base = newDossierTemplate(uid); + const { data, error } = await supabase + .from('dossiers') + .insert({ + user_id: uid, + title: base.title, + status: base.status, + surface_m2: base.surface_m2, + purchase_price_target: base.purchase_price_target, + resale_price_estimate: base.resale_price_estimate, + dvf_reference_price_m2: base.dvf_reference_price_m2, + works_estimate_total: base.works_estimate_total, + works_visit_adjustment: base.works_visit_adjustment, + notary_fee_rate: base.notary_fee_rate, + sale_agency_fee_rate: base.sale_agency_fee_rate, + misc_acquisition_cost: base.misc_acquisition_cost, + misc_sale_cost: base.misc_sale_cost, + carrying_months: base.carrying_months, + carrying_annual_rate: base.carrying_annual_rate, + parcel_subdivision_candidate: base.parcel_subdivision_candidate, + deficit_foncier_candidate: base.deficit_foncier_candidate, + plu_zone_code: base.plu_zone_code, + plu_notes: base.plu_notes, + }) + .select('id') + .single(); + if (error || !data?.id) { + return null; + } + const id = data.id as string; + const defs = definitions.length ? definitions : VISIT_FINDING_SEED; + const rows = defs.map((d) => ({ + dossier_id: id, + finding_code: d.code, + checked: false, + })); + await supabase.from('dossier_visit_findings').insert(rows); + await refreshAll(); + return id; + }, [user?.id, runtimeMode, supabase, persistLocal, definitions, refreshAll]); + + const updateDossier = useCallback( + async (id: string, patch: Partial) => { + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const nextDossiers = snap.dossiers.map((d) => + d.id === id ? { ...d, ...patch, updated_at: new Date().toISOString() } : d, + ); + await persistLocal({ ...snap, dossiers: nextDossiers }); + return; + } + if (!supabase || !user) return; + const clean: Record = { ...patch }; + delete clean.id; + delete clean.user_id; + delete clean.created_at; + await supabase.from('dossiers').update(clean).eq('id', id).eq('user_id', user.id); + await refreshAll(); + }, + [runtimeMode, persistLocal, supabase, user, refreshAll], + ); + + const deleteDossier = useCallback( + async (id: string) => { + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const next: LocalDbSnapshot = { + dossiers: snap.dossiers.filter((d) => d.id !== id), + dossier_visit_findings: snap.dossier_visit_findings.filter( + (f) => f.dossier_id !== id, + ), + investisseurs: snap.investisseurs, + deals_sources: snap.deals_sources ?? [], + }; + await persistLocal(next); + return; + } + if (!supabase || !user) return; + await supabase.from('dossiers').delete().eq('id', id).eq('user_id', user.id); + await refreshAll(); + }, + [runtimeMode, persistLocal, supabase, user, refreshAll], + ); + + const setDossierStatus = useCallback( + async (id: string, status: DossierRow['status']) => { + const extra: Partial = { status }; + if (status === 'under_promise') { + extra.under_promise_at = new Date().toISOString(); + } + await updateDossier(id, extra); + }, + [updateDossier], + ); + + const listFindings = useCallback( + (dossierId: string): DossierVisitFindingRow[] => { + if (runtimeMode !== 'local') return []; + return localDb.dossier_visit_findings.filter((f) => f.dossier_id === dossierId); + }, + [runtimeMode, localDb.dossier_visit_findings], + ); + + const toggleFinding = useCallback( + async (dossierId: string, code: string, checked: boolean) => { + const now = new Date().toISOString(); + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const nextFindings = snap.dossier_visit_findings.map((f) => + f.dossier_id === dossierId && f.finding_code === code + ? { ...f, checked, checked_at: checked ? now : null } + : f, + ); + await persistLocal({ ...snap, dossier_visit_findings: nextFindings }); + return; + } + if (!supabase) return; + await supabase + .from('dossier_visit_findings') + .update({ checked, checked_at: checked ? now : null }) + .eq('dossier_id', dossierId) + .eq('finding_code', code); + setFindingTick((t) => t + 1); + await refreshAll(); + }, + [runtimeMode, persistLocal, supabase, refreshAll], + ); + + const upsertInvestisseur = useCallback( + async ( + row: Omit & { + id?: string; + }, + ) => { + const ts = new Date().toISOString(); + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const id = row.id ?? randomUuid(); + const existing = snap.investisseurs.find((i) => i.id === id); + const nextRow: InvestisseurRow = { + id, + user_id: row.user_id, + display_name: row.display_name, + email: row.email, + phone: row.phone, + min_margin_pct: row.min_margin_pct, + max_ticket_eur: row.max_ticket_eur, + zones: row.zones, + strategies: row.strategies, + notes: row.notes, + created_at: existing?.created_at ?? ts, + updated_at: ts, + }; + const list = existing + ? snap.investisseurs.map((i) => (i.id === id ? nextRow : i)) + : [nextRow, ...snap.investisseurs]; + await persistLocal({ ...snap, investisseurs: list }); + return; + } + if (!supabase || !user) return; + if (row.id) { + await supabase + .from('investisseurs') + .update({ + display_name: row.display_name, + email: row.email, + phone: row.phone, + min_margin_pct: row.min_margin_pct, + max_ticket_eur: row.max_ticket_eur, + zones: row.zones, + strategies: row.strategies, + notes: row.notes, + }) + .eq('id', row.id) + .eq('user_id', user.id); + } else { + await supabase.from('investisseurs').insert({ + user_id: user.id, + display_name: row.display_name, + email: row.email, + phone: row.phone, + min_margin_pct: row.min_margin_pct, + max_ticket_eur: row.max_ticket_eur, + zones: row.zones, + strategies: row.strategies, + notes: row.notes, + }); + } + await refreshAll(); + }, + [runtimeMode, persistLocal, supabase, user, refreshAll], + ); + + const deleteInvestisseur = useCallback( + async (id: string) => { + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + await persistLocal({ + ...snap, + investisseurs: snap.investisseurs.filter((i) => i.id !== id), + }); + return; + } + if (!supabase || !user) return; + await supabase.from('investisseurs').delete().eq('id', id).eq('user_id', user.id); + await refreshAll(); + }, + [runtimeMode, persistLocal, supabase, user, refreshAll], + ); + + const runScoutSampleBatch = useCallback(async () => { + const uid = user?.id; + if (!uid) { + return { error: 'Session requise.' }; + } + if (runtimeMode === 'local') { + const snap = await readLocalDb(); + const newOnes = scoutFilterListings(SCOUT_SAMPLE_JSON, uid); + await persistLocal({ + ...snap, + deals_sources: [...newOnes, ...(snap.deals_sources ?? [])], + }); + const gradeA = newOnes.filter((x) => x.grade === 'A').length; + return { inserted: newOnes.length, gradeA }; + } + if (!supabase) { + return { error: 'Supabase non configuré.' }; + } + const { data, error } = await supabase.rpc('scout_process_batch', { + p_listings: SCOUT_SAMPLE_JSON, + }); + if (error) { + return { error: error.message }; + } + const row = data as { inserted_count?: number; grade_a_count?: number } | null; + await refreshAll(); + return { + inserted: row?.inserted_count ?? 0, + gradeA: row?.grade_a_count ?? 0, + }; + }, [user?.id, runtimeMode, supabase, persistLocal, refreshAll]); + + const value = useMemo( + () => ({ + ready, + runtimeMode, + user, + supabase, + dossiers, + investisseurs, + definitions, + findingTick, + dealSources, + refreshAll, + enterLocalMode, + saveCloudConfig, + signIn, + signUp, + signOut, + createDossier, + updateDossier, + deleteDossier, + setDossierStatus, + listFindings, + toggleFinding, + upsertInvestisseur, + deleteInvestisseur, + runScoutSampleBatch, + }), + [ + ready, + runtimeMode, + user, + supabase, + dossiers, + investisseurs, + definitions, + findingTick, + dealSources, + refreshAll, + enterLocalMode, + saveCloudConfig, + signIn, + signUp, + signOut, + createDossier, + updateDossier, + deleteDossier, + setDossierStatus, + listFindings, + toggleFinding, + upsertInvestisseur, + deleteInvestisseur, + runScoutSampleBatch, + ], + ); + + return {children}; +} + +export function useApp(): AppContextValue { + const v = useContext(Ctx); + if (!v) { + throw new Error('useApp doit être utilisé dans AppProvider'); + } + return v; +} + +export function useVisitFindings(dossierId: string | undefined) { + const app = useApp(); + const [cloudRows, setCloudRows] = useState([]); + + useEffect(() => { + let cancelled = false; + (async () => { + if (!dossierId || app.runtimeMode !== 'cloud' || !app.supabase) { + setCloudRows([]); + return; + } + const { data } = await app.supabase + .from('dossier_visit_findings') + .select('*') + .eq('dossier_id', dossierId); + if (!cancelled) { + setCloudRows((data as DossierVisitFindingRow[]) ?? []); + } + })(); + return () => { + cancelled = true; + }; + }, [dossierId, app.runtimeMode, app.supabase, app.findingTick]); + + if (!dossierId) return []; + if (app.runtimeMode === 'local') { + return app.listFindings(dossierId); + } + return cloudRows; +} diff --git a/src/context/persistence.ts b/src/context/persistence.ts new file mode 100644 index 0000000..4e9b473 --- /dev/null +++ b/src/context/persistence.ts @@ -0,0 +1,75 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { LocalDbSnapshot } from '../data/types'; + +const KEY_MODE = 'mdb_mode_v1'; +const KEY_CONFIG = 'mdb_config_v1'; +const KEY_LOCAL_DB = 'mdb_local_db_v1'; + +export type StoredMode = 'local' | 'cloud' | 'none'; + +export interface StoredCloudConfig { + supabaseUrl: string; + supabaseAnonKey: string; +} + +export async function readStoredMode(): Promise { + const v = await AsyncStorage.getItem(KEY_MODE); + if (v === 'local' || v === 'cloud') return v; + return 'none'; +} + +export async function writeStoredMode(mode: StoredMode): Promise { + if (mode === 'none') { + await AsyncStorage.removeItem(KEY_MODE); + return; + } + await AsyncStorage.setItem(KEY_MODE, mode); +} + +export async function readCloudConfig(): Promise { + const raw = await AsyncStorage.getItem(KEY_CONFIG); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as StoredCloudConfig; + if (parsed.supabaseUrl && parsed.supabaseAnonKey) return parsed; + return null; + } catch { + return null; + } +} + +export async function writeCloudConfig(cfg: StoredCloudConfig): Promise { + await AsyncStorage.setItem(KEY_CONFIG, JSON.stringify(cfg)); +} + +export async function readLocalDb(): Promise { + const raw = await AsyncStorage.getItem(KEY_LOCAL_DB); + if (!raw) { + return { + dossiers: [], + dossier_visit_findings: [], + investisseurs: [], + deals_sources: [], + }; + } + try { + const parsed = JSON.parse(raw) as LocalDbSnapshot; + return { + dossiers: parsed.dossiers ?? [], + dossier_visit_findings: parsed.dossier_visit_findings ?? [], + investisseurs: parsed.investisseurs ?? [], + deals_sources: parsed.deals_sources ?? [], + }; + } catch { + return { + dossiers: [], + dossier_visit_findings: [], + investisseurs: [], + deals_sources: [], + }; + } +} + +export async function writeLocalDb(db: LocalDbSnapshot): Promise { + await AsyncStorage.setItem(KEY_LOCAL_DB, JSON.stringify(db)); +} diff --git a/src/core/juge/index.ts b/src/core/juge/index.ts new file mode 100644 index 0000000..1e9a06f --- /dev/null +++ b/src/core/juge/index.ts @@ -0,0 +1,10 @@ +export { + evaluateDeal, + maxPurchaseForTargetNetMarginPct, +} from './marginCalculator'; +export type { JugeInputs, JugeResult } from './marginCalculator'; +export { + DEFAULT_VAT_ON_MARGIN_RATE, + DVF_DISCOUNT_FLASH_PCT, + MIN_NET_MARGIN_PCT, +} from './thresholds'; diff --git a/src/core/juge/marginCalculator.ts b/src/core/juge/marginCalculator.ts index ce7355e..c79e70d 100644 --- a/src/core/juge/marginCalculator.ts +++ b/src/core/juge/marginCalculator.ts @@ -1,16 +1,16 @@ +import type { DealTrafficLight } from '../../domain/dealSignals'; import { DEFAULT_VAT_ON_MARGIN_RATE, DVF_DISCOUNT_FLASH_PCT, MIN_NET_MARGIN_PCT, } from './thresholds'; -export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf'; +export type { DealTrafficLight }; export interface JugeInputs { purchasePrice: number; resalePrice: number; surfaceM2: number; - /** Prix m² de référence marché (DVF / étude locale). */ dvfReferencePriceM2?: number | null; worksTotal: number; notaryFeeRate: number; @@ -20,11 +20,6 @@ export interface JugeInputs { carryingMonths: number; carryingAnnualRate: number; carryingPrincipal?: number | null; - /** - * TVA sur marge : approximation prudentielle pour outil terrain. - * Base imposable = marge économique avant TVA (cash hors TVA collectée/déductible). - * Ajustez avec votre expert-comptable selon votre assiette réelle. - */ vatOnMarginRate?: number; } @@ -38,8 +33,9 @@ export interface JugeResult { breakEvenResalePrice: number; purchasePricePerM2: number; dvfDiscountPct: number | null; + /** Sous-cotation vs DVF (prix / m² ≤ référence × (1 − 20 %)). */ + dvfUnderMarketFlash: boolean; trafficLight: DealTrafficLight; - /** 0–100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */ scoreDeal: number; } @@ -51,9 +47,6 @@ function carryingCostEUR(input: JugeInputs): number { return principal * input.carryingAnnualRate * (input.carryingMonths / 12); } -/** - * Le Juge : synthèse financière go / no-go pour marchand de biens. - */ export function evaluateDeal(input: JugeInputs): JugeResult { const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE; @@ -81,6 +74,7 @@ export function evaluateDeal(input: JugeInputs): JugeResult { input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0; let dvfDiscountPct: number | null = null; + let dvfUnderMarketFlash = false; if ( input.dvfReferencePriceM2 != null && input.dvfReferencePriceM2 > 0 && @@ -89,13 +83,15 @@ export function evaluateDeal(input: JugeInputs): JugeResult { dvfDiscountPct = (input.dvfReferencePriceM2 - purchasePricePerM2) / input.dvfReferencePriceM2; + dvfUnderMarketFlash = + purchasePricePerM2 <= + input.dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT); } const trafficLight = resolveTrafficLight( netMarginPct, + dvfUnderMarketFlash, dvfDiscountPct, - purchasePricePerM2, - input.dvfReferencePriceM2, ); const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct); @@ -107,35 +103,34 @@ export function evaluateDeal(input: JugeInputs): JugeResult { vatOnMargin, netMarginAfterVat, netMarginPct, - breakEvenResalePrice: computeBreakEvenResale(input, vatRate), + breakEvenResalePrice: computeBreakEvenResale(input), purchasePricePerM2, dvfDiscountPct, + dvfUnderMarketFlash, trafficLight, scoreDeal, }; } +/** Marge : feu rouge sous 15 %. DVF : flash vert indépendant (signal d’achat). */ function resolveTrafficLight( netMarginPct: number, + dvfUnderMarketFlash: boolean, dvfDiscountPct: number | null, - purchasePricePerM2: number, - dvfReferencePriceM2?: number | null, ): DealTrafficLight { - const underDvfFlash = - dvfReferencePriceM2 != null && - dvfReferencePriceM2 > 0 && - purchasePricePerM2 <= dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT); - if (netMarginPct < MIN_NET_MARGIN_PCT) { - return underDvfFlash ? 'green_flash_dvf' : 'red'; + return 'red'; } - if (underDvfFlash) { + if (dvfUnderMarketFlash) { return 'green_flash_dvf'; } if (dvfDiscountPct != null && dvfDiscountPct > 0.1) { return 'green'; } - return 'orange'; + if (netMarginPct < 0.18) { + return 'orange'; + } + return 'green'; } function computeScoreDeal( @@ -153,7 +148,7 @@ function computeScoreDeal( return Math.round(Math.min(100, marginScore + dvfBonus)); } -function computeBreakEvenResale(input: JugeInputs, vatRate: number): number { +function computeBreakEvenResale(input: JugeInputs): number { const notaryFees = input.purchasePrice * input.notaryFeeRate; const carrying = carryingCostEUR(input); const fixedCosts = @@ -165,83 +160,25 @@ function computeBreakEvenResale(input: JugeInputs, vatRate: number): number { input.miscSaleCost; const agencyRate = input.saleAgencyFeeRate; - const effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate); - if (effectiveCoeff <= 0) { + const coeff = 1 - agencyRate; + if (coeff <= 0) { return Number.POSITIVE_INFINITY; } - return fixedCosts / effectiveCoeff; + return fixedCosts / coeff; } /** - * Prix d'achat maximum pour respecter une marge nette cible (linéaire sur coûts). - * Utile quand la checklist visite augmente les travaux : recalcul offre max. + * Prix d’achat max pour tenir une marge nette cible (ex. 15 % après TVA sur marge). + * Recalcul instantané quand la checklist visite augmente les travaux. */ export function maxPurchaseForTargetNetMarginPct( input: Omit, targetNetMarginPct: number, ): number { const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE; - const carryingPrincipalFallback = 0; - - const agency = input.saleAgencyFeeRate; - const netResaleCoeff = 1 - agency; - - const numerator = - netResaleCoeff * input.resalePrice - - input.miscSaleCost - - input.worksTotal - - input.miscAcquisitionCost - - (1 + targetNetMarginPct + vatRate * (1 + targetNetMarginPct)) * - carryingPrincipalFallback; - - const denominator = - (1 + input.notaryFeeRate) * - (1 + - targetNetMarginPct + - vatRate * (1 + targetNetMarginPct) + - input.carryingAnnualRate * (input.carryingMonths / 12)); - - if (denominator <= 0) { - return 0; - } - - let maxPurchase = numerator / denominator; - - const refine = (candidate: number): number => { - const trial: JugeInputs = { - ...input, - purchasePrice: candidate, - carryingPrincipal: input.carryingPrincipal ?? candidate, - }; - const { netMarginPct } = evaluateDeal(trial); - return netMarginPct - targetNetMarginPct; - }; - - maxPurchase = binarySearchPurchase(input, targetNetMarginPct, vatRate); - - void refine; - return Math.max(0, Math.round(maxPurchase)); -} - -/** - * Recherche dichotomique robuste (le lien marge ↔ prix d'achat est affine par morceaux - * mais les arrondis / TVA max(0,·) peuvent créer des irrégularités légères). - */ -export function maxPurchaseForTargetNetMarginPctRobust( - input: Omit, - targetNetMarginPct: number, -): number { - return binarySearchPurchase(input, targetNetMarginPct); -} - -function binarySearchPurchase( - input: Omit, - targetNetMarginPct: number, - vatRate: number = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE, -): number { let low = 0; - let high = input.resalePrice * 1.5; - for (let i = 0; i < 48; i++) { + let high = Math.max(input.resalePrice * 1.5, 1); + for (let i = 0; i < 56; i++) { const mid = (low + high) / 2; const { netMarginPct } = evaluateDeal({ ...input, diff --git a/src/data/dealSource.ts b/src/data/dealSource.ts new file mode 100644 index 0000000..449ba6c --- /dev/null +++ b/src/data/dealSource.ts @@ -0,0 +1,91 @@ +import { randomUuid } from '../lib/uuid'; +import type { DealSourceRow } from './types'; + +export const SCOUT_SIMULATED_DVF_AVG_M2 = 3500; + +const KEYWORDS = ['succession', 'urgent', 'travaux important'] as const; + +export interface RawListingInput { + title?: string; + description?: string; + price_eur?: number; + surface_m2?: number; + url?: string; + source?: string; +} + +export function scoutFilterListings( + listings: RawListingInput[], + userId: string, +): DealSourceRow[] { + const out: DealSourceRow[] = []; + const now = new Date().toISOString(); + const avg = SCOUT_SIMULATED_DVF_AVG_M2; + + for (const el of listings) { + const price = el.price_eur; + const surf = el.surface_m2; + if (price == null || surf == null || surf <= 0) continue; + const txt = `${el.description ?? ''} ${el.title ?? ''}`.toLowerCase(); + const matched: string[] = []; + for (const k of KEYWORDS) { + if (txt.includes(k)) matched.push(k); + } + const okKw = matched.length > 0; + const pm2 = price / surf; + const okPm2 = pm2 < avg; + if (!okKw || !okPm2) continue; + + const score = + 40 + + Math.max(0, ((avg - pm2) / avg) * 50) + + matched.length * 10; + const grade = score >= 80 ? 'A' : score >= 55 ? 'B' : 'C'; + + out.push({ + id: randomUuid(), + user_id: userId, + title: (el.title?.trim() || 'Sans titre').slice(0, 500), + description: el.description ?? null, + source_url: el.url?.trim() || null, + source_name: el.source?.trim() || null, + price_eur: price, + surface_m2: surf, + price_per_m2_eur: pm2, + dvf_avg_m2_simulated: avg, + distress_keywords: matched, + opportunity_score: Math.round(score * 100) / 100, + grade, + raw_payload: el as unknown as Record, + created_at: now, + }); + } + return out; +} + +export const SCOUT_SAMPLE_JSON: RawListingInput[] = [ + { + title: 'Maison succession à rénover', + description: 'Urgent vente succession, travaux importants à prévoir', + price_eur: 198000, + surface_m2: 95, + url: 'https://example.com/a1', + source: 'simulation', + }, + { + title: 'Appartement centre', + description: 'Bel appartement rénové', + price_eur: 320000, + surface_m2: 65, + url: 'https://example.com/a2', + source: 'simulation', + }, + { + title: 'Longère DPE G urgent', + description: 'Succession — urgent — gros travaux importants', + price_eur: 142000, + surface_m2: 120, + url: 'https://example.com/a3', + source: 'simulation', + }, +]; diff --git a/src/data/defaults.ts b/src/data/defaults.ts new file mode 100644 index 0000000..93929ff --- /dev/null +++ b/src/data/defaults.ts @@ -0,0 +1,54 @@ +import { randomUuid } from '../lib/uuid'; +import type { DossierRow, DossierVisitFindingRow } from './types'; +import { VISIT_FINDING_SEED } from './visitWorks'; + +export const LOCAL_USER_ID = '11111111-1111-1111-1111-111111111111'; + +export function newDossierTemplate(userId: string): DossierRow { + const now = new Date().toISOString(); + return { + id: randomUuid(), + user_id: userId, + title: 'Nouveau dossier', + status: 'draft', + address_line: null, + city: null, + postal_code: null, + insee_code: null, + surface_m2: 90, + land_surface_m2: null, + rooms_count: null, + dpe_class: null, + purchase_price_target: 150_000, + resale_price_estimate: 240_000, + dvf_reference_price_m2: 2800, + works_estimate_total: 25_000, + works_visit_adjustment: 0, + notary_fee_rate: 0.077, + sale_agency_fee_rate: 0.05, + misc_acquisition_cost: 2000, + misc_sale_cost: 1500, + carrying_months: 6, + carrying_annual_rate: 0.055, + carrying_principal: null, + plu_zone_code: null, + plu_notes: null, + parcel_subdivision_candidate: false, + deficit_foncier_candidate: false, + under_promise_at: null, + teaser_pdf_url: null, + created_at: now, + updated_at: now, + }; +} + +export function seedVisitRowsForDossier(dossierId: string): DossierVisitFindingRow[] { + return VISIT_FINDING_SEED.map((d) => ({ + id: randomUuid(), + dossier_id: dossierId, + finding_code: d.code, + checked: false, + works_delta_override_eur: null, + checked_at: null, + })); +} diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..db1a3b4 --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,104 @@ +export type DossierStatusDb = + | 'draft' + | 'sourcing' + | 'analysis' + | 'visit' + | 'offer' + | 'under_promise' + | 'resale' + | 'closed_won' + | 'closed_lost'; + +export interface VisitFindingDefinitionRow { + code: string; + label: string; + default_works_delta_eur: number; + severity: number; + sort_order: number; +} + +export interface DossierVisitFindingRow { + id: string; + dossier_id: string; + finding_code: string; + checked: boolean; + works_delta_override_eur: number | null; + checked_at: string | null; +} + +export interface DossierRow { + id: string; + user_id: string; + title: string; + status: DossierStatusDb; + address_line: string | null; + city: string | null; + postal_code: string | null; + insee_code: string | null; + surface_m2: number | null; + land_surface_m2: number | null; + rooms_count: number | null; + dpe_class: string | null; + purchase_price_target: number | null; + resale_price_estimate: number | null; + dvf_reference_price_m2: number | null; + works_estimate_total: number | null; + works_visit_adjustment: number | null; + notary_fee_rate: number | null; + sale_agency_fee_rate: number | null; + misc_acquisition_cost: number | null; + misc_sale_cost: number | null; + carrying_months: number | null; + carrying_annual_rate: number | null; + carrying_principal: number | null; + plu_zone_code: string | null; + plu_notes: string | null; + parcel_subdivision_candidate: boolean; + deficit_foncier_candidate: boolean; + under_promise_at: string | null; + teaser_pdf_url: string | null; + created_at: string; + updated_at: string; +} + +export interface InvestisseurRow { + id: string; + user_id: string; + display_name: string; + email: string | null; + phone: string | null; + min_margin_pct: number; + max_ticket_eur: number | null; + zones: string[] | null; + strategies: string[] | null; + notes: string | null; + created_at: string; + updated_at: string; +} + +export type DealGrade = 'A' | 'B' | 'C'; + +export interface DealSourceRow { + id: string; + user_id: string; + title: string; + description: string | null; + source_url: string | null; + source_name: string | null; + price_eur: number | null; + surface_m2: number; + price_per_m2_eur: number; + dvf_avg_m2_simulated: number; + distress_keywords: string[]; + opportunity_score: number; + grade: DealGrade; + raw_payload?: Record | null; + created_at: string; +} + +export interface LocalDbSnapshot { + dossiers: DossierRow[]; + dossier_visit_findings: DossierVisitFindingRow[]; + investisseurs: InvestisseurRow[]; + deals_sources: DealSourceRow[]; +} diff --git a/src/data/visitWorks.ts b/src/data/visitWorks.ts new file mode 100644 index 0000000..73f48f3 --- /dev/null +++ b/src/data/visitWorks.ts @@ -0,0 +1,95 @@ +import type { + DossierVisitFindingRow, + VisitFindingDefinitionRow, +} from './types'; + +export const VISIT_FINDING_SEED: VisitFindingDefinitionRow[] = [ + { + code: 'structural_crack', + label: 'Fissure structurelle / désordre porteur', + default_works_delta_eur: 15000, + severity: 5, + sort_order: 10, + }, + { + code: 'roof_full_replace', + label: 'Toiture à refaire (complète)', + default_works_delta_eur: 35000, + severity: 5, + sort_order: 20, + }, + { + code: 'roof_partial', + label: 'Toiture partielle / zinguerie lourde', + default_works_delta_eur: 8000, + severity: 3, + sort_order: 30, + }, + { + code: 'humidity_basement', + label: 'Infiltrations cave / vide sanitaire', + default_works_delta_eur: 12000, + severity: 3, + sort_order: 40, + }, + { + code: 'electrical_rewire', + label: 'Rénovation électrique complète', + default_works_delta_eur: 15000, + severity: 4, + sort_order: 50, + }, + { + code: 'asbestos', + label: 'Présence amiante / désamiantage à prévoir', + default_works_delta_eur: 10000, + severity: 4, + sort_order: 60, + }, + { + code: 'septic_non_conform', + label: 'Assainissement non conforme', + default_works_delta_eur: 12000, + severity: 3, + sort_order: 70, + }, + { + code: 'facade_insulation', + label: 'ITE / ravalement lourd', + default_works_delta_eur: 25000, + severity: 3, + sort_order: 80, + }, + { + code: 'heat_pump_full', + label: 'Chauffage à refaire (pompe à chaleur + réseau)', + default_works_delta_eur: 18000, + severity: 2, + sort_order: 90, + }, +]; + +export function definitionMap( + defs: VisitFindingDefinitionRow[], +): Map { + return new Map(defs.map((d) => [d.code, d])); +} + +export function sumCheckedVisitWorksEUR( + findings: DossierVisitFindingRow[], + defs: VisitFindingDefinitionRow[], +): number { + const map = definitionMap(defs); + let sum = 0; + for (const f of findings) { + if (!f.checked) continue; + const def = map.get(f.finding_code); + if (!def) continue; + const delta = + f.works_delta_override_eur != null + ? f.works_delta_override_eur + : def.default_works_delta_eur; + sum += delta; + } + return sum; +} diff --git a/src/domain/dossier.ts b/src/domain/dossier.ts index 3ac9b94..0f1bf3f 100644 --- a/src/domain/dossier.ts +++ b/src/domain/dossier.ts @@ -1,5 +1,7 @@ import type { DealTrafficLight } from './dealSignals'; +export type { DealTrafficLight } from './dealSignals'; + /** Statuts alignés sur `public.dossier_status` (Supabase). */ export type DossierStatus = | 'draft' @@ -36,5 +38,3 @@ export interface DossierDealSnapshot { dvfDiscountPct: number | null; scoreDeal: number; } - -export type { DealTrafficLight }; diff --git a/src/domain/mapDossierToJuge.ts b/src/domain/mapDossierToJuge.ts new file mode 100644 index 0000000..e0eaf38 --- /dev/null +++ b/src/domain/mapDossierToJuge.ts @@ -0,0 +1,44 @@ +import type { DossierFinancialInputs } from './dossier'; +import type { DossierRow } from '../data/types'; +import type { JugeInputs } from '../core/juge/marginCalculator'; + +export function dossierFinancialToJugeInput( + row: DossierFinancialInputs, + checklistWorksExtraEur = 0, +): JugeInputs { + return { + purchasePrice: row.purchasePriceTarget, + resalePrice: row.resalePriceEstimate, + surfaceM2: row.surfaceM2, + dvfReferencePriceM2: row.dvfReferencePriceM2 ?? undefined, + worksTotal: + row.worksEstimateTotal + + row.worksVisitAdjustment + + checklistWorksExtraEur, + notaryFeeRate: row.notaryFeeRate, + saleAgencyFeeRate: row.saleAgencyFeeRate, + miscAcquisitionCost: row.miscAcquisitionCost, + miscSaleCost: row.miscSaleCost, + carryingMonths: row.carryingMonths, + carryingAnnualRate: row.carryingAnnualRate, + carryingPrincipal: row.carryingPrincipal ?? row.purchasePriceTarget, + }; +} + +export function dossierRowToFinancialInputs(row: DossierRow): DossierFinancialInputs { + return { + purchasePriceTarget: row.purchase_price_target ?? 0, + resalePriceEstimate: row.resale_price_estimate ?? 0, + surfaceM2: row.surface_m2 ?? 0, + dvfReferencePriceM2: row.dvf_reference_price_m2, + worksEstimateTotal: row.works_estimate_total ?? 0, + worksVisitAdjustment: row.works_visit_adjustment ?? 0, + notaryFeeRate: row.notary_fee_rate ?? 0.077, + saleAgencyFeeRate: row.sale_agency_fee_rate ?? 0.05, + miscAcquisitionCost: row.misc_acquisition_cost ?? 0, + miscSaleCost: row.misc_sale_cost ?? 0, + carryingMonths: row.carrying_months ?? 6, + carryingAnnualRate: row.carrying_annual_rate ?? 0.05, + carryingPrincipal: row.carrying_principal, + }; +} diff --git a/src/hooks/useDealsSourcesGradeAAlerts.ts b/src/hooks/useDealsSourcesGradeAAlerts.ts new file mode 100644 index 0000000..c4a18cf --- /dev/null +++ b/src/hooks/useDealsSourcesGradeAAlerts.ts @@ -0,0 +1,74 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import * as Notifications from 'expo-notifications'; +import { useEffect } from 'react'; +import type { DealSourceRow } from '../data/types'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export async function ensureNotificationPermission(): Promise { + const { status: existing } = await Notifications.getPermissionsAsync(); + if (existing === 'granted') return true; + const { status } = await Notifications.requestPermissionsAsync(); + return status === 'granted'; +} + +export function notifyGradeADealLocal(title: string): void { + void Notifications.scheduleNotificationAsync({ + content: { + title: 'Opportunité Grade A', + body: title.slice(0, 160), + }, + trigger: null, + }); +} + +/** + * Abonnement Realtime : alerte locale quand une ligne Grade A est insérée (Supabase). + * Activer Realtime sur `deals_sources` dans le dashboard Supabase. + */ +export function useDealsSourcesGradeAAlerts( + supabase: SupabaseClient | null, + userId: string | undefined, + enabled: boolean, +): void { + useEffect(() => { + if (!enabled || !supabase || !userId) return; + + const channel = supabase + .channel(`realtime:deals_sources:${userId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'deals_sources', + filter: `user_id=eq.${userId}`, + }, + (payload) => { + const row = payload.new as DealSourceRow; + if (row?.grade === 'A') { + void Notifications.scheduleNotificationAsync({ + content: { + title: 'Nouvelle opportunité Grade A', + body: (row.title ?? 'Deal').slice(0, 160), + }, + trigger: null, + }); + } + }, + ) + .subscribe(); + + return () => { + void supabase.removeChannel(channel); + }; + }, [supabase, userId, enabled]); +} diff --git a/src/hooks/useDossierJuge.ts b/src/hooks/useDossierJuge.ts new file mode 100644 index 0000000..7b903cf --- /dev/null +++ b/src/hooks/useDossierJuge.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import type { DossierRow, DossierVisitFindingRow, VisitFindingDefinitionRow } from '../data/types'; +import { sumCheckedVisitWorksEUR } from '../data/visitWorks'; +import { evaluateDeal, maxPurchaseForTargetNetMarginPct, MIN_NET_MARGIN_PCT } from '../core/juge'; +import { + dossierFinancialToJugeInput, + dossierRowToFinancialInputs, +} from '../domain/mapDossierToJuge'; + +export function useDossierJuge( + dossier: DossierRow | undefined, + findings: DossierVisitFindingRow[], + definitions: VisitFindingDefinitionRow[], +) { + return useMemo(() => { + if (!dossier) return null; + const checklist = sumCheckedVisitWorksEUR(findings, definitions); + const fin = dossierRowToFinancialInputs(dossier); + const jugeInput = dossierFinancialToJugeInput(fin, checklist); + const result = evaluateDeal(jugeInput); + const maxPurchase = maxPurchaseForTargetNetMarginPct( + { + resalePrice: jugeInput.resalePrice, + surfaceM2: jugeInput.surfaceM2, + dvfReferencePriceM2: jugeInput.dvfReferencePriceM2, + worksTotal: jugeInput.worksTotal, + notaryFeeRate: jugeInput.notaryFeeRate, + saleAgencyFeeRate: jugeInput.saleAgencyFeeRate, + miscAcquisitionCost: jugeInput.miscAcquisitionCost, + miscSaleCost: jugeInput.miscSaleCost, + carryingMonths: jugeInput.carryingMonths, + carryingAnnualRate: jugeInput.carryingAnnualRate, + carryingPrincipal: jugeInput.carryingPrincipal, + }, + MIN_NET_MARGIN_PCT, + ); + return { result, maxPurchase, checklistWorks: checklist }; + }, [dossier, findings, definitions]); +} diff --git a/src/lib/supabaseFactory.ts b/src/lib/supabaseFactory.ts new file mode 100644 index 0000000..0174803 --- /dev/null +++ b/src/lib/supabaseFactory.ts @@ -0,0 +1,14 @@ +import 'react-native-url-polyfill/auto'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + +export function makeSupabase(url: string, anonKey: string): SupabaseClient { + return createClient(url, anonKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, + }); +} diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 0000000..52c997e --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,7 @@ +export function randomUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/src/services/matchInvestors.ts b/src/services/matchInvestors.ts new file mode 100644 index 0000000..9de2dba --- /dev/null +++ b/src/services/matchInvestors.ts @@ -0,0 +1,37 @@ +import type { DossierRow, InvestisseurRow } from '../data/types'; +import type { JugeResult } from '../core/juge'; + +export function matchInvestisseurs( + dossier: DossierRow, + juge: JugeResult, + list: InvestisseurRow[], + limit = 5, +): InvestisseurRow[] { + const purchase = dossier.purchase_price_target ?? 0; + const marginPct100 = juge.netMarginPct * 100; + const city = (dossier.city ?? '').toLowerCase().trim(); + + return list + .filter((inv) => { + if (marginPct100 + 1e-6 < inv.min_margin_pct) { + return false; + } + if ( + inv.max_ticket_eur != null && + purchase > 0 && + purchase > inv.max_ticket_eur + ) { + return false; + } + const zones = inv.zones; + if (zones && zones.length > 0 && city) { + const z = zones.map((x) => x.toLowerCase().trim()); + const ok = z.some( + (zone) => city.includes(zone) || zone.includes(city), + ); + if (!ok) return false; + } + return true; + }) + .slice(0, limit); +} diff --git a/src/services/teaserPdf.ts b/src/services/teaserPdf.ts new file mode 100644 index 0000000..403d6fc --- /dev/null +++ b/src/services/teaserPdf.ts @@ -0,0 +1,81 @@ +import type { DossierRow } from '../data/types'; +import type { JugeResult } from '../core/juge'; +import * as Print from 'expo-print'; +import * as Sharing from 'expo-sharing'; +import { Platform } from 'react-native'; + +function escapeHtml(s: string): string { + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function buildTeaserHtml( + dossier: DossierRow, + juge: JugeResult, + investisseurs: string[], +): string { + const title = escapeHtml(dossier.title); + const addr = escapeHtml( + [dossier.address_line, dossier.postal_code, dossier.city] + .filter(Boolean) + .join(', ') || 'Adresse à compléter', + ); + const bullets = investisseurs + .map((n) => `
  • ${escapeHtml(n)}
  • `) + .join(''); + + return ` + + + + + + +
    MDB-Turbo — Teaser investisseur
    +

    ${title}

    +

    ${addr}

    +
    +
    Marge nette (estim.)
    ${(juge.netMarginPct * 100).toFixed(1)} %
    +
    Score deal
    ${juge.scoreDeal} / 100
    +
    Prix / m² (achat cible)
    ${Math.round(juge.purchasePricePerM2).toLocaleString('fr-FR')} €
    +
    Break-even revente
    ${Number.isFinite(juge.breakEvenResalePrice) ? `${Math.round(juge.breakEvenResalePrice).toLocaleString('fr-FR')} €` : '—'}
    +
    +

    Investisseurs ciblés (matching critères)

    +
      ${bullets || '
    • (aucun match — complétez la base investisseurs)
    • '}
    + + +`; +} + +export async function shareTeaserPdf( + dossier: DossierRow, + juge: JugeResult, + investisseurNames: string[], +): Promise { + const html = buildTeaserHtml(dossier, juge, investisseurNames); + const { uri } = await Print.printToFileAsync({ html }); + if (Platform.OS === 'web') { + return; + } + const can = await Sharing.isAvailableAsync(); + if (!can) { + return; + } + await Sharing.shareAsync(uri, { + mimeType: 'application/pdf', + dialogTitle: 'Teaser investisseur', + }); +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts new file mode 100644 index 0000000..f979a6b --- /dev/null +++ b/src/theme/colors.ts @@ -0,0 +1,12 @@ +export const colors = { + bg: '#0b1220', + bgCard: '#121c2f', + border: '#243352', + text: '#f4f7ff', + textMuted: '#9fb0d0', + accent: '#3d8bfd', + success: '#3fb950', + warning: '#d29922', + danger: '#f85149', + flash: '#7ee787', +}; diff --git a/supabase/migrations/20260429180000_mdb_turbo_dossiers.sql b/supabase/migrations/20260429180000_mdb_turbo_dossiers.sql index 5bbae6c..9dc1a67 100644 --- a/supabase/migrations/20260429180000_mdb_turbo_dossiers.sql +++ b/supabase/migrations/20260429180000_mdb_turbo_dossiers.sql @@ -156,17 +156,17 @@ $$; drop trigger if exists set_profiles_updated_at on public.profiles; create trigger set_profiles_updated_at before update on public.profiles -for each row execute function public.set_updated_at(); +for each row execute procedure public.set_updated_at(); drop trigger if exists set_dossiers_updated_at on public.dossiers; create trigger set_dossiers_updated_at before update on public.dossiers -for each row execute function public.set_updated_at(); +for each row execute procedure public.set_updated_at(); drop trigger if exists set_investisseurs_updated_at on public.investisseurs; create trigger set_investisseurs_updated_at before update on public.investisseurs -for each row execute function public.set_updated_at(); +for each row execute procedure public.set_updated_at(); -- --------------------------------------------------------------------------- -- RLS @@ -218,7 +218,7 @@ begin end; $$; -drop trigger if exists on_auth_user_created on auth.users; -create trigger on_auth_user_created +drop trigger if exists mdb_on_auth_user_created on auth.users; +create trigger mdb_on_auth_user_created after insert on auth.users -for each row execute function public.handle_new_user(); +for each row execute procedure public.handle_new_user(); diff --git a/supabase/migrations/20260429200000_deals_sources_scout.sql b/supabase/migrations/20260429200000_deals_sources_scout.sql new file mode 100644 index 0000000..c07da3b --- /dev/null +++ b/supabase/migrations/20260429200000_deals_sources_scout.sql @@ -0,0 +1,154 @@ +-- Flux opportunités aspirées + agent Scout (batch JSON côté Postgres) + +create table if not exists public.deals_sources ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + title text not null, + description text, + source_url text, + source_name text, + price_eur numeric(14, 2), + surface_m2 numeric(12, 2) not null, + price_per_m2_eur numeric(14, 2) not null, + dvf_avg_m2_simulated numeric(14, 2) not null default 3500, + distress_keywords text[] not null default '{}', + opportunity_score numeric(6, 2) not null, + grade text not null check (grade in ('A', 'B', 'C')), + raw_payload jsonb, + created_at timestamptz not null default now() +); + +create index if not exists deals_sources_user_score_idx + on public.deals_sources (user_id, opportunity_score desc); +create index if not exists deals_sources_user_created_idx + on public.deals_sources (user_id, created_at desc); + +alter table public.profiles add column if not exists expo_push_token text; + +-- Activer Realtime sur cette table : Dashboard Supabase → Realtime → ajouter public.deals_sources + +alter table public.deals_sources enable row level security; + +create policy deals_sources_select_own on public.deals_sources + for select to authenticated + using (auth.uid() = user_id); + +-- Pas d’INSERT direct : uniquement via la fonction SECURITY DEFINER ci-dessous. + +create or replace function public.scout_process_batch(p_listings jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + uid uuid := auth.uid(); + el jsonb; + txt text; + price numeric; + surf numeric; + pm2 numeric; + avg_m2 constant numeric := 3500; + keywords text[] := array['succession', 'urgent', 'travaux important']; + k text; + matched text[]; + ok_kw boolean; + ok_pm2 boolean; + score numeric; + grade text; + n int := 0; + na int := 0; +begin + if uid is null then + raise exception 'scout_process_batch: non authentifié'; + end if; + + for el in select * from jsonb_array_elements(coalesce(p_listings, '[]'::jsonb)) + loop + txt := lower( + coalesce(el ->> 'description', '') || ' ' || coalesce(el ->> 'title', '') + ); + price := nullif(trim(el ->> 'price_eur'), '')::numeric; + surf := nullif(trim(el ->> 'surface_m2'), '')::numeric; + + if price is null or surf is null or surf <= 0 then + continue; + end if; + + pm2 := price / surf; + matched := array[]::text[]; + ok_kw := false; + + foreach k in array keywords + loop + if strpos(txt, k) > 0 then + ok_kw := true; + matched := array_append(matched, k); + end if; + end loop; + + ok_pm2 := pm2 < avg_m2; + + if not (ok_kw and ok_pm2) then + continue; + end if; + + score := 40::numeric + + greatest(0::numeric, (avg_m2 - pm2) / nullif(avg_m2, 0) * 50) + + coalesce(array_length(matched, 1), 0) * 10; + + grade := case + when score >= 80 then 'A' + when score >= 55 then 'B' + else 'C' + end; + + insert into public.deals_sources ( + user_id, + title, + description, + source_url, + source_name, + price_eur, + surface_m2, + price_per_m2_eur, + dvf_avg_m2_simulated, + distress_keywords, + opportunity_score, + grade, + raw_payload + ) + values ( + uid, + coalesce(nullif(trim(el ->> 'title'), ''), 'Sans titre'), + el ->> 'description', + nullif(trim(el ->> 'url'), ''), + nullif(trim(el ->> 'source'), ''), + price, + surf, + pm2, + avg_m2, + matched, + score, + grade, + el + ); + + n := n + 1; + if grade = 'A' then + na := na + 1; + end if; + end loop; + + return jsonb_build_object( + 'inserted_count', n, + 'grade_a_count', na, + 'simulated_dvf_avg_m2', avg_m2 + ); +end; +$$; + +grant execute on function public.scout_process_batch(jsonb) to authenticated; + +comment on function public.scout_process_batch(jsonb) is + 'Filtre un lot JSON d''annonces (mots-clés détresse + prix/m² < moyenne simulée) et insère dans deals_sources.';