> ## Documentation Index
> Fetch the complete documentation index at: https://developers.ligdicash.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Guide mobile : React Native et Flutter

> Intégrer LigdiCash dans une application mobile React Native ou Flutter : architecture, WebView, gestion du retour et confirmation côté backend.

Ce guide explique comment intégrer LigdiCash dans une application mobile. Il couvre l'architecture recommandée, l'ouverture de la page de paiement via WebView, la détection du retour utilisateur et la confirmation du paiement côté backend.

**Pré-requis :** vous avez un backend exposant une route d'initiation du paiement et une route de statut. Ne jamais appeler l'API LigdiCash directement depuis l'application mobile. Voir [Architecture recommandée](/guides/architecture-recommandee).

## Architecture mobile

```
App mobile
    |
    | POST /api/paiement/initier
    ↓
Votre backend   →   LigdiCash
    |
    | { pay_url, transaction_id }
    ↓
App mobile
    |
    | Ouvre pay_url dans une WebView native
    ↓
Page de paiement LigdiCash
    |
    | Redirige vers return_url ou cancel_url
    ↓
WebView détecte l'URL → ferme la WebView
    |
    | GET /api/paiement/:id/statut
    ↓
Votre backend (interroge LigdiCash si needed)
    |
    | { statut: "completed" | "pending" | "notcompleted" }
    ↓
App mobile — affiche le résultat
```

<Warning>
  Ne jamais ouvrir l'URL LigdiCash dans le navigateur système. Cela fait quitter l'application et rend le retour impossible à détecter proprement. Utilisez une WebView native ou, en fallback acceptable, `SFSafariViewController` / Chrome Custom Tabs avec deep link.
</Warning>

## React Native

### Installation

```bash theme={null}
npm install react-native-webview
# iOS uniquement
npx pod-install
```

### Composant WebView de paiement

```jsx React Native theme={null}
import { useState } from "react";
import { ActivityIndicator, StyleSheet, View, Alert } from "react-native";
import { WebView } from "react-native-webview";

/**
 * @param {string} paymentUrl   - URL retournée par votre backend (response_text LigdiCash)
 * @param {Function} onSuccess  - Appelé quand return_url est détectée
 * @param {Function} onCancel   - Appelé quand cancel_url est détectée
 */
export function PaiementWebView({ paymentUrl, onSuccess, onCancel }) {
  const [loading, setLoading] = useState(true);

  const RETURN_URL = "https://monapp.com/paiement/succes";
  const CANCEL_URL = "https://monapp.com/paiement/annule";

  const handleNavigationChange = (navState) => {
    const { url } = navState;
    if (url.startsWith(RETURN_URL)) onSuccess();
    else if (url.startsWith(CANCEL_URL)) onCancel();
  };

  return (
    <View style={styles.container}>
      <WebView
        source={{ uri: paymentUrl }}
        onNavigationStateChange={handleNavigationChange}
        onLoadStart={() => setLoading(true)}
        onLoadEnd={() => setLoading(false)}
        onError={() => Alert.alert("Erreur", "Impossible de charger la page de paiement.")}
        javaScriptEnabled
      />
      {loading && (
        <ActivityIndicator style={styles.loader} size="large" color="#FF6B00" />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  loader: { position: "absolute", alignSelf: "center", top: "50%" },
});
```

### Intégration dans un écran de paiement

```jsx React Native theme={null}
import { useState } from "react";
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from "react-native";
import { PaiementWebView } from "./PaiementWebView";

export function EcranPaiement({ route, navigation }) {
  const { commandeId, montant, description } = route.params;

  const [payUrl, setPayUrl] = useState(null);
  const [transactionId, setTransactionId] = useState(null);
  const [statut, setStatut] = useState("idle"); // idle | loading | webview | verifying | done

  const initierPaiement = async () => {
    setStatut("loading");

    const res = await fetch("https://api.monapp.com/api/paiement/initier", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ commandeId, montant, description }),
    }).then((r) => r.json());

    if (!res.pay_url) {
      setStatut("idle");
      Alert.alert("Erreur", "Impossible d'initier le paiement. Réessayez.");
      return;
    }

    setTransactionId(res.transaction_id);
    setPayUrl(res.pay_url);
    setStatut("webview");
  };

  const verifierPaiement = async () => {
    setStatut("verifying");

    const res = await fetch(
      `https://api.monapp.com/api/paiement/${transactionId}/statut`
    ).then((r) => r.json());

    setStatut("done");

    if (res.statut === "completed") {
      navigation.replace("CommandeConfirmee", { commandeId });
    } else if (res.statut === "notcompleted") {
      navigation.replace("PaiementEchoue", { commandeId });
    } else {
      // pending : le callback n'est pas encore arrivé
      navigation.replace("PaiementEnAttente", { transactionId });
    }
  };

  if (statut === "webview") {
    return (
      <PaiementWebView
        paymentUrl={payUrl}
        onSuccess={verifierPaiement}
        onCancel={() => setStatut("idle")}
      />
    );
  }

  if (statut === "verifying") {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" color="#FF6B00" />
        <Text style={styles.text}>Vérification du paiement…</Text>
      </View>
    );
  }

  return (
    <View style={styles.center}>
      <Text style={styles.montant}>{montant.toLocaleString()} XOF</Text>
      <Text style={styles.desc}>{description}</Text>
      <TouchableOpacity
        style={styles.bouton}
        onPress={initierPaiement}
        disabled={statut === "loading"}
      >
        {statut === "loading" ? (
          <ActivityIndicator color="#fff" />
        ) : (
          <Text style={styles.boutonTexte}>Payer maintenant</Text>
        )}
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
  montant: { fontSize: 32, fontWeight: "bold", marginBottom: 8 },
  desc: { fontSize: 16, color: "#666", marginBottom: 32 },
  bouton: {
    backgroundColor: "#FF6B00",
    paddingHorizontal: 32,
    paddingVertical: 16,
    borderRadius: 8,
    minWidth: 200,
    alignItems: "center",
  },
  boutonTexte: { color: "#fff", fontSize: 18, fontWeight: "600" },
  text: { marginTop: 16, fontSize: 16, color: "#666" },
});
```

## Flutter

### Dépendance

```yaml pubspec.yaml theme={null}
dependencies:
  webview_flutter: ^4.0.0
  http: ^1.0.0
```

### Widget WebView de paiement

```dart Flutter theme={null}
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class PaiementWebView extends StatefulWidget {
  final String paymentUrl;
  final VoidCallback onSuccess;
  final VoidCallback onCancel;

  const PaiementWebView({
    required this.paymentUrl,
    required this.onSuccess,
    required this.onCancel,
    super.key,
  });

  @override
  State<PaiementWebView> createState() => _PaiementWebViewState();
}

class _PaiementWebViewState extends State<PaiementWebView> {
  late final WebViewController _controller;
  bool _loading = true;

  static const _returnUrl = "https://monapp.com/paiement/succes";
  static const _cancelUrl = "https://monapp.com/paiement/annule";

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onPageStarted: (_) => setState(() => _loading = true),
        onPageFinished: (_) => setState(() => _loading = false),
        onNavigationRequest: (request) {
          if (request.url.startsWith(_returnUrl)) {
            widget.onSuccess();
            return NavigationDecision.prevent;
          }
          if (request.url.startsWith(_cancelUrl)) {
            widget.onCancel();
            return NavigationDecision.prevent;
          }
          return NavigationDecision.navigate;
        },
      ))
      ..loadRequest(Uri.parse(widget.paymentUrl));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Paiement"),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: widget.onCancel,
        ),
      ),
      body: Stack(
        children: [
          WebViewWidget(controller: _controller),
          if (_loading) const Center(child: CircularProgressIndicator()),
        ],
      ),
    );
  }
}
```

### Écran de paiement complet

```dart Flutter theme={null}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'paiement_webview.dart';

class EcranPaiement extends StatefulWidget {
  final String commandeId;
  final int montant;
  final String description;

  const EcranPaiement({
    required this.commandeId,
    required this.montant,
    required this.description,
    super.key,
  });

  @override
  State<EcranPaiement> createState() => _EcranPaiementState();
}

class _EcranPaiementState extends State<EcranPaiement> {
  String _statut = "idle";
  String? _payUrl;
  String? _transactionId;

  Future<void> _initierPaiement() async {
    setState(() => _statut = "loading");

    final res = await http.post(
      Uri.parse("https://api.monapp.com/api/paiement/initier"),
      headers: {"Content-Type": "application/json"},
      body: jsonEncode({
        "commandeId": widget.commandeId,
        "montant": widget.montant,
        "description": widget.description,
      }),
    );

    final data = jsonDecode(res.body) as Map<String, dynamic>;

    if (data["pay_url"] == null) {
      setState(() => _statut = "idle");
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text("Impossible d'initier le paiement.")),
        );
      }
      return;
    }

    setState(() {
      _payUrl = data["pay_url"] as String;
      _transactionId = data["transaction_id"] as String;
      _statut = "webview";
    });
  }

  Future<void> _verifierPaiement() async {
    setState(() => _statut = "verifying");

    final res = await http.get(
      Uri.parse("https://api.monapp.com/api/paiement/$_transactionId/statut"),
    );

    final data = jsonDecode(res.body) as Map<String, dynamic>;
    final statut = data["statut"] as String?;

    if (!mounted) return;

    if (statut == "completed") {
      Navigator.of(context).pushReplacementNamed(
        "/commande-confirmee",
        arguments: widget.commandeId,
      );
    } else if (statut == "notcompleted") {
      Navigator.of(context).pushReplacementNamed("/paiement-echoue");
    } else {
      // pending — le callback n'est pas encore arrivé
      Navigator.of(context).pushReplacementNamed(
        "/paiement-en-attente",
        arguments: _transactionId,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_statut == "webview") {
      return PaiementWebView(
        paymentUrl: _payUrl!,
        onSuccess: _verifierPaiement,
        onCancel: () => setState(() => _statut = "idle"),
      );
    }

    if (_statut == "verifying") {
      return const Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              CircularProgressIndicator(),
              SizedBox(height: 16),
              Text("Vérification du paiement…"),
            ],
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(title: const Text("Paiement")),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                "${widget.montant.toString()} XOF",
                style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 8),
              Text(widget.description, style: const TextStyle(color: Colors.grey)),
              const SizedBox(height: 32),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: _statut == "loading" ? null : _initierPaiement,
                  style: ElevatedButton.styleFrom(
                    backgroundColor: const Color(0xFFFF6B00),
                    padding: const EdgeInsets.symmetric(vertical: 16),
                  ),
                  child: _statut == "loading"
                      ? const CircularProgressIndicator(color: Colors.white)
                      : const Text(
                          "Payer maintenant",
                          style: TextStyle(fontSize: 18, color: Colors.white),
                        ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

## Gérer l'état "pending"

Quand le callback n'est pas encore arrivé au moment où l'utilisateur revient dans l'application, la transaction est `pending`. Deux approches :

<Tabs>
  <Tab title="Polling côté app">
    Affichez un écran « Vérification en cours » et interrogez votre backend toutes les 3 secondes pendant 30 secondes maximum.

    ```dart Flutter — polling theme={null}
    Future<String> attendreStatut(String transactionId) async {
      for (var i = 0; i < 10; i++) {
        await Future.delayed(const Duration(seconds: 3));

        final res = await http.get(
          Uri.parse("https://api.monapp.com/api/paiement/$transactionId/statut"),
        );
        final statut = jsonDecode(res.body)["statut"] as String;

        if (statut != "pending") return statut;
      }
      return "pending"; // toujours pending après 30s
    }
    ```
  </Tab>

  <Tab title="Notification push">
    Votre backend reçoit le callback LigdiCash et envoie une notification push à l'application via FCM ou APNs. L'application rafraîchit l'écran à la réception.

    C'est l'approche la plus propre : elle évite le polling et garantit que l'utilisateur voit le résultat dès que LigdiCash confirme, même s'il a mis l'application en arrière-plan.
  </Tab>
</Tabs>

## Points de vigilance

<Warning>
  La détection de `return_url` ou `cancel_url` dans la WebView est un signal d'interface, pas une preuve de paiement. Confirmez toujours le statut via votre backend avant d'afficher une confirmation à l'utilisateur.
</Warning>

<Note>
  Sur iOS, `WKWebView` (utilisé par `react-native-webview` et `webview_flutter`) bloque les requêtes vers des URLs HTTP si App Transport Security est activé. Utilisez des URLs HTTPS pour votre `return_url` et `cancel_url`.
</Note>

<Tip>
  Passez le `transaction_id` dans les paramètres de query de `return_url` et `cancel_url` (`?txn=txn_abc123`) pour l'extraire facilement dans la WebView sans avoir à le stocker en état global.
</Tip>

## Pages associées

* [Intégration mobile native (payin redirect)](/api-paiement/payin-redirect/integration-mobile) — WebView détaillée pour iOS (Swift) et Android (Kotlin)
* [Architecture recommandée](/guides/architecture-recommandee) — structure backend proxy
* [Sécurisation du callback](/api-paiement/callback/securisation) — re-vérification côté serveur
* [Guide e-commerce](/guides/quickstart-ecommerce) — le même flux côté web
