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:
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 limit exceeded",
|
||||||
"ResizeObserver loop completed with undelivered notifications.",
|
"ResizeObserver loop completed with undelivered notifications.",
|
||||||
"Non-Error promise rejection captured",
|
"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;
|
initialized = true;
|
||||||
console.log("[sentry] browser SDK initialized");
|
console.log("[sentry] browser SDK initialized");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InAppEscape } from "@/components/in-app-escape";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||||
@@ -139,6 +140,7 @@ function RootComponent() {
|
|||||||
<a href="#main-content" className="skip-link">
|
<a href="#main-content" className="skip-link">
|
||||||
{t("a11y.skipToContent")}
|
{t("a11y.skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
|
<InAppEscape />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user