dev #82
36
apps/web/src/components/__tests__/in-app-escape.test.ts
Normal file
36
apps/web/src/components/__tests__/in-app-escape.test.ts
Normal 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=");
|
||||
});
|
||||
});
|
||||
109
apps/web/src/components/in-app-escape.tsx
Normal file
109
apps/web/src/components/in-app-escape.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user