hooks/useNotifications.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
Concepts détectés — comprends la théorie
Hooks React
10 occurrencesCe fichier utilise des hooks React. Les hooks sont la façon moderne de gérer l'état et les effets dans React. Voir l'architecture mobile pour le pattern complet.
Voir l'article général
Routing Expo (mobile)
1 occurrenceCe fichier utilise le routing Expo (file-based). Convention : `_layout.tsx` pour les layouts, `[param].tsx` pour les routes dynamiques.
Voir l'article général
UI React Native
1 occurrenceComposants UI React Native (View, Text, etc.). Différents de l'HTML web — on rend du natif.
Voir l'article général
1 export
useNotifications
Code source· typescript· tronqué à 200 lignes sur 221
import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useAuthStore } from "@/store/authStore";
import { useGerantStore } from "@/store/gerantStore";
import { apiPost } from "@/lib/api";
import { API_MOBILE } from "@/lib/constants";
import { captureError } from "@/lib/sentry";
// DEC-185 + BUG-118 : expo-notifications throw "Push removed from Expo Go SDK 53+"
// au moment de l'import (avant tout useEffect). On le require uniquement
// hors Expo Go pour silence complet des logs en dev.
const isExpoGo = Constants.executionEnvironment === "storeClient";
type NotificationsModule = typeof import("expo-notifications");
const Notifications: NotificationsModule | null = (() => {
if (isExpoGo) return null;
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("expo-notifications") as NotificationsModule;
})();
// Foreground handler — affiche bannière + son + reset badge même app ouverte.
// iOS 14+ utilise Banner+List ; `shouldShowAlert` deprecated SDK 53+.
if (Notifications) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
type NotifData = Record<string, unknown> | undefined;
/**
* Route une notif tappée → bonne destination. Extrait pour réutilisation
* cold start (`getLastNotificationResponseAsync`) ET warm (listener).
*/
function routeNotificationTap(
data: NotifData,
helpers: {
activerModeGerant: () => void;
setTab: (tab: string) => void;
setSelectedCommande: (id: string | null) => void;
},
) {
if (!data) return;
// Gérant : nouvelle commande → mode gérant + activité
if (data.type === "commande" && typeof data.commandeId === "string") {
helpers.activerModeGerant();
helpers.setTab("activite");
helpers.setSelectedCommande(data.commandeId);
router.push("/(tabs)/compte" as never);
return;
}
// Client : commande/paiement → onglet Activité
if (
data.type === "commande.confirmee"
|| data.type === "commande.statut"
|| data.type === "paiement.valide"
) {
router.push("/(tabs)/activite" as never);
return;
}
// Client : accès SUR_DEMANDE approuvé → fiche vitrine
if (data.type === "acces.approuve" && typeof data.slug === "string") {
router.push(`/vitrine/${data.slug}` as never);
return;
}
// Gérant/Client : broadcast vitrine → fiche vitrine si slug fourni
if (data.type === "broadcast" && typeof data.slug === "string") {
router.push(`/vitrine/${data.slug}` as never);
return;
}
}
/**
* Register le token push avec retry exponentiel — survit aux glitches réseau 4G BF.
* Retry 3× (500ms, 2s, 8s) puis abandon silencieux.
*/
async function registerTokenWithRetry(expoPushToken: string, attempt = 0): Promise<boolean> {
const { error } = await apiPost(API_MOBILE.PUSH_TOKENS_REGISTER, {
token: expoPushToken,
platform: Platform.OS,
});
if (!error) return true;
if (attempt >= 2) {
if (__DEV__) console.warn("[NOTIF] Token register failed after 3 attempts:", error);
return false;
}
const delay = 500 * Math.pow(4, attempt);
await new Promise((r) => setTimeout(r, delay));
return registerTokenWithRetry(expoPushToken, attempt + 1);
}
export function useNotifications() {
// WP-227 — enregistrer le token push pour TOUT user authentifié (gérant
// OU client). Le backend discrimine via session.role (BUG-120 fix).
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const token = useAuthStore((s) => s.token);
const activerModeGerant = useAuthStore((s) => s.activerModeGerant);
const setTab = useGerantStore((s) => s.setTab);
const setSelectedCommande = useGerantStore((s) => s.setSelectedCommande);
const registered = useRef(false);
useEffect(() => {
if (!isAuthenticated || !token || registered.current) return;
registered.current = true;
let cancelled = false;
// DEC-185 + BUG-118 : skip total en Expo Go (require échoue à l'import en SDK 53+)
if (!Notifications) {
if (__DEV__) console.log("[NOTIF] Skip — Expo Go ne supporte plus push remote (DEC-185, requiert EAS Dev Build)");
return;
}
const helpers = { activerModeGerant, setTab, setSelectedCommande };
(async () => {
if (!Device.isDevice) {
if (__DEV__) console.log("[NOTIF] Skip — pas un device physique");
return;
}
// Android — channel obligatoire
if (Platform.OS === "android") {
await Notifications!.setNotificationChannelAsync("default", {
name: "Commandes",
importance: Notifications!.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#C9A227",
sound: "default",
});
}
// Permissions iOS — options explicites + allowProvisional pour notifs
// silencieuses sans demander (meilleur engagement Apple HIG).
const { status: existing } = await Notifications!.getPermissionsAsync();
let final = existing;
if (existing !== "granted") {
const req = await Notifications!.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowCriticalAlerts: false,
provideAppNotificationSettings: true,
allowProvisional: true,
},
});
final = req.status;
}
if (final !== "granted") {
if (__DEV__) console.log("[NOTIF] Permission refusée");
return;
}
// Récup token Expo
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
if (__DEV__) console.warn("[NOTIF] projectId manquant dans app.json extra.eas");
return;
}
try {
const tokenData = await Notifications!.getExpoPushTokenAsync({ projectId });
const expoPushToken = tokenData.data;
if (cancelled) return;
const ok = await registerTokenWithRetry(expoPushToken);
if (__DEV__ && ok) console.log("[NOTIF] Token registered");
} catch (e) {
if (__DEV__) console.error("[NOTIF] Token error:", e);
captureError(e, { feature: 'notifications', step: 'getExpoPushTokenAsync' });
}
// Cold start — si app lancée depuis tap notif, router maintenant
try {
const last = await Notifications!.getLastNotificationResponseAsync();
if (last && !cancelled) {
const data = last.notification.request.content.data as NotifData;
routeNotificationTap(data, helpers);
// Clear badge après prise en compte du tap
await Notifications!.setBadgeCountAsync(0).catch(() => {});
}
} catch {}
})();
// APNs token rotation (réinstall, restore, key change) → re-sync backendimport { useEffect, useRef } from "react";
import { Platform } from "react-native";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useAuthStore } from "@/store/authStore";
import { useGerantStore } from "@/store/gerantStore";
import { apiPost } from "@/lib/api";
import { API_MOBILE } from "@/lib/constants";
import { captureError } from "@/lib/sentry";
// DEC-185 + BUG-118 : expo-notifications throw "Push removed from Expo Go SDK 53+"
// au moment de l'import (avant tout useEffect). On le require uniquement
// hors Expo Go pour silence complet des logs en dev.
const isExpoGo = Constants.executionEnvironment === "storeClient";
type NotificationsModule = typeof import("expo-notifications");
const Notifications: NotificationsModule | null = (() => {
if (isExpoGo) return null;
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("expo-notifications") as NotificationsModule;
})();
// Foreground handler — affiche bannière + son + reset badge même app ouverte.
// iOS 14+ utilise Banner+List ; `shouldShowAlert` deprecated SDK 53+.
if (Notifications) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
type NotifData = Record<string, unknown> | undefined;
/**
* Route une notif tappée → bonne destination. Extrait pour réutilisation
* cold start (`getLastNotificationResponseAsync`) ET warm (listener).
*/
function routeNotificationTap(
data: NotifData,
helpers: {
activerModeGerant: () => void;
setTab: (tab: string) => void;
setSelectedCommande: (id: string | null) => void;
},
) {
if (!data) return;
// Gérant : nouvelle commande → mode gérant + activité
if (data.type === "commande" && typeof data.commandeId === "string") {
helpers.activerModeGerant();
helpers.setTab("activite");
helpers.setSelectedCommande(data.commandeId);
router.push("/(tabs)/compte" as never);
return;
}
// Client : commande/paiement → onglet Activité
if (
data.type === "commande.confirmee"
|| data.type === "commande.statut"
|| data.type === "paiement.valide"
) {
router.push("/(tabs)/activite" as never);
return;
}
// Client : accès SUR_DEMANDE approuvé → fiche vitrine
if (data.type === "acces.approuve" && typeof data.slug === "string") {
router.push(`/vitrine/${data.slug}` as never);
return;
}
// Gérant/Client : broadcast vitrine → fiche vitrine si slug fourni
if (data.type === "broadcast" && typeof data.slug === "string") {
router.push(`/vitrine/${data.slug}` as never);
return;
}
}
/**
* Register le token push avec retry exponentiel — survit aux glitches réseau 4G BF.
* Retry 3× (500ms, 2s, 8s) puis abandon silencieux.
*/
async function registerTokenWithRetry(expoPushToken: string, attempt = 0): Promise<boolean> {
const { error } = await apiPost(API_MOBILE.PUSH_TOKENS_REGISTER, {
token: expoPushToken,
platform: Platform.OS,
});
if (!error) return true;
if (attempt >= 2) {
if (__DEV__) console.warn("[NOTIF] Token register failed after 3 attempts:", error);
return false;
}
const delay = 500 * Math.pow(4, attempt);
await new Promise((r) => setTimeout(r, delay));
return registerTokenWithRetry(expoPushToken, attempt + 1);
}
export function useNotifications() {
// WP-227 — enregistrer le token push pour TOUT user authentifié (gérant
// OU client). Le backend discrimine via session.role (BUG-120 fix).
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const token = useAuthStore((s) => s.token);
const activerModeGerant = useAuthStore((s) => s.activerModeGerant);
const setTab = useGerantStore((s) => s.setTab);
const setSelectedCommande = useGerantStore((s) => s.setSelectedCommande);
const registered = useRef(false);
useEffect(() => {
if (!isAuthenticated || !token || registered.current) return;
registered.current = true;
let cancelled = false;
// DEC-185 + BUG-118 : skip total en Expo Go (require échoue à l'import en SDK 53+)
if (!Notifications) {
if (__DEV__) console.log("[NOTIF] Skip — Expo Go ne supporte plus push remote (DEC-185, requiert EAS Dev Build)");
return;
}
const helpers = { activerModeGerant, setTab, setSelectedCommande };
(async () => {
if (!Device.isDevice) {
if (__DEV__) console.log("[NOTIF] Skip — pas un device physique");
return;
}
// Android — channel obligatoire
if (Platform.OS === "android") {
await Notifications!.setNotificationChannelAsync("default", {
name: "Commandes",
importance: Notifications!.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#C9A227",
sound: "default",
});
}
// Permissions iOS — options explicites + allowProvisional pour notifs
// silencieuses sans demander (meilleur engagement Apple HIG).
const { status: existing } = await Notifications!.getPermissionsAsync();
let final = existing;
if (existing !== "granted") {
const req = await Notifications!.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowCriticalAlerts: false,
provideAppNotificationSettings: true,
allowProvisional: true,
},
});
final = req.status;
}
if (final !== "granted") {
if (__DEV__) console.log("[NOTIF] Permission refusée");
return;
}
// Récup token Expo
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
if (__DEV__) console.warn("[NOTIF] projectId manquant dans app.json extra.eas");
return;
}
try {
const tokenData = await Notifications!.getExpoPushTokenAsync({ projectId });
const expoPushToken = tokenData.data;
if (cancelled) return;
const ok = await registerTokenWithRetry(expoPushToken);
if (__DEV__ && ok) console.log("[NOTIF] Token registered");
} catch (e) {
if (__DEV__) console.error("[NOTIF] Token error:", e);
captureError(e, { feature: 'notifications', step: 'getExpoPushTokenAsync' });
}
// Cold start — si app lancée depuis tap notif, router maintenant
try {
const last = await Notifications!.getLastNotificationResponseAsync();
if (last && !cancelled) {
const data = last.notification.request.content.data as NotifData;
routeNotificationTap(data, helpers);
// Clear badge après prise en compte du tap
await Notifications!.setBadgeCountAsync(0).catch(() => {});
}
} catch {}
})();
// APNs token rotation (réinstall, restore, key change) → re-sync backend