chore: add notification telegram

This commit is contained in:
2026-03-24 11:51:22 +01:00
parent a8eeb55e72
commit 79c050d689
15 changed files with 424 additions and 253 deletions
+33 -19
View File
@@ -760,6 +760,8 @@ export interface AppSettings {
nowpayments_api_key: string;
nowpayments_ipn_secret: string;
nowpayments_currencies: string[];
telegram_bot_token: string;
telegram_bot_username: string;
}
export const getSettings = async (): Promise<{
@@ -778,25 +780,6 @@ export const getSettings = async (): Promise<{
}
};
export const registerAdminPushToken = async (
pushToken: string,
): Promise<void> => {
try {
await apiClient.post(`${V2}/admin/protected/push-token`, {
push_token: pushToken,
});
} catch {
/* ignore */
}
};
export const unregisterAdminPushToken = async (): Promise<void> => {
try {
await apiClient.delete(`${V2}/admin/protected/push-token`);
} catch {
/* ignore */
}
};
export interface AppNotification {
command_id: number;
@@ -818,6 +801,37 @@ export const markAdminNotificationsRead = async (): Promise<void> => {
await apiClient.post(`${V2}/admin/protected/notifications/read`);
};
// ============================================
// TELEGRAM — ADMIN
// ============================================
export const getAdminTelegramStatus = async (): Promise<{ linked: boolean; enabled: boolean }> => {
try {
const { data } = await apiClient.get(`${V2}/admin/protected/telegram/status`);
return data;
} catch {
return { linked: false, enabled: false };
}
};
export const generateAdminLinkToken = async (): Promise<{
link_url?: string;
error?: string;
}> => {
try {
const { data } = await apiClient.post(`${V2}/admin/protected/telegram/link-token`);
return data;
} catch (e: any) {
return { error: e?.response?.data?.error || "Erreur" };
}
};
export const unlinkAdminTelegram = async (): Promise<void> => {
try {
await apiClient.delete(`${V2}/admin/protected/telegram/unlink`);
} catch { /* ignore */ }
};
export const updateSettings = async (
settings: AppSettings,
): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
+33 -18
View File
@@ -354,24 +354,6 @@ export interface PublicSettings {
pool_keys: string[];
}
export const registerCabinePushToken = async (
pushToken: string,
): Promise<void> => {
try {
await apiClient.post(`${API}/push-token`, { push_token: pushToken });
} catch {
/* ignore */
}
};
export const unregisterCabinePushToken = async (): Promise<void> => {
try {
await apiClient.delete(`${API}/push-token`);
} catch {
/* ignore */
}
};
export interface AppNotification {
command_id: number;
type: string;
@@ -454,3 +436,36 @@ export const getAllAddresses = async (): Promise<
correct_address: a.correct_address ?? a.CorrectAddress ?? "",
}));
};
// ============================================
// TELEGRAM — CABINE
// ============================================
const CABINE_API = "https://5.181.0.112.nip.io/api/v1/cabine";
export const getCabineTelegramStatus = async (): Promise<{ linked: boolean; enabled: boolean }> => {
try {
const { data } = await apiClient.get(`${CABINE_API}/telegram/status`);
return data;
} catch {
return { linked: false, enabled: false };
}
};
export const generateCabineLinkToken = async (): Promise<{
link_url?: string;
error?: string;
}> => {
try {
const { data } = await apiClient.post(`${CABINE_API}/telegram/link-token`);
return data;
} catch (e: any) {
return { error: e?.response?.data?.error || "Erreur" };
}
};
export const unlinkCabineTelegram = async (): Promise<void> => {
try {
await apiClient.delete(`${CABINE_API}/telegram/unlink`);
} catch { /* ignore */ }
};
+33
View File
@@ -310,3 +310,36 @@ export const markLivreurNotificationsRead = async (): Promise<{
};
}
};
// ============================================
// TELEGRAM — LIVREUR
// ============================================
export const getLivreurTelegramStatus = async (): Promise<{ linked: boolean; enabled: boolean }> => {
try {
const { data } = await apiClient.get(`${API}/telegram/status`);
return data;
} catch {
return { linked: false, enabled: false };
}
};
export const generateLivreurLinkToken = async (): Promise<{
link_url?: string;
expires_in?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.post(`${API}/telegram/link-token`);
return data;
} catch (e: any) {
return { error: e?.response?.data?.error || "Erreur" };
}
};
export const unlinkLivreurTelegram = async (): Promise<void> => {
try {
await apiClient.delete(`${API}/telegram/unlink`);
} catch { /* ignore */ }
};
@@ -5,6 +5,9 @@ import {
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
Linking,
Alert,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
@@ -16,10 +19,10 @@ import {
getAllClients,
getAvailableDeliveryPersons,
getCommandCountByStatus,
registerAdminPushToken,
unregisterAdminPushToken,
getAdminTelegramStatus,
generateAdminLinkToken,
unlinkAdminTelegram,
} from "../../api/api_admin";
import { getExpoPushToken } from "../../utils/pushTokenUtils";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
interface StatCard {
@@ -35,14 +38,9 @@ export default function DashboardScreen() {
const [stats, setStats] = useState<StatCard[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Enregistrement push token admin au montage
useEffect(() => {
getExpoPushToken().then((token) => {
if (token) registerAdminPushToken(token);
});
return () => { unregisterAdminPushToken(); };
}, []);
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const loadStats = useCallback(async () => {
try {
@@ -106,8 +104,27 @@ export default function DashboardScreen() {
useEffect(() => {
loadStats();
getAdminTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
}, [loadStats]);
const handleLinkTelegram = async () => {
setTgLoading(true);
const res = await generateAdminLinkToken();
setTgLoading(false);
if (res.error || !res.link_url) {
Alert.alert("Erreur", res.error || "Service Telegram non disponible");
return;
}
Linking.openURL(res.link_url);
};
const handleUnlinkTelegram = () => {
Alert.alert("Délier Telegram", "Vous ne recevrez plus de notifications Telegram.", [
{ text: "Annuler", style: "cancel" },
{ text: "Délier", style: "destructive", onPress: async () => { await unlinkAdminTelegram(); setTgLinked(false); } },
]);
};
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
@@ -202,6 +219,32 @@ export default function DashboardScreen() {
</View>
))}
</View>
{tgEnabled && (
<View style={{ margin: spacing.l, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte Telegram lié</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier Telegram</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
</TouchableOpacity>
)}
</View>
)}
</ScrollView>
);
}
@@ -659,6 +659,8 @@ export default function SettingsScreen() {
nowpayments_api_key: "",
nowpayments_ipn_secret: "",
nowpayments_currencies: [],
telegram_bot_token: "",
telegram_bot_username: "",
});
const [showApiKey, setShowApiKey] = useState(false);
const [showIpnSecret, setShowIpnSecret] = useState(false);
@@ -1300,6 +1302,53 @@ export default function SettingsScreen() {
</View>
</View>
{/* ============================================ */}
{/* 🤖 TELEGRAM */}
{/* ============================================ */}
<View style={s.section}>
<Text style={s.sectionTitle}>Notifications Telegram</Text>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
<Text style={s.rowDesc}>
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
</Text>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Token du bot</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Obtenu via @BotFather avec la commande /newbot
</Text>
<TextInput
style={[s.input, { fontFamily: "monospace" }]}
value={settings.telegram_bot_token}
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_token: v }))}
placeholder="123456:ABCdefGHI..."
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
secureTextEntry={true}
/>
</View>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Username du bot</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Sans le @, ex : MonBotNotifications
</Text>
<TextInput
style={s.input}
value={settings.telegram_bot_username}
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_username: v }))}
placeholder="MonBotNotifications"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
</View>
{settings.telegram_bot_token !== "" && settings.telegram_bot_username !== "" && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.m, backgroundColor: "#10b98122", borderRadius: borderRadius.sm }}>
<Ionicons name="checkmark-circle" size={16} color="#10b981" />
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré @{settings.telegram_bot_username}</Text>
</View>
)}
</View>
</View>
<TouchableOpacity
style={s.saveButton}
onPress={handleSave}
@@ -5,6 +5,9 @@ import {
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
Linking,
Alert,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
@@ -12,21 +15,21 @@ import { useTheme } from "../../context/ThemeContext";
import { shadows } from "../../theme/shadows";
import { useAuth } from "../../auth/AuthContext";
import { getAllCommands } from "../../api/api_admin";
import { getAllDeliveryPersonsWithDetails, registerCabinePushToken, unregisterCabinePushToken } from "../../api/api_cabine";
import { getExpoPushToken } from "../../utils/pushTokenUtils";
import {
getAllDeliveryPersonsWithDetails,
getCabineTelegramStatus,
generateCabineLinkToken,
unlinkCabineTelegram,
} from "../../api/api_cabine";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
export default function DashboardScreen() {
const { colors } = useTheme();
const { username } = useAuth();
// Enregistrement push token cabine au montage
useEffect(() => {
getExpoPushToken().then((token) => {
if (token) registerCabinePushToken(token);
});
return () => { unregisterCabinePushToken(); };
}, []);
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [stats, setStats] = useState<
Array<{
label: string;
@@ -97,7 +100,27 @@ export default function DashboardScreen() {
useEffect(() => {
loadStats();
getCabineTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
}, [loadStats]);
const handleLinkTelegram = async () => {
setTgLoading(true);
const res = await generateCabineLinkToken();
setTgLoading(false);
if (res.error || !res.link_url) {
Alert.alert("Erreur", res.error || "Service Telegram non disponible");
return;
}
Linking.openURL(res.link_url);
};
const handleUnlinkTelegram = () => {
Alert.alert("Délier Telegram", "Vous ne recevrez plus de notifications Telegram.", [
{ text: "Annuler", style: "cancel" },
{ text: "Délier", style: "destructive", onPress: async () => { await unlinkCabineTelegram(); setTgLinked(false); } },
]);
};
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
@@ -191,6 +214,32 @@ export default function DashboardScreen() {
</View>
))}
</View>
{tgEnabled && (
<View style={{ margin: spacing.l, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte Telegram lié</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier Telegram</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
</TouchableOpacity>
)}
</View>
)}
</ScrollView>
);
}
@@ -35,6 +35,9 @@ import {
startDelivery,
updateDeliveryStatus,
updateMyLocation,
getLivreurTelegramStatus,
generateLivreurLinkToken,
unlinkLivreurTelegram,
} from "../../api/api_delivery";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo } from "../../api/tomtom";
@@ -106,6 +109,11 @@ export default function DashboardScreen() {
const [detailsDelivery, setDetailsDelivery] =
useState<EnrichedDelivery | null>(null);
// Telegram
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [cancelModal, setCancelModal] = useState<{
visible: boolean;
deliveryId: number | null;
@@ -279,6 +287,7 @@ export default function DashboardScreen() {
useEffect(() => {
loadData();
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
}, [loadData]);
useEffect(() => {
@@ -741,6 +750,23 @@ export default function DashboardScreen() {
// --------------------------------------------------
// Header avec TomTomMap
// --------------------------------------------------
const handleLinkTelegram = async () => {
setTgLoading(true);
const res = await generateLivreurLinkToken();
setTgLoading(false);
if (res.error || !res.link_url) {
showError("Erreur", res.error || "Service Telegram non disponible");
return;
}
Linking.openURL(res.link_url);
};
const handleUnlinkTelegram = async () => {
await unlinkLivreurTelegram();
setTgLinked(false);
showSuccess("Telegram délié", "Vous ne recevrez plus de notifications Telegram.");
};
const renderHeader = () => (
<View>
{/* Carte TomTom — toujours montée pour que le ref soit disponible */}
@@ -863,6 +889,33 @@ export default function DashboardScreen() {
</View>
)}
{/* Carte Telegram */}
{tgEnabled && (
<View style={{ marginHorizontal: spacing.l, marginBottom: spacing.m, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte lié</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
</TouchableOpacity>
)}
</View>
)}
<Text style={styles.sectionTitle}>
Mes livraisons ({deliveries.length})
</Text>
@@ -1,38 +0,0 @@
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import { Platform } from "react-native";
/**
* Demande les permissions et retourne le token Expo push.
* Retourne null si non supporté (simulateur, refus de permission).
*/
export async function getExpoPushToken(): Promise<string | null> {
if (!Device.isDevice) return null;
const { status: existing } = await Notifications.getPermissionsAsync();
let finalStatus = existing;
if (existing !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") return null;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("orders", {
name: "Nouvelles commandes",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
sound: "default",
});
}
try {
const tokenData = await Notifications.getExpoPushTokenAsync();
return tokenData.data;
} catch {
// Google Play Services absent (ex: GrapheneOS) — push non disponible
return null;
}
}