# React Native

`@mayarin/react-native` shows Mayarin's hosted checkout as a sheet that slides up from the bottom of your app. The buyer picks a product in your screens, taps Pay, and pays inside the sheet. They never leave your app, and you never handle a card or a wallet.

```tsx
import { MayarinCheckoutModal } from "@mayarin/react-native";

<MayarinCheckoutModal
  url={url}
  onSuccess={({ intentId }) => showThanks(intentId)}
  onClose={() => setUrl(undefined)}
/>;
```

The rest of this page builds a working screen\: a product list, a cart, a Pay button, and what to do when the payment settles.

## How the pieces fit

```mermaid
sequenceDiagram
    autonumber
    participant App as React Native app
    participant API as Your backend
    participant M as Mayarin

    App->>API: GET /api/products
    API->>M: commerce.products.list
    M-->>API: catalog
    API-->>App: products
    App->>API: POST /api/checkout (orderId, lines)
    API->>M: commerce.carts.checkout
    M-->>API: payment intent + checkout url
    API-->>App: { id, url }
    Note over App,M: MayarinCheckoutModal opens url in a WebView
    App->>App: onSuccess({ intentId }), a signal for the screen
    M->>API: signed webhook
    API->>M: payment.getIntent (read it back)
    API->>API: mark the order paid
```

Two rules shape all of it\:

* **The app never sets the price.** It sends product ids and quantities. Your backend holds the secret key and mints the payment, so a modified app cannot pay less.
* **`onSuccess` is a signal for your screen, not proof of payment.** Mark an order paid only from your backend, after the [webhook](/sdk/typescript/webhooks) arrives or after you read the payment back.

## 1. Install

```bash
npm install @mayarin/react-native@next react-native-webview
npx pod-install   # iOS only
```

Your backend uses [`@mayarin/sdk`](/sdk/typescript/server). The app does not need it.

Requirements\: `react` 18 or newer, `react-native` 0.73 or newer, `react-native-webview` 13 or newer. `@mayarin/sdk` installs with the package, because the sheet reads the checkout's messages through `@mayarin/sdk/embed`.

## 2. Your backend

Three routes\: the product list, the checkout, and the order status. The secret key stays here.

```ts
import { createMayarin } from "@mayarin/sdk";

const mayarin = createMayarin({
  baseUrl: "https://api.mayarin.xyz",
  secretKey: process.env.MAYARIN_SECRET_KEY!,
});

// GET /api/products — what the app lists.
export async function listProducts() {
  const products = await mayarin.commerce.products.list(MERCHANT_ID);
  return Response.json({ products });
}

// POST /api/checkout — { orderId, lines: [{ productId, quantity }] } in, { id, url } out.
export async function checkout(request: Request) {
  const { orderId, lines } = await request.json();

  const intent = await mayarin.commerce.carts.checkout(
    {
      currency: "IDR",
      lines, // [{ productId, quantity }] — prices come from your catalog, not the app
      merchantReference: orderId, // comes back on the payment and in every webhook
    },
    // One order is one payment, however many times the buyer taps Pay.
    { idempotencyKey: `order:${orderId}` },
  );

  return Response.json({ id: intent.id, url: intent.url });
}

// GET /api/orders/:orderId — what the app polls after onSuccess.
// Your order table is the source of truth; the webhook below is what sets "paid".
```

`products.list` returns each product with its `id`, `name`, `description` and `prices`. A price's `amount` is in the currency's smallest unit, so IDR `"5000000"` is Rp 50.000,00.

### Mark the order paid from the webhook

```ts
import { constructWebhook } from "@mayarin/sdk";

// POST /api/webhooks/mayarin
export async function webhook(request: Request) {
  const event = await constructWebhook({
    payload: await request.text(),
    signature: request.headers.get("webhook-signature")!,
    secret: process.env.MAYARIN_WEBHOOK_SECRET!,
  });

  // A webhook is a signal, not truth: read the payment back before you trust it.
  const intent = await mayarin.payment.getIntent(event.data.paymentIntentId);
  if (intent.status === "COMPLETED") {
    await markOrderPaid(intent.merchantReference);
  }
  return Response.json({ received: true }, { status: 202 });
}
```

See [webhooks](/sdk/typescript/webhooks) for the events and the signature header.

## 3. The screen

A product list, quantities, a total, a Pay button, and the sheet. This is the whole screen.

```tsx
import { MayarinCheckoutModal } from "@mayarin/react-native";
import { useEffect, useState } from "react";
import {
  ActivityIndicator,
  Alert,
  Button,
  FlatList,
  Text,
  View,
} from "react-native";

const API = "https://shop.example"; // your backend

interface Product {
  id: string;
  name: string;
  description: string;
  prices: { amount: string; asset: string }[];
}

// Display only. Money stays a string of minor units everywhere else.
const rupiah = (minor: string) =>
  new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR" }).format(
    Number(minor) / 100,
  );

export function ShopScreen() {
  const [products, setProducts] = useState<Product[]>([]);
  const [quantities, setQuantities] = useState<Record<string, number>>({});
  const [paying, setPaying] = useState(false);
  const [url, setUrl] = useState<string>(); // the sheet is open while this is set
  const [orderId, setOrderId] = useState<string>();
  const [status, setStatus] = useState<"idle" | "confirming" | "paid">("idle");

  useEffect(() => {
    fetch(`${API}/api/products`)
      .then((r) => r.json())
      .then((body) => setProducts(body.products));
  }, []);

  const lines = products
    .filter((p) => (quantities[p.id] ?? 0) > 0)
    .map((p) => ({ productId: p.id, quantity: quantities[p.id] ?? 0 }));

  const total = products.reduce((sum, p) => {
    const price = BigInt(p.prices[0]?.amount ?? "0");
    return sum + price * BigInt(quantities[p.id] ?? 0);
  }, 0n);

  const setQuantity = (id: string, quantity: number) =>
    setQuantities((current) => ({ ...current, [id]: Math.max(0, quantity) }));

  async function pay() {
    setPaying(true);
    try {
      // The order id exists before the payment does: it is the key for everything after.
      const id = `order_${Date.now()}`;
      const response = await fetch(`${API}/api/checkout`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ orderId: id, lines }),
      });
      const body = await response.json();
      if (!response.ok)
        throw new Error(body.error ?? `HTTP ${response.status}`);
      setOrderId(id);
      setUrl(body.url); // opens the sheet
    } catch (error) {
      Alert.alert("Could not start the payment", String(error));
    } finally {
      setPaying(false);
    }
  }

  // After the sheet reports success, ask your backend until the webhook has marked the order paid.
  useEffect(() => {
    if (status !== "confirming" || orderId === undefined) return;
    const timer = setInterval(async () => {
      const order = await fetch(`${API}/api/orders/${orderId}`).then((r) =>
        r.json(),
      );
      if (order.status === "paid") {
        setStatus("paid");
        setQuantities({});
      }
    }, 2000);
    return () => clearInterval(timer);
  }, [status, orderId]);

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <FlatList
        data={products}
        keyExtractor={(p) => p.id}
        ListEmptyComponent={<ActivityIndicator />}
        renderItem={({ item }) => (
          <View style={{ paddingVertical: 12 }}>
            <Text style={{ fontWeight: "600" }}>{item.name}</Text>
            <Text>{item.description}</Text>
            <Text>{rupiah(item.prices[0]?.amount ?? "0")}</Text>
            <View
              style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
            >
              <Button
                title="−"
                onPress={() =>
                  setQuantity(item.id, (quantities[item.id] ?? 0) - 1)
                }
              />
              <Text>{quantities[item.id] ?? 0}</Text>
              <Button
                title="+"
                onPress={() =>
                  setQuantity(item.id, (quantities[item.id] ?? 0) + 1)
                }
              />
            </View>
          </View>
        )}
      />

      {status === "paid" && <Text>Paid. Thank you!</Text>}
      {status === "confirming" && <Text>Confirming your payment…</Text>}

      <Text style={{ fontWeight: "600" }}>
        Total {rupiah(total.toString())}
      </Text>
      <Button
        title={paying ? "Preparing…" : "Pay"}
        disabled={lines.length === 0 || paying}
        onPress={pay}
      />

      <MayarinCheckoutModal
        url={url}
        // The checkout says the payment settled. Show progress; your backend decides "paid".
        onSuccess={() => setStatus("confirming")}
        onFailed={({ status: reason }) =>
          Alert.alert(
            reason === "EXPIRED" ? "Payment expired" : "Payment failed",
          )
        }
        // Done, the X, a tap outside, or Android back. Clearing `url` closes the sheet.
        onClose={() => setUrl(undefined)}
      />
    </View>
  );
}
```

What happens when the buyer taps Pay\:

1. The app sends `{ orderId, lines }` to your backend. The backend mints the payment and returns `{ id, url }`.
2. Setting `url` slides the sheet up. The buyer sees the order, picks the asset and network, and sends the payment.
3. On success the sheet stays open on the receipt. `onSuccess` fires, and the screen shows "Confirming…".
4. The buyer taps **Done**, which calls `onClose`. You clear `url` and the sheet slides away.
5. Your webhook marks the order paid, and the poll on `/api/orders/:orderId` flips the screen to "Paid".

If the buyer closes the sheet before paying, `onClose` runs without `onSuccess`. The order stays unpaid on your backend, and tapping Pay again with the same `orderId` returns the same payment because of the idempotency key.

## Tips and donations

For an amount the buyer chooses, create a payment link of kind `open` on your backend ([payment links](/sdk/typescript/commerce#payment-links)) and pass its `url` to the sheet. It opens on an amount field, so the app needs no input of its own and no backend call per payment\:

```tsx
<Button title="Send a tip" onPress={() => setUrl(openLinkUrl)} />
<MayarinCheckoutModal url={url} onSuccess={() => thank()} onClose={() => setUrl(undefined)} />
```

## Props

| Prop        | Meaning                                                                                    |
| ----------- | ------------------------------------------------------------------------------------------ |
| `url`       | The checkout URL your backend minted (`intent.url`). The sheet is open while this is set.  |
| `onSuccess` | `({ intentId, successUrl }) => void`. The payment settled. The sheet stays on the receipt. |
| `onFailed`  | `({ intentId, status }) => void`. `status` is `"FAILED"` or `"EXPIRED"`.                   |
| `onClose`   | Done, the X, a tap on the backdrop, or the Android back button. Clear `url` here.          |

`successUrl` is the merchant's success page, if you set `metadata.checkoutSuccessBaseUrl` when you minted. The sheet never navigates to it; use it or ignore it.

## What the sheet does

* A native spinner shows until the page has loaded.
* Only the checkout's own origin loads in the sheet. A link out, such as a local-currency provider's page, opens in the system browser.
* The checkout is always the light sheet, whatever theme the phone uses.
* A WebView has no wallet extension, so "Use wallet" is not offered. The buyer pays by scanning the QR from another device or by copying the address and amount into their wallet app.

## Without the component

`MayarinCheckoutModal` is a `Modal` with a `WebView` inside. If you want your own container, such as a full screen or your own navigation stack, use the same pieces from `@mayarin/sdk/embed`\:

* `embedUrl(url)` turns the checkout URL into the form that lays the page out for a modal. It adds `?embed=1`.
* `originOf(url)` gives `https://host` of a URL, to tell the checkout's own pages from a link out.
* `parseCheckoutEvent(data)` reads what the page posts to your app and returns `{ type: "ready" | "success" | "failed" | "close", … }`, or `undefined` for anything that is not the checkout's.

`url` is the checkout URL your backend minted in step 2 (`intent.url`), for example `https://pay.mayarin.xyz/checkout/pay/pi_01J…?t=…`. Pass it in from wherever your screen got it, as a route param or as state\:

```tsx
import { embedUrl, originOf, parseCheckoutEvent } from "@mayarin/sdk/embed";
import { Linking } from "react-native";
import { WebView } from "react-native-webview";

// `url` is the checkout URL from your backend; `onPaid` and `onDone` are yours.
function CheckoutScreen({
  url,
  onPaid,
  onDone,
}: {
  url: string;
  onPaid: (intentId: string) => void;
  onDone: () => void;
}) {
  const source = embedUrl(url);
  const origin = originOf(url); // not `new URL(url).origin`: React Native does not implement it

  return (
    <WebView
      source={{ uri: source }}
      onMessage={(e) => {
        const event = parseCheckoutEvent(e.nativeEvent.data);
        if (event?.type === "success") onPaid(event.intentId); // a signal; confirm on your backend
        if (event?.type === "failed") onDone();
        if (event?.type === "close") onDone(); // the buyer tapped Done or the X
      }}
      // Keep the sheet on the checkout; send any other link to the system browser.
      onShouldStartLoadWithRequest={(request) => {
        if (request.url.startsWith(origin)) return true;
        void Linking.openURL(request.url);
        return false;
      }}
      setSupportMultipleWindows={false}
    />
  );
}
```

`close` is how the checkout asks you to dismiss it\: the buyer pressed the X or Done. Hide the screen when it arrives, as `onClose` does for the component.

The same events drive the browser [`openCheckout`](/sdk/typescript/browser#open-the-checkout-in-a-modal) and the React [`PayButton`](/sdk/typescript/react#open-the-checkout-in-a-modal), so a web storefront and an app can share one backend.
