# App Bridge

> Panel ile gömülü uygulama arasındaki postMessage protokolü, bağlam nesnesi, @tecof/app-bridge ve React sağlayıcısı.
>
> Kaynak: https://tecof-app-dev.vercel.app/app-bridge · Bölüm: Uygulama geliştirme · Son güncelleme: 2018-10-20

App Bridge, panel içinde iframe olarak açılan uygulama arayüzünüz ile Tecof paneli arasındaki mesajlaşma katmanıdır. Oturum token'ı almak, bildirim göstermek, onay penceresi açmak, panelde başka bir sayfaya gitmek ve aksiyon penceresini kapatmak bu kanal üzerinden yapılır. Altta `window.postMessage` kullanılır; `@tecof/app-bridge` paketi protokolü sizin yerinize yönetir.

---

## Kurulum

```bash
npm i @tecof/app-bridge
```

Paket tarayıcıda çalışır ve bağımlılık içermez. React için `@tecof/app-bridge/react` alt yolu vardır.

---

## Protokol

Her mesaj aynı zarfı taşır:

```json
{ "tecof": "app-bridge", "v": 1, "id": "req_1", "type": "toast", "payload": { "message": "Kaydedildi" } }
```

| Alan | Açıklama |
| :--- | :--- |
| `tecof` | Her zaman `"app-bridge"` |
| `v` | Protokol sürümü: `1` |
| `id` | İsteğe bağlı; istek–yanıt eşleştirmesi için |
| `type` | Mesaj tipi |
| `payload` | Tipe özgü yük |

Panelin yanıtı:

```json
{ "tecof": "app-bridge", "v": 1, "id": "req_1", "type": "response", "ok": true, "payload": {} }
```

Hata durumunda `ok: false` ve `error: { code, message }` döner.

### Güvenlik kuralları

| Taraf | Kural |
| :--- | :--- |
| Panel | Yalnız `event.origin` uygulamanızın origin'ine **ve** `event.source` iframe'in `contentWindow`'una eşit olan mesajları işler. Yanıtı `postMessage(msg, appOrigin)` ile yalnız uygulama origin'ine gönderir |
| Uygulama (SDK) | Yalnız açılış adresindeki `host` parametresinde (base64url) bildirilen panel origin'inden gelen mesajları kabul eder |

SDK'sız çalışıyorsanız bu iki kuralı kendiniz uygulayın; `"*"` hedef origin'iyle mesaj göndermeyin.

---

## Mesaj Tipleri

| `type` (uygulama → panel) | `payload` | Yanıt `payload` |
| :--- | :--- | :--- |
| `handshake` | `{ sdkVersion }` | `{ context }` |
| `sessionToken` | — | `{ token, expiresAt }` |
| `toast` | `{ message, kind?, duration? }` — `kind`: `success`, `error`, `info`, `warning` | `{}` |
| `navigate` | `{ to }` — yalnız `/app/` ile başlayan panel yolu | `{}` |
| `open` | `{ url }` — `https` adresi, yeni sekmede açılır | `{}` |
| `confirm` | `{ title, message, confirmLabel?, cancelLabel?, destructive? }` | `{ confirmed }` |
| `setTitle` | `{ title }` | `{}` |
| `setLoading` | `{ loading }` | `{}` |
| `resize` | `{ height }` — modal/çekmece yüksekliği | `{}` |
| `close` | `{ refresh?, message?, kind? }` — aksiyon penceresini kapatır | `{}` |
| `openBilling` | — | `{}` — plan seçim çekmecesini açar |

Panelden uygulamaya kendiliğinden gelen olay:

```json
{ "tecof": "app-bridge", "v": 1, "type": "context", "payload": { "context": { } } }
```

Bu olay tema ya da dil değiştiğinde güncel bağlamla gönderilir.

---

## Bağlam (context) Nesnesi

```json
{
  "merchant": { "id": "66f0c0ffee0000000000abcd", "name": "Örnek Mağaza", "currency": "TRY", "languages": ["tr", "en"] },
  "user": { "id": "66f0aa00bb11cc22dd33ee66", "name": "Örnek Kullanıcı", "locale": "tr" },
  "installation": {
    "id": "6720aa00bb11cc22dd33ee55",
    "scopes": ["orders:read", "customers:read", "products:read"],
    "plan": { "key": "standart", "status": "active" }
  },
  "app": { "clientId": "tcfa_xxxxxxxxxxxxxxxx", "slug": "isbasi", "name": "Logo İşbaşı" },
  "theme": "light",
  "locale": "tr",
  "action": { "runId": "6720cc00dd11ee22ff33aa55", "key": "create-invoice", "placement": "order.detail", "ids": ["6720bb00cc11dd22ee33ff44"] }
}
```

| Alan | Açıklama |
| :--- | :--- |
| `merchant` | Mağaza kimliği, adı, para birimi, dilleri |
| `user` | Paneli kullanan kişi |
| `installation.scopes` | Verilmiş kapsamlar |
| `installation.plan` | `{ key, status }` ya da plan yoksa `null`. Bkz. [Ücretlendirme](/app-billing) |
| `app` | Uygulamanın istemci kimliği, slug'ı, adı |
| `theme` | `light` ya da `dark` — arayüzünüzü panelle uyumlu boyayın |
| `locale` | Panel dili |
| `action` | Yalnız iframe aksiyonu olarak açıldığında: `{ runId, key, placement, ids }` |

> Bağlam arayüz içindir. Sunucu tarafında yetki kararı için oturum token'ını doğrulayın; bkz. [Kimlik Doğrulama](/app-auth).

---

## SDK Kullanımı

```ts
import { createAppBridge, isEmbedded } from "@tecof/app-bridge";

if (!isEmbedded()) {
  // Sayfa panel dışında açıldı (window.top === window.self ya da embedded=1 yok)
}

const bridge = createAppBridge({ debug: false }); // host origin'i URL'deki `host` parametresinden okunur
await bridge.ready();                              // handshake → bridge.context dolar

console.log(bridge.context.merchant.name);

// Oturum token'ı: önbellekli, bitişe 10 sn kala yenilenir
const token = await bridge.getSessionToken();

// Kendi sunucunuza Authorization: Bearer <oturum token'ı> ile istek
const res = await bridge.fetch("/api/orders");

bridge.toast("Ayarlar kaydedildi", { kind: "success" });
bridge.navigate("/app/ecommerce/orders/6720bb00cc11dd22ee33ff44");
bridge.open("https://ornek.com/yardim");

const confirmed = await bridge.confirm({
  title: "Faturayı iptal et",
  message: "Bu işlem geri alınamaz.",
  confirmLabel: "İptal et",
  cancelLabel: "Vazgeç",
  destructive: true,
});

bridge.setTitle("Faturalar");
bridge.setLoading(true);
bridge.resize(640);
bridge.close({ refresh: true, message: "Tamamlandı", kind: "success" });
bridge.openBilling();

const unsubscribe = bridge.on("context", (context) => {
  document.documentElement.dataset.theme = context.theme;
});
```

| Metot | Açıklama |
| :--- | :--- |
| `createAppBridge({ debug? })` | Köprüyü oluşturur |
| `ready()` | Handshake'i tamamlar; ardından `bridge.context` kullanılabilir |
| `context` | Güncel bağlam |
| `getSessionToken()` | Önbellekli oturum token'ı |
| `fetch(input, init)` | `Authorization: Bearer <oturum token'ı>` ekleyerek `fetch` |
| `toast(message, { kind?, duration? })` | Panel bildirimi |
| `navigate(to)` | Panel içi yönlendirme (`/app/…`) |
| `open(url)` | Yeni sekmede `https` adresi |
| `confirm({...})` | Panel onay penceresi → `boolean` |
| `setTitle`, `setLoading`, `resize` | Başlık, yükleniyor durumu, yükseklik |
| `close({ refresh?, message?, kind? })` | Aksiyon penceresini kapatır |
| `openBilling()` | Plan seçim çekmecesi |
| `on("context", cb)` | Bağlam değişimini dinler; aboneliği kaldıran fonksiyon döner |
| `isEmbedded()` | Panel içinde mi (`window.top !== window.self` ve `embedded=1`) |

---

## React

```tsx
// app/layout.tsx
import { AppShell } from "./AppShell";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="tr">
      <body>
        <AppShell>{children}</AppShell>
      </body>
    </html>
  );
}
```

```tsx
// app/AppShell.tsx
"use client";

import { AppBridgeProvider } from "@tecof/app-bridge/react";

export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <AppBridgeProvider fallback={<p>Yükleniyor…</p>}>
      {children}
    </AppBridgeProvider>
  );
}
```

```tsx
// app/invoices/page.tsx
"use client";

import { useEffect, useState } from "react";
import { useAppBridge, useAppContext, useSessionFetch } from "@tecof/app-bridge/react";

type InvoiceRow = { orderNumber: string; invoiceNumber: string };

export default function InvoicesPage() {
  const bridge = useAppBridge();
  const context = useAppContext();
  const sessionFetch = useSessionFetch();
  const [rows, setRows] = useState<InvoiceRow[]>([]);

  useEffect(() => {
    bridge.setTitle("Faturalar");
    sessionFetch("/api/invoices")
      .then((res) => res.json())
      .then((data) => setRows(data.items))
      .catch(() => bridge.toast("Faturalar yüklenemedi", { kind: "error" }));
  }, [bridge, sessionFetch]);

  return (
    <main data-theme={context.theme}>
      <h1>{context.merchant.name} — Faturalar</h1>
      <ul>
        {rows.map((row) => (
          <li key={row.invoiceNumber}>{row.orderNumber}: {row.invoiceNumber}</li>
        ))}
      </ul>
    </main>
  );
}
```

| API | Açıklama |
| :--- | :--- |
| `<AppBridgeProvider fallback?>` | Köprüyü kurar, handshake bitene kadar `fallback`'i gösterir |
| `useAppBridge()` | Köprü nesnesi |
| `useAppContext()` | Güncel bağlam (`context` olaylarıyla güncellenir) |
| `useSessionFetch()` | Oturum token'ını ekleyen `fetch` |

`useSessionFetch` ile çağrılan sunucu ucunuz token'ı `tecof.sessionFromRequest(req)` ile doğrular. Bkz. [Kimlik Doğrulama](/app-auth).

---

## SDK'sız Kullanım

Farklı bir çatı kullanıyorsanız protokolü doğrudan uygulayabilirsiniz:

```js
function decodeBase64Url(value) {
  const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
  return atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
}

const params = new URLSearchParams(window.location.search);
const panelOrigin = decodeBase64Url(params.get("host"));
const pending = new Map();
let seq = 0;

window.addEventListener("message", (event) => {
  if (event.origin !== panelOrigin) return;            // yalnız panel origin'i
  const msg = event.data;
  if (!msg || msg.tecof !== "app-bridge" || msg.v !== 1) return;
  if (msg.type === "response" && pending.has(msg.id)) {
    const { resolve, reject } = pending.get(msg.id);
    pending.delete(msg.id);
    msg.ok ? resolve(msg.payload) : reject(msg.error);
  }
  if (msg.type === "context") {
    // msg.payload.context ile arayüzü güncelle
  }
});

function send(type, payload) {
  const id = `req_${++seq}`;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    window.parent.postMessage({ tecof: "app-bridge", v: 1, id, type, payload }, panelOrigin);
  });
}

const { context } = await send("handshake", { sdkVersion: "custom-1" });
const { token } = await send("sessionToken");
await send("toast", { message: `Merhaba ${context.user.name}`, kind: "info" });
```
