diff --git a/package.json b/package.json index c1c159d0..e8234cc8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-dom": "19.2.4", "react-icons": "^5.6.0", "stripe": "^22.2.1", + "ua-parser-js": "^2.0.10", "unstorage": "^1.17.5", "xstate": "^5", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57fde0b2..9745eeaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: stripe: specifier: ^22.2.1 version: 22.2.1(@types/node@20.19.41) + ua-parser-js: + specifier: ^2.0.10 + version: 2.0.10 unstorage: specifier: ^1.17.5 version: 1.17.5 @@ -1639,6 +1642,9 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2147,6 +2153,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3081,6 +3090,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.10: + resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4689,6 +4705,8 @@ snapshots: destr@2.0.5: {} + detect-europe-js@0.1.2: {} + detect-libc@2.1.2: {} dexie@4.4.3: {} @@ -5365,6 +5383,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-standalone-pwa@0.1.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -6359,6 +6379,14 @@ snapshots: typescript@5.9.3: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.10: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + ufo@1.6.4: {} unbox-primitive@1.1.0: diff --git a/src/utils/browser-detect.ts b/src/utils/browser-detect.ts index 84693f84..3e814c2e 100644 --- a/src/utils/browser-detect.ts +++ b/src/utils/browser-detect.ts @@ -1,26 +1,142 @@ +import { UAParser } from "ua-parser-js"; + +export type InAppBrowserName = + | "facebook" + | "instagram" + | "wechat" + | "weibo" + | "qq" + | "line" + | "tiktok" + | "webview" + | "unknown"; + +export interface BrowserInfo { + name: string; + version: string; + major: string; + engineName: string; + engineVersion: string; + isInAppBrowser: boolean; + inAppBrowserName: InAppBrowserName | null; + userAgent: string; +} + /** * 浏览器 / WebView 检测 + * + * 只放浏览器相关能力。OS / 设备 / 平台检测请使用 `PlatformDetector`。 */ -export const BrowserDetector = { +export class BrowserDetector { + private constructor() {} + + static getBrowserInfo(ua: string | null = null): BrowserInfo { + const userAgent = BrowserDetector.getUserAgent(ua); + const result = new UAParser(userAgent).getResult(); + const inAppBrowserName = BrowserDetector.getInAppBrowserName(userAgent); + + return { + name: result.browser.name ?? "", + version: result.browser.version ?? "", + major: result.browser.major ?? "", + engineName: result.engine.name ?? "", + engineVersion: result.engine.version ?? "", + isInAppBrowser: inAppBrowserName !== null, + inAppBrowserName, + userAgent, + }; + } + + static getBrowserName(ua: string | null = null): string { + return BrowserDetector.getBrowserInfo(ua).name; + } + + static getBrowserVersion(ua: string | null = null): string { + return BrowserDetector.getBrowserInfo(ua).version; + } + + static isChrome(ua: string | null = null): boolean { + return /Chrome|CriOS/i.test(BrowserDetector.getBrowserName(ua)); + } + + static isSafari(ua: string | null = null): boolean { + return /Safari/i.test(BrowserDetector.getBrowserName(ua)); + } + + static isEdge(ua: string | null = null): boolean { + return /Edge/i.test(BrowserDetector.getBrowserName(ua)); + } + /** 当前是否在 Facebook 内嵌浏览器 */ - isFacebookInAppBrowser(ua: string | null = null): boolean { - if (typeof navigator === "undefined" && !ua) return false; - const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : ""); - return /FBAN|FBAV|Instagram/i.test(userAgent); - }, + static isFacebookInAppBrowser(ua: string | null = null): boolean { + return /FBAN|FBAV|FB_IAB|FB4A|FBIOS/i.test(BrowserDetector.getUserAgent(ua)); + } + + /** 当前是否在 Instagram 内嵌浏览器 */ + static isInstagramInAppBrowser(ua: string | null = null): boolean { + return /Instagram/i.test(BrowserDetector.getUserAgent(ua)); + } /** 当前是否在微信内嵌浏览器 */ - isWeChatInAppBrowser(ua: string | null = null): boolean { - if (typeof navigator === "undefined" && !ua) return false; - const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : ""); - return /MicroMessenger/i.test(userAgent); - }, + static isWeChatInAppBrowser(ua: string | null = null): boolean { + return /MicroMessenger/i.test(BrowserDetector.getUserAgent(ua)); + } + + /** 当前是否在微博内嵌浏览器 */ + static isWeiboInAppBrowser(ua: string | null = null): boolean { + return /Weibo/i.test(BrowserDetector.getUserAgent(ua)); + } + + /** 当前是否在 QQ 内嵌浏览器 */ + static isQQInAppBrowser(ua: string | null = null): boolean { + return /QQ\//i.test(BrowserDetector.getUserAgent(ua)); + } + + /** 当前是否在 Line 内嵌浏览器 */ + static isLineInAppBrowser(ua: string | null = null): boolean { + return /Line\//i.test(BrowserDetector.getUserAgent(ua)); + } + + /** 当前是否在 TikTok 内嵌浏览器 */ + static isTikTokInAppBrowser(ua: string | null = null): boolean { + return /BytedanceWebview|TikTok|Musical_ly/i.test( + BrowserDetector.getUserAgent(ua), + ); + } /** 当前是否在 WebView(不通用;仅作 best-effort 判断) */ - isInAppBrowser(ua: string | null = null): boolean { - if (typeof navigator === "undefined" && !ua) return false; - const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : ""); - return /WebView|; wv\)|FBAN|FBAV|MicroMessenger|Instagram|Line\//i.test(userAgent); - }, + static isWebView(ua: string | null = null): boolean { + const userAgent = BrowserDetector.getUserAgent(ua); + return ( + /WebView|; wv\)/i.test(userAgent) || + (/iPhone|iPad|iPod/i.test(userAgent) && + /AppleWebKit/i.test(userAgent) && + !/Safari/i.test(userAgent)) + ); + } -}; + /** 当前是否在应用内浏览器 */ + static isInAppBrowser(ua: string | null = null): boolean { + return BrowserDetector.getInAppBrowserName(ua) !== null; + } + + static getInAppBrowserName( + ua: string | null = null, + ): InAppBrowserName | null { + if (BrowserDetector.isFacebookInAppBrowser(ua)) return "facebook"; + if (BrowserDetector.isInstagramInAppBrowser(ua)) return "instagram"; + if (BrowserDetector.isWeChatInAppBrowser(ua)) return "wechat"; + if (BrowserDetector.isWeiboInAppBrowser(ua)) return "weibo"; + if (BrowserDetector.isQQInAppBrowser(ua)) return "qq"; + if (BrowserDetector.isLineInAppBrowser(ua)) return "line"; + if (BrowserDetector.isTikTokInAppBrowser(ua)) return "tiktok"; + if (BrowserDetector.isWebView(ua)) return "webview"; + return null; + } + + private static getUserAgent(ua: string | null = null): string { + if (ua) return ua; + if (typeof navigator === "undefined") return ""; + return navigator.userAgent; + } +} diff --git a/src/utils/platform-detect.ts b/src/utils/platform-detect.ts new file mode 100644 index 00000000..d512377d --- /dev/null +++ b/src/utils/platform-detect.ts @@ -0,0 +1,98 @@ +import { UAParser } from "ua-parser-js"; + +export type PlatformName = "ios" | "android" | "desktop" | "unknown"; + +export interface PlatformInfo { + platform: PlatformName; + osName: string; + osVersion: string; + deviceType: string; + deviceVendor: string; + deviceModel: string; + cpuArchitecture: string; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isIOS: boolean; + isAndroid: boolean; + userAgent: string; +} + +/** + * 平台 / OS / 设备检测 + * + * 与浏览器内核、内置浏览器判断解耦;浏览器相关能力请使用 `BrowserDetector`。 + */ +export class PlatformDetector { + private constructor() {} + + static getPlatformInfo(ua: string | null = null): PlatformInfo { + const userAgent = PlatformDetector.getUserAgent(ua); + const result = new UAParser(userAgent).getResult(); + const osName = result.os.name ?? ""; + const deviceType = result.device.type ?? ""; + const isIOS = PlatformDetector.isIOS(userAgent); + const isAndroid = PlatformDetector.isAndroid(userAgent); + const isTablet = deviceType === "tablet" || PlatformDetector.isIPadOS(); + const isMobile = deviceType === "mobile" || isTablet; + + return { + platform: PlatformDetector.getPlatform(userAgent), + osName, + osVersion: result.os.version ?? "", + deviceType, + deviceVendor: result.device.vendor ?? "", + deviceModel: result.device.model ?? "", + cpuArchitecture: result.cpu.architecture ?? "", + isMobile, + isTablet, + isDesktop: !isMobile, + isIOS, + isAndroid, + userAgent, + }; + } + + static getPlatform(ua: string | null = null): PlatformName { + const userAgent = PlatformDetector.getUserAgent(ua); + if (PlatformDetector.isIOS(userAgent)) return "ios"; + if (PlatformDetector.isAndroid(userAgent)) return "android"; + if (userAgent.length > 0) return "desktop"; + return "unknown"; + } + + static isIOS(ua: string | null = null): boolean { + const userAgent = PlatformDetector.getUserAgent(ua); + const osName = new UAParser(userAgent).getOS().name ?? ""; + return /iOS/i.test(osName) || PlatformDetector.isIPadOS(); + } + + static isAndroid(ua: string | null = null): boolean { + const userAgent = PlatformDetector.getUserAgent(ua); + const osName = new UAParser(userAgent).getOS().name ?? ""; + return /Android/i.test(osName) || /Android/i.test(userAgent); + } + + static isMobile(ua: string | null = null): boolean { + return PlatformDetector.getPlatformInfo(ua).isMobile; + } + + static isTablet(ua: string | null = null): boolean { + return PlatformDetector.getPlatformInfo(ua).isTablet; + } + + static isDesktop(ua: string | null = null): boolean { + return PlatformDetector.getPlatformInfo(ua).isDesktop; + } + + private static getUserAgent(ua: string | null = null): string { + if (ua) return ua; + if (typeof navigator === "undefined") return ""; + return navigator.userAgent; + } + + private static isIPadOS(): boolean { + if (typeof navigator === "undefined") return false; + return navigator.platform === "MacIntel" && (navigator.maxTouchPoints ?? 0) > 1; + } +} diff --git a/src/utils/url-launcher-util.ts b/src/utils/url-launcher-util.ts new file mode 100644 index 00000000..fe15843e --- /dev/null +++ b/src/utils/url-launcher-util.ts @@ -0,0 +1,84 @@ +import { PlatformDetector } from "./platform-detect"; + +/** + * URL 打开工具类 + * + * 对齐 Flutter 端 `lib/utils/url_launcher_util.dart`: + * - Android: 使用 Chrome Intent URL 尝试跳出应用内浏览器 + * - iOS: 使用 Safari URL scheme 尝试跳出应用内浏览器 + * - 其他平台: 使用普通新窗口打开,失败时回退到当前页跳转 + */ +export type UrlQueryParams = Record; + +export class UrlLauncherUtil { + private constructor() {} + + static openUrlWithExternalBrowser( + url: string, + options: { queryParams?: UrlQueryParams } = {}, + ): void { + if (typeof window === "undefined") return; + + const finalUrl = UrlLauncherUtil.appendQueryParams( + UrlLauncherUtil.toAbsoluteUrl(url), + options.queryParams, + ); + + if (PlatformDetector.isAndroid()) { + window.location.href = UrlLauncherUtil.buildIntentUrl(finalUrl); + return; + } + + if (PlatformDetector.isIOS()) { + window.location.href = UrlLauncherUtil.buildSafariUrl(finalUrl); + return; + } + + UrlLauncherUtil.openUrl(finalUrl); + } + + static openUrl(url: string): void { + if (typeof window === "undefined") return; + + const finalUrl = UrlLauncherUtil.toAbsoluteUrl(url); + const opened = window.open(finalUrl, "_blank", "noopener,noreferrer"); + if (!opened) { + window.location.href = finalUrl; + } + } + + static appendQueryParams( + url: string, + queryParams?: UrlQueryParams, + ): string { + if (!queryParams || Object.keys(queryParams).length === 0) return url; + + const uri = new URL(url, UrlLauncherUtil.getBaseUrl()); + for (const [key, value] of Object.entries(queryParams)) { + uri.searchParams.set(key, value); + } + return uri.toString(); + } + + static buildIntentUrl(url: string): string { + const uri = new URL(url, UrlLauncherUtil.getBaseUrl()); + const scheme = uri.protocol.replace(":", "") || "https"; + const path = `${uri.pathname}${uri.search}`; + return `intent://${uri.host}${path}#Intent;scheme=${scheme};package=com.android.chrome;end`; + } + + static buildSafariUrl(url: string): string { + const uri = new URL(url, UrlLauncherUtil.getBaseUrl()); + const path = `${uri.pathname}${uri.search}`; + return `x-safari-https://${uri.host}${path}`; + } + + private static toAbsoluteUrl(url: string): string { + return new URL(url, UrlLauncherUtil.getBaseUrl()).toString(); + } + + private static getBaseUrl(): string { + if (typeof window !== "undefined") return window.location.origin; + return "https://cozsweet.com"; + } +}