Merge pull request 'dev' (#82) from dev into main

Reviewed-on: #82
This commit was merged in pull request #82.
This commit is contained in:
2026-06-03 00:03:13 +00:00
4 changed files with 160 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildAndroidIntent, detect } from "../in-app-escape";
describe("detect", () => {
const IG_IOS =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 Instagram 432.0.0";
const IG_ANDROID =
"Mozilla/5.0 (Linux; Android 15; 2210129SG) AppleWebKit/537.36 Instagram 432.0.0";
const CHROME =
"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Mobile Safari/537.36";
const SAFARI =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 Version/16.6 Mobile Safari/604.1";
it("flags Instagram iOS as in-app + ios", () => {
expect(detect(IG_IOS)).toEqual({ inApp: true, ios: true, android: false });
});
it("flags Instagram Android as in-app + android", () => {
expect(detect(IG_ANDROID)).toEqual({ inApp: true, ios: false, android: true });
});
it("does NOT flag normal Chrome/Safari (no false positives → no redirect loop)", () => {
expect(detect(CHROME).inApp).toBe(false);
expect(detect(SAFARI).inApp).toBe(false);
});
});
describe("buildAndroidIntent", () => {
it("preserves the full URL incl. UTM/fbclid so attribution survives the hop", () => {
const href = "https://sase.tr/?utm_source=facebook&utm_content=greenab_gv1&fbclid=ABC123";
expect(buildAndroidIntent(href)).toBe(
"intent://sase.tr/?utm_source=facebook&utm_content=greenab_gv1&fbclid=ABC123#Intent;scheme=https;end",
);
});
it("does not force a package (user's default browser)", () => {
expect(buildAndroidIntent("https://sase.tr/")).not.toContain("package=");
});
});

View File

@@ -0,0 +1,109 @@
import { capture } from "@/lib/posthog";
import { useEffect, useState } from "react";
/**
* Escapes Instagram/Facebook in-app browsers (WebViews) into the system browser.
*
* Why: Google OAuth (signIn.social google) returns `403 disallowed_useragent`
* inside embedded WebViews, so ad traffic landing from Instagram/FB can't sign
* up with Google. The system browser also restores password autofill, a
* persistent session, and clean attribution.
*
* - Android: hard-escape via the `intent://` scheme (no `package=` → the user's
* default browser). The full URL — UTM + fbclid — is preserved.
* - iOS: Apple allows no programmatic escape, so we show a dismissible banner
* guiding the user to "••• → Safari'de Aç", with a copy-link fallback.
*/
const IN_APP_RE = /Instagram|FBAN|FBAV|FB_IAB|Twitter|Line\/|TikTok|musical_ly/i;
const TRIED_KEY = "inapp_escape_tried";
const IOS_DISMISS_KEY = "inapp_escape_ios_dismissed";
/** Pure UA classifier — exported for tests. */
export function detect(ua = typeof navigator === "undefined" ? "" : navigator.userAgent || "") {
return {
inApp: IN_APP_RE.test(ua),
ios: /iPhone|iPad|iPod/i.test(ua),
android: /Android/i.test(ua),
};
}
/** Build the Android intent:// escape URL, preserving the full URL (UTM/fbclid). */
export function buildAndroidIntent(href: string): string {
const noScheme = href.replace(/^https?:\/\//, "");
// No package= → the user's default browser (not forced to Chrome).
return `intent://${noScheme}#Intent;scheme=https;end`;
}
export function InAppEscape() {
const [showBanner, setShowBanner] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
const { inApp, ios, android } = detect();
if (!inApp) return;
if (android) {
// Guard: redirect once per session. The system browser's UA won't match
// IN_APP_RE, so there's no loop — this only protects against an in-app
// browser that fails to hand off the intent and reloads us.
if (sessionStorage.getItem(TRIED_KEY)) return;
sessionStorage.setItem(TRIED_KEY, "1");
window.location.href = buildAndroidIntent(window.location.href);
return;
}
if (ios && !sessionStorage.getItem(IOS_DISMISS_KEY)) {
setShowBanner(true);
capture("inapp_escape_banner_shown", { platform: "ios" });
}
}, []);
if (!showBanner) return null;
const dismiss = () => {
sessionStorage.setItem(IOS_DISMISS_KEY, "1");
setShowBanner(false);
capture("inapp_escape_banner_dismissed", { platform: "ios" });
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
capture("inapp_escape_link_copied", { platform: "ios" });
setTimeout(() => setCopied(false), 2500);
} catch {
// Clipboard blocked — the user can still use the ••• menu.
}
};
return (
<div
className="fixed inset-x-0 top-0 z-[9999] flex items-center gap-3 px-4 py-3 text-sm text-white shadow-lg"
style={{ background: "#0a0a0a", borderBottom: "3px solid #ff0016" }}
role="alert"
>
<div className="flex-1 leading-snug">
Daha iyi deneyim ve <b>Google ile giriş</b> için sağ üstteki <b></b> menüsünden{" "}
<b>"Safari'de Aç"</b>a dokun.
</div>
<button
type="button"
onClick={copyLink}
className="shrink-0 rounded-md px-3 py-1.5 font-semibold"
style={{ background: "#ff0016", color: "#fff" }}
>
{copied ? "Kopyalandı ✓" : "Linki kopyala"}
</button>
<button
type="button"
onClick={dismiss}
aria-label="Kapat"
className="shrink-0 px-1 text-lg leading-none text-white/60 hover:text-white"
>
×
</button>
</div>
);
}

View File

@@ -47,7 +47,20 @@ export async function initSentry() {
"ResizeObserver loop limit exceeded",
"ResizeObserver loop completed with undelivered notifications.",
"Non-Error promise rejection captured",
// Instagram/Meta in-app browsers inject their own JS that probes the iOS
// WKWebView bridge; it throws when window.webkit is absent. Not our code —
// pure noise that scales with Instagram ad traffic.
"window.webkit.messageHandlers",
"undefined is not an object (evaluating 'window.webkit",
// iOS in-app browsers fail to start AudioContext without a user gesture.
"Failed to start the audio device",
// Android Instagram in-app browser bridge teardown during beforeunload.
"Java object is gone",
],
// Drop anything thrown by in-app browser injected scripts (Instagram/FB
// Android use the iabjs:// scheme). Message-independent, so it catches the
// whole family of in-app browser bridge errors at the source URL.
denyUrls: [/iabjs:\/\//],
});
initialized = true;
console.log("[sentry] browser SDK initialized");

View File

@@ -1,3 +1,4 @@
import { InAppEscape } from "@/components/in-app-escape";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
@@ -139,6 +140,7 @@ function RootComponent() {
<a href="#main-content" className="skip-link">
{t("a11y.skipToContent")}
</a>
<InAppEscape />
<Outlet />
<Toaster position="top-center" />
</>