285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import type { ReactNode } from 'react';
|
||
import { useCallback, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Linking,
|
||
Pressable,
|
||
ScrollView,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
import {
|
||
LEGI_L151_36,
|
||
LEGI_L152_6,
|
||
OFF_MARKET_KEYWORDS,
|
||
PROSPECTION_CHECKLIST,
|
||
} from '@/constants/rechercheMarche';
|
||
import { UI } from '@/constants/uiTheme';
|
||
import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
|
||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||
|
||
const LBC = 'https://www.leboncoin.fr/';
|
||
const MOTEUR = 'https://www.moteurimmo.fr/';
|
||
|
||
function Collapsible({
|
||
title,
|
||
children,
|
||
defaultOpen,
|
||
}: {
|
||
title: string;
|
||
children: ReactNode;
|
||
defaultOpen?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||
return (
|
||
<View className="mb-3 rounded-2xl border-2 bg-white" style={{ borderColor: UI.border }}>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={() => setOpen((o) => !o)}
|
||
className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
|
||
>
|
||
<Text className="flex-1 pr-2 text-lg font-bold" style={{ color: UI.text }}>
|
||
{title}
|
||
</Text>
|
||
<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={22} color={UI.textMuted} />
|
||
</Pressable>
|
||
{open ? <View className="border-t-2 px-4 pb-4 pt-2" style={{ borderColor: UI.border }}>{children}</View> : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function OpportunitesTab() {
|
||
const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
|
||
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null);
|
||
const [noteDraft, setNoteDraft] = useState('');
|
||
|
||
const persistItem = useCallback(
|
||
async (questionId: string, next: { done: boolean; note: string }) => {
|
||
try {
|
||
await saveChecklistItem({ questionId, data: next });
|
||
} catch (e) {
|
||
Alert.alert('Sauvegarde', formatPocketBaseError(e));
|
||
}
|
||
},
|
||
[saveChecklistItem],
|
||
);
|
||
|
||
const openNoteEditor = (questionId: string) => {
|
||
const st = getState(questionId);
|
||
setExpandedNoteId(questionId);
|
||
setNoteDraft(st.note);
|
||
};
|
||
|
||
const saveNoteFor = async (questionId: string) => {
|
||
const st = getState(questionId);
|
||
await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
|
||
setExpandedNoteId(null);
|
||
};
|
||
|
||
const copyText = async (text: string) => {
|
||
try {
|
||
await Clipboard.setStringAsync(text);
|
||
Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
|
||
} catch {
|
||
Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<View className="flex-1 items-center justify-center py-16">
|
||
<ActivityIndicator size="large" color={UI.primary} />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }}>
|
||
<Collapsible title="1. Mots-clés off-market">
|
||
{OFF_MARKET_KEYWORDS.map((k) => (
|
||
<View key={k.id} className="mb-3 rounded-xl border-2 p-3" style={{ borderColor: UI.border }}>
|
||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||
{k.label}
|
||
</Text>
|
||
<Text selectable className="mt-2 font-mono text-sm leading-5" style={{ color: UI.text }}>
|
||
{k.text}
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => void copyText(k.text)}
|
||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
|
||
style={{ backgroundColor: UI.primary }}
|
||
>
|
||
<Text className="text-base font-bold text-white">Copier</Text>
|
||
</Pressable>
|
||
</View>
|
||
))}
|
||
<View className="mt-2 flex-row flex-wrap gap-3">
|
||
<Pressable
|
||
onPress={() => void Linking.openURL(LBC)}
|
||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||
>
|
||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||
Leboncoin
|
||
</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => void Linking.openURL(MOTEUR)}
|
||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||
>
|
||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||
Moteur Immo
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
<View className="mt-4 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||
Astuce maison de ville
|
||
</Text>
|
||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||
Surface terrain max 100 m² + surface habitable min 150 m² → maisons sans jardin, moins de concurrence,
|
||
idéal division.
|
||
</Text>
|
||
</View>
|
||
</Collapsible>
|
||
|
||
<Collapsible title='2. Biens "fatigués" — décotes'>
|
||
<Text className="text-base leading-6" style={{ color: UI.text }}>
|
||
Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
|
||
sous prix marché.
|
||
</Text>
|
||
<View className="mt-3 self-start rounded-xl px-3 py-2" style={{ backgroundColor: '#FEE2E2' }}>
|
||
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||
−10 % à −24 %
|
||
</Text>
|
||
</View>
|
||
<Pressable
|
||
onPress={() => void Linking.openURL(MOTEUR)}
|
||
className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
|
||
style={{ backgroundColor: UI.danger }}
|
||
>
|
||
<Text className="text-center text-lg font-bold text-white">Moteur Immo — tri par ancienneté</Text>
|
||
</Pressable>
|
||
</Collapsible>
|
||
|
||
<Collapsible title="3. Vérifier la division — articles">
|
||
<View className="mb-3 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||
Article L151-36
|
||
</Text>
|
||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||
1 place de parking max par logement créé en zone bien desservie.
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => void Linking.openURL(LEGI_L151_36)}
|
||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||
>
|
||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||
Ouvrir sur Légifrance →
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
<View className="rounded-2xl border-2 p-4" style={{ borderColor: UI.success, backgroundColor: '#F0FDF4' }}>
|
||
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||
Article L152-6
|
||
</Text>
|
||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||
Dans 500 m d'une gare ou métro → division sans obligation parking.
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => void Linking.openURL(LEGI_L152_6)}
|
||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||
>
|
||
<Text className="text-base font-bold" style={{ color: UI.success }}>
|
||
Ouvrir sur Légifrance →
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
</Collapsible>
|
||
|
||
<Collapsible title="4. Confirmer avec les pros">
|
||
<Text className="mb-2 text-base" style={{ color: UI.textMuted }}>
|
||
Coche après échange ; note la réponse pour ton dossier.
|
||
</Text>
|
||
{PROSPECTION_CHECKLIST.map((item) => {
|
||
const st = getState(item.id);
|
||
const expanded = expandedNoteId === item.id;
|
||
return (
|
||
<View key={item.id} className="mb-3 rounded-xl border-2 px-3 py-3" style={{ borderColor: UI.border }}>
|
||
<Pressable
|
||
accessibilityRole="checkbox"
|
||
accessibilityState={{ checked: st.done }}
|
||
onPress={() => void persistItem(item.id, { done: !st.done, note: st.note })}
|
||
className="min-h-[48px] flex-row items-start gap-3"
|
||
>
|
||
<View
|
||
className="mt-0.5 h-8 w-8 items-center justify-center rounded-lg border-2"
|
||
style={{
|
||
borderColor: st.done ? UI.success : UI.border,
|
||
backgroundColor: st.done ? UI.success : UI.card,
|
||
}}
|
||
>
|
||
{st.done ? <Text className="font-bold text-white">✓</Text> : null}
|
||
</View>
|
||
<View className="min-w-0 flex-1">
|
||
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||
{item.label}
|
||
</Text>
|
||
<Text className="mt-1 text-base leading-6" style={{ color: UI.text }}>
|
||
{item.question}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
|
||
className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
|
||
style={{ backgroundColor: '#F1F5F9' }}
|
||
>
|
||
<Text className="text-base font-semibold" style={{ color: UI.primary }}>
|
||
{expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
|
||
</Text>
|
||
</Pressable>
|
||
{expanded ? (
|
||
<View className="mt-2">
|
||
<TextInput
|
||
className="min-h-[88px] rounded-xl border-2 px-3 py-2 text-base"
|
||
style={{ borderColor: UI.border, color: UI.text }}
|
||
multiline
|
||
textAlignVertical="top"
|
||
value={noteDraft}
|
||
onChangeText={setNoteDraft}
|
||
placeholder="Réponse du pro…"
|
||
placeholderTextColor={UI.textMuted}
|
||
/>
|
||
<Pressable
|
||
onPress={() => void saveNoteFor(item.id)}
|
||
disabled={isSaving}
|
||
className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
|
||
style={{ backgroundColor: UI.primary }}
|
||
>
|
||
{isSaving ? (
|
||
<ActivityIndicator color="#fff" />
|
||
) : (
|
||
<Text className="text-base font-bold text-white">Enregistrer la note</Text>
|
||
)}
|
||
</Pressable>
|
||
</View>
|
||
) : st.note ? (
|
||
<Text className="mt-2 text-base italic" style={{ color: UI.textMuted }}>
|
||
{st.note}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
);
|
||
})}
|
||
</Collapsible>
|
||
</ScrollView>
|
||
);
|
||
}
|