diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index a968012f38..64ff412007 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,6 +10,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') + implementation project(':capacitor-app-launcher') implementation project(':capacitor-browser') implementation project(':capacitor-camera') implementation project(':capacitor-clipboard') @@ -20,6 +21,7 @@ dependencies { implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-crisp') + implementation project(':capgo-capacitor-in-app-review') implementation project(':capgo-capacitor-native-biometric') implementation project(':capgo-capacitor-passkey') implementation project(':capgo-capacitor-updater') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index e9d814e6ea..22fedfbffb 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capa include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.2.0/node_modules/@capacitor/app/android') +include ':capacitor-app-launcher' +project(':capacitor-app-launcher').projectDir = new File('../node_modules/.pnpm/@capacitor+app-launcher@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/app-launcher/android') + include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') @@ -35,6 +38,9 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@c include ':capgo-capacitor-crisp' project(':capgo-capacitor-crisp').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp/android') +include ':capgo-capacitor-in-app-review' +project(':capgo-capacitor-in-app-review').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-in-app-review@8.2.3_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-in-app-review/android') + include ':capgo-capacitor-native-biometric' project(':capgo-capacitor-native-biometric').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric/android') diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 9a13aafe6f..49ff4f0803 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -6,6 +6,7 @@ import { withoutBrowserTracing } from '@/utils/sentry-integrations' import { posthogErrorMirror } from '@/utils/sentry-posthog-mirror' import { whenIdle } from '@/utils/defer-analytics' import { startWebVitalsShim } from '@/utils/web-vitals-shim' +import { noteAppReviewFriction } from '@/utils/app-review-friction' import { installPaymentNetworkGoogleAnalyticsGuard, isPaymentNetworkExplorerPath } from '@/utils/private-routes' // Same conditions as the GA bootstrap in app/layout.tsx: with no GA to disable @@ -49,7 +50,13 @@ if ( capture_pageleave: true, // The payment explorer contains team-only identity and relationship data. // Drop every event on client navigation; direct loads skip init above. - before_send: (event) => (isPaymentNetworkExplorerPath(window.location.pathname) ? null : event), + // Doubles as the review nudge's friction tap: every money-flow failure + // already funnels through here, so the suppressor needs no call sites. + before_send: (event) => { + if (isPaymentNetworkExplorerPath(window.location.pathname)) return null + if (event?.event) noteAppReviewFriction(event.event) + return event + }, // autocapture walks the DOM ancestor chain on every tap, which costs frames // in the in-app WebView renderer for data that 220+ explicit // posthog.capture calls already cover. Native keeps the explicit events only. diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index e2cd6b5f34..1ff5861063 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -13,6 +13,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.2.0/node_modules/@capacitor/app"), + .package(name: "CapacitorAppLauncher", path: "../../../node_modules/.pnpm/@capacitor+app-launcher@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/app-launcher"), .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser"), .package(name: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/camera"), .package(name: "CapacitorClipboard", path: "../../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard"), @@ -23,6 +24,7 @@ let package = Package( .package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen"), .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/status-bar"), .package(name: "CapgoCapacitorCrisp", path: "../../../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp"), + .package(name: "CapgoCapacitorInAppReview", path: "../../../node_modules/.pnpm/@capgo+capacitor-in-app-review@8.2.3_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-in-app-review"), .package(name: "CapgoCapacitorNativeBiometric", path: "../../../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric"), .package(name: "CapgoCapacitorPasskey", path: "../../../node_modules/@capgo/capacitor-passkey"), .package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/.pnpm/@capgo+capacitor-updater@8.51.14_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-updater"), @@ -36,6 +38,7 @@ let package = Package( .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorAppLauncher", package: "CapacitorAppLauncher"), .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), .product(name: "CapacitorCamera", package: "CapacitorCamera"), .product(name: "CapacitorClipboard", package: "CapacitorClipboard"), @@ -46,6 +49,7 @@ let package = Package( .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"), .product(name: "CapgoCapacitorCrisp", package: "CapgoCapacitorCrisp"), + .product(name: "CapgoCapacitorInAppReview", package: "CapgoCapacitorInAppReview"), .product(name: "CapgoCapacitorNativeBiometric", package: "CapgoCapacitorNativeBiometric"), .product(name: "CapgoCapacitorPasskey", package: "CapgoCapacitorPasskey"), .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"), diff --git a/package.json b/package.json index 2a967d279e..40ea2eb1f6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "@capacitor/android": "8.2.0", "@capacitor/app": "^8.1.0", + "@capacitor/app-launcher": "8.0.1", "@capacitor/browser": "^8.0.3", "@capacitor/camera": "^8.2.0", "@capacitor/cli": "8.2.0", @@ -65,6 +66,7 @@ "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.2", "@capgo/capacitor-crisp": "^8.0.27", + "@capgo/capacitor-in-app-review": "8.2.3", "@capgo/capacitor-native-biometric": "8.6.0", "@capgo/capacitor-passkey": "^8.2.2", "@capgo/capacitor-updater": "8.51.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 656ada3320..7fa2be644f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@capacitor/app': specifier: ^8.1.0 version: 8.1.0(@capacitor/core@8.2.0) + '@capacitor/app-launcher': + specifier: 8.0.1 + version: 8.0.1(@capacitor/core@8.2.0) '@capacitor/browser': specifier: ^8.0.3 version: 8.0.3(@capacitor/core@8.2.0) @@ -72,6 +75,9 @@ importers: '@capgo/capacitor-crisp': specifier: ^8.0.27 version: 8.0.27(@capacitor/core@8.2.0) + '@capgo/capacitor-in-app-review': + specifier: 8.2.3 + version: 8.2.3(@capacitor/core@8.2.0) '@capgo/capacitor-native-biometric': specifier: 8.6.0 version: 8.6.0(@capacitor/core@8.2.0) @@ -605,6 +611,11 @@ packages: peerDependencies: '@capacitor/core': ^8.2.0 + '@capacitor/app-launcher@8.0.1': + resolution: {integrity: sha512-23D8zi74sn7kxvISix8qYwgqdxGJN+4NImcNGvHen98LB1zb4eZfkJvFvp7pwntxGw4OjIE7yuf4wbzZxQHpog==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/app@8.1.0': resolution: {integrity: sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==} peerDependencies: @@ -673,6 +684,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-in-app-review@8.2.3': + resolution: {integrity: sha512-uTFp0IZc6JaCALEg1Z/Ku+95hrhIs8fgKmgqCxQn4MAC5m2EtkjVhSw1+Px9fk3wVbgyAZqFpRvDzK83HUB5Zw==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-native-biometric@8.6.0': resolution: {integrity: sha512-FvkOzrVzaYdOtv/EqK8GP4lUJblEnLmruSxkrHwnlsgLscN5k4Z4DL7vPohFvTFoce0e/2Y/4VpvV5GwHLJp8Q==} peerDependencies: @@ -8922,6 +8938,10 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 + '@capacitor/app-launcher@8.0.1(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/app@8.1.0(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -8996,6 +9016,10 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 + '@capgo/capacitor-in-app-review@8.2.3(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capgo/capacitor-native-biometric@8.6.0(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 diff --git a/scripts/native-fingerprint.mjs b/scripts/native-fingerprint.mjs index 6e4c2b91f1..cabe2dfed7 100644 --- a/scripts/native-fingerprint.mjs +++ b/scripts/native-fingerprint.mjs @@ -84,6 +84,7 @@ let repoRoot = defaultRoot export const NATIVE_DEPENDENCIES = [ '@capacitor/android', '@capacitor/app', + '@capacitor/app-launcher', '@capacitor/browser', '@capacitor/camera', '@capacitor/clipboard', @@ -96,6 +97,7 @@ export const NATIVE_DEPENDENCIES = [ '@capacitor/splash-screen', '@capacitor/status-bar', '@capgo/capacitor-crisp', + '@capgo/capacitor-in-app-review', '@capgo/capacitor-native-biometric', '@capgo/capacitor-passkey', '@capgo/capacitor-updater', diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index b987b8df39..cf2a8b62c8 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -78,6 +78,7 @@ import { STAR_STRAIGHT_ICON } from '@/assets/icons' import { useAuth } from '@/context/authContext' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' +import { useAppReviewNudge } from '@/hooks/useAppReviewNudge' import { usePointsCalculation } from '@/hooks/usePointsCalculation' import { useModalsContext } from '@/context/ModalsContext' import maintenanceConfig from '@/config/underMaintenance.config' @@ -1221,6 +1222,10 @@ export default function QRPayPage() { } }, [isSuccess, queryClient]) + // the success here is optimistic — a post-factum claim failure keeps the + // user on this screen with an error, and that is not a moment to ask + useAppReviewNudge(user?.user.userId, 'qr_payment_completed', isSuccess && !errorMessage) + useEffect(() => { if (waitingForMerchantAmount && !isLoadingPaymentLock) { setWaitingForMerchantAmount(false) diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index 9c334695c9..dd447f508c 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -21,6 +21,7 @@ import CreateAccountButton from '@/components/Global/CreateAccountButton' import { PeanutCheering } from '@/assets/mascot' import Image from 'next/image' import { useAppHaptic } from '@/hooks/useAppHaptic' +import { useAppReviewNudge } from '@/hooks/useAppReviewNudge' import { useTranslations } from 'next-intl' import { Notification } from '@/components/0_Bruddle/Notification' import Loading from '@/components/Global/Loading' @@ -181,6 +182,9 @@ export const SuccessClaimLinkView = ({ triggerHaptic() }, [isClaimed, triggerHaptic]) + // same gate as the haptic: a confirmed claim, never the optimistic mount + useAppReviewNudge(authUser?.user.userId, 'money_received', isClaimed && !claimFailure) + // The optimistic 202 lands here with no outcome yet. Hold the processing // state until the claim is confirmed — rendering success before that would // claim money that has not moved. diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx index 31f1e847e3..9140dcbd2d 100644 --- a/src/components/Home/HomeCarouselCTA/index.tsx +++ b/src/components/Home/HomeCarouselCTA/index.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/context/authContext' import { useWebSocket } from '@/hooks/useWebSocket' import { extractInviteeName } from '@/utils/general.utils' import PerkClaimModal from '../PerkClaimModal' +import { useAppReviewNudge } from '@/hooks/useAppReviewNudge' const HomeCarouselCTA = () => { const t = useTranslations('home.carousel') @@ -86,6 +87,11 @@ const HomeCarouselCTA = () => { setSelectedPerk(null) }, []) + // reward claimed and our modal gone: a friend joined, money landed, and + // nothing of ours is on screen. Lives here rather than in PerkClaimModal, + // which unmounts with `selectedPerk` and would take the pending ask with it. + useAppReviewNudge(user?.user.userId, 'reward_claimed', claimedPerkIds.size > 0 && !selectedPerk) + // don't render carousel if there are no CTAs if (!allCTAs.length) return null diff --git a/src/components/Migration/ReviewPromptModal.tsx b/src/components/Migration/ReviewPromptModal.tsx deleted file mode 100644 index 764c0ba33a..0000000000 --- a/src/components/Migration/ReviewPromptModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client' -import { useEffect, useState } from 'react' -import posthog from 'posthog-js' -import { useTranslations } from 'next-intl' -import ActionModal from '@/components/Global/ActionModal' -import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' -import { REVIEW_URL } from '@/constants/migration.consts' -import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' -import { useMigrationFlag } from '@/hooks/useMigrationFlag' -import { useTransactionHistory } from '@/hooks/useTransactionHistory' -import { useModalsContext } from '@/context/ModalsContext' -import { useUserStore } from '@/redux/hooks' -import { isCapacitor, openExternalUrl } from '@/utils/capacitor' -import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' - -/** - * "Loving Peanut so far?" pre-prompt (TASK-20598), native app only, asked once - * ever. Love it → store review page; Could be better → support drawer, so - * unhappy users never reach the store. - * - * ponytail: "good moment" V1 = user has at least one transaction and visits - * home (same {mode:'latest', limit:50} query key useHomeCarouselCTAs already - * fetches there, so the read is free). Wiring the exact success screens is the - * upgrade path. Store-page deep link for the rating; - * @capacitor-community/in-app-review for the native sheet if conversion - * matters. - */ -export default function ReviewPromptModal() { - const t = useTranslations('migration') - const migrationOn = useMigrationFlag() - const { deviceType } = useDeviceType() - const { user } = useUserStore() - const { openSupportWithMessage } = useModalsContext() - const { data: latestHistory } = useTransactionHistory({ mode: 'latest', limit: 50 }) - const [visible, setVisible] = useState(false) - - const userId = user?.user.userId - const hasTransacted = (latestHistory?.entries.length ?? 0) > 0 - - useEffect(() => { - if (!migrationOn || !userId || !isCapacitor() || !hasTransacted) return - if (getUserPreferences(userId)?.reviewPromptShownAt) return - setVisible(true) - posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.APP_REVIEW }) - }, [migrationOn, userId, hasTransacted]) - - const close = (cta?: 'love' | 'meh') => { - setVisible(false) - // stamp on interaction, not on show — home's priority wrapper can - // unmount a just-shown modal, and a show-time stamp would burn the - // once-ever ask before the user ever saw it - updateUserPreferences(userId, { reviewPromptShownAt: new Date().toISOString() }) - if (cta) { - posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.APP_REVIEW, cta }) - } else { - posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.APP_REVIEW }) - } - } - - return ( - close()} - icon="star" - title={t('review.title')} - description={t('review.description')} - ctas={[ - { - text: t('review.loveIt'), - variant: 'purple', - shadowSize: '4', - onClick: () => { - close('love') - void openExternalUrl(REVIEW_URL[deviceType === DeviceType.ANDROID ? 'android' : 'ios']) - }, - }, - { - text: t('review.meh'), - variant: 'stroke', - shadowSize: '4', - onClick: () => { - close('meh') - // prefilled so the drawer opens ready for feedback - openSupportWithMessage(t('review.supportPrefill')) - }, - }, - ]} - /> - ) -} diff --git a/src/components/Profile/views/About.view.tsx b/src/components/Profile/views/About.view.tsx index bd3e6151c1..e04ff3e2bd 100644 --- a/src/components/Profile/views/About.view.tsx +++ b/src/components/Profile/views/About.view.tsx @@ -10,6 +10,9 @@ import { LEGAL_POLICIES } from '@/constants/legal-policies' import { useAuth } from '@/context/authContext' import { claimPeanutTeamBadge } from '@/services/peanut-team-badge' import { useAppVersion } from '@/hooks/useAppVersion' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { openStoreReviewPage } from '@/utils/app-review' +import { isNativeBridge } from '@/utils/capacitor' import { useSafeBack } from '@/hooks/useSafeBack' import { useTranslations } from 'next-intl' import { useEffect, useRef, useState } from 'react' @@ -32,6 +35,12 @@ export const AboutView = ({ appVersion }: { appVersion: string }) => { const betaAccess = useBetaUpdatesAccess() const betaCardRef = useRef(null) const { fetchUser } = useAuth() + const { deviceType } = useDeviceType() + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + // false through SSR and the first client render — a render-time bridge check + // disagrees with the prerendered html and hard-fails hydration in the WebView + const [isNative, setIsNative] = useState(false) + useEffect(() => setIsNative(isNativeBridge()), []) useEffect(() => { if (betaRevealed) betaCardRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }) @@ -88,6 +97,25 @@ export const AboutView = ({ appVersion }: { appVersion: string }) => { ))} + {/* Native only: the web has no store listing to review against. + A row the user taps themselves, never a prompt — see + utils/app-review.ts for why that distinction is the whole rule. */} + {isNative && ( +
+

{t('rateHeading')}

+ + + +
+ )} + {betaRevealed && (
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index bd94541c2e..45c3db7edb 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -149,6 +149,13 @@ export const ANALYTICS_EVENTS = { NOTIFICATION_CLICKED: 'notification_clicked', NOTIFICATION_SUBSCRIPTION_SNAPSHOT: 'notification_subscription_snapshot', + // ── App store review ── + // The OS sheet was requested. There is deliberately no "shown" or "rated" + // counterpart: neither SKStoreReviewController nor Play In-App Review tells + // us whether the sheet appeared or what the user did. Measure the outcome + // against App Store Connect / Play Console rating counts, not against this. + REVIEW_REQUESTED: 'review_requested', + // ── Modal Fatigue ── MODAL_SHOWN: 'modal_shown', MODAL_DISMISSED: 'modal_dismissed', @@ -382,7 +389,6 @@ export const MODAL_TYPES = { KYC_COMPLETED: 'kyc_completed', INVITE: 'invite', MIGRATION_DOWNLOAD: 'migration_download', - APP_REVIEW: 'app_review', RE_CONSENT: 're_consent', } as const diff --git a/src/constants/migration.consts.ts b/src/constants/migration.consts.ts index 8d4011ccb3..f005f93e81 100644 --- a/src/constants/migration.consts.ts +++ b/src/constants/migration.consts.ts @@ -25,13 +25,6 @@ export const MIGRATION_URGENCY_THRESHOLD_DAYS = 14 // (only during the migration window; flag off keeps closed-forever) export const NOTIF_PROMPT_SNOOZE_DAYS = 14 -// store review deep links ("Love it" on the review prompt); the ios id is the -// App Store Connect app id (6786373552). -export const REVIEW_URL = { - ios: 'https://apps.apple.com/app/id6786373552?action=write-review', - android: 'https://play.google.com/store/apps/details?id=me.peanut.wallet', -} as const - // support escape hatch for users who can't install the app: support DMs // `/home?keep-web=`; visiting it stores a 90-day cookie that bypasses // the sunset block. @@ -40,8 +33,20 @@ export const KEEP_WEB_COOKIE = 'keep-web' export const KEEP_WEB_TOKEN = 'walnut-still-cracks' export const KEEP_WEB_COOKIE_DAYS = 90 +/** + * Write-a-review deep links for the user-initiated "Rate Peanut" row in Profile + * → About. A prompt built out of these would breach guideline 5.6.1 (that ask is + * the OS sheet, see utils/app-review.ts) — Apple documents this form only for a + * review the user starts themselves, which is what the row is. It is also the + * release valve for a user whose OS quota silently swallowed the sheet. + */ +export const REVIEW_URL = { + ios: 'https://apps.apple.com/us/app/id6786373552?action=write-review', + android: 'https://play.google.com/store/apps/details?id=me.peanut.wallet', +} as const + export const STORE_URL = { - ios: 'https://apps.apple.com/app/id6786373552', + ios: 'https://apps.apple.com/us/app/id6786373552', android: 'https://play.google.com/store/apps/details?id=me.peanut.wallet', } as const diff --git a/src/dev/surfaces/list.ts b/src/dev/surfaces/list.ts index 4011e60140..6710b4598e 100644 --- a/src/dev/surfaces/list.ts +++ b/src/dev/surfaces/list.ts @@ -99,11 +99,6 @@ export const SURFACE_META: Record = { path: 'Migration/MigrationDownloadModal.tsx', blocked: 'Opens itself off the sunset countdown and a stored dismissal — no visible prop.', }, - '42-c-reviewpromptmodal': { - name: 'ReviewPromptModal', - path: 'Migration/ReviewPromptModal.tsx', - blocked: 'Opens itself after a happy-moment event and a stored cooldown — no visible prop.', - }, '43-c-scantodownloadmodal': { name: 'ScanToDownloadModal', path: 'Migration/ScanToDownloadModal.tsx' }, '44-c-otaupdatemodal': { name: 'OtaUpdateModal (normal)', path: 'Profile/components/OtaUpdateModal.tsx' }, '45-c-residencechangemodal': { name: 'ResidenceChangeModal', path: 'Profile/views/ResidenceChangeModal.tsx' }, diff --git a/src/dev/surfaces/registry.tsx b/src/dev/surfaces/registry.tsx index 8078a27fd9..df93c4d2f3 100644 --- a/src/dev/surfaces/registry.tsx +++ b/src/dev/surfaces/registry.tsx @@ -399,11 +399,6 @@ export const SURFACES: Record = { path: 'Migration/MigrationDownloadModal.tsx', blocked: 'Opens itself off the sunset countdown and a stored dismissal — no visible prop.', }, - '42-c-reviewpromptmodal': { - name: 'ReviewPromptModal', - path: 'Migration/ReviewPromptModal.tsx', - blocked: 'Opens itself after a happy-moment event and a stored cooldown — no visible prop.', - }, '43-c-scantodownloadmodal': { name: 'ScanToDownloadModal', path: 'Migration/ScanToDownloadModal.tsx', diff --git a/src/features/home/components/HomeModals.tsx b/src/features/home/components/HomeModals.tsx index 843db20ae6..e7c5aa5a3b 100644 --- a/src/features/home/components/HomeModals.tsx +++ b/src/features/home/components/HomeModals.tsx @@ -26,7 +26,6 @@ const WelcomeUnlockModal = lazy(() => import('@/components/Home/WelcomeUnlockMod const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal')) const MigrationDownloadModal = lazy(() => import('@/components/Migration/MigrationDownloadModal')) const ScanToDownloadModal = lazy(() => import('@/components/Migration/ScanToDownloadModal')) -const ReviewPromptModal = lazy(() => import('@/components/Migration/ReviewPromptModal')) // guard against malformed env values — NaN would silently disable the // warning (threshold compare always false) or break dismissal expiry @@ -38,7 +37,7 @@ const BALANCE_WARNING_EXPIRY = Number.isNaN(parsedExpiry) ? 1814400 : parsedExpi /** * home modal orchestration — the priority chain the old home page carried * inline. migration download outranks everything, then notifications, kyc - * celebration, post-signup, ios pwa, balance warning, review prompt. + * celebration, post-signup, ios pwa, balance warning. */ export function HomeModals() { const { showPermissionModal } = useNotifications() @@ -57,7 +56,7 @@ export function HomeModals() { // the migration prompt outranks the post-signup modal; unmounting the // manager skips its onVisibilityChange(false), so clear the state here or - // it stays stuck true and suppresses the balance-warning/review modals + // it stays stuck true and suppresses the balance-warning modal useEffect(() => { if (showMigrationModal) setIsPostSignupActionModalVisible(false) }, [showMigrationModal]) @@ -208,19 +207,6 @@ export function HomeModals() { {!showMigrationModal && ( )} - - {/* app review nudge (native only, once ever) — lowest priority */} - {!showMigrationModal && - !showPermissionModal && - !showBalanceWarningModal && - !showKycModal && - !isPostSignupActionModalVisible && ( - - - - - - )} ) } diff --git a/src/features/payments/shared/components/PaymentSuccessView.tsx b/src/features/payments/shared/components/PaymentSuccessView.tsx index 3302dd1912..693eec47c4 100644 --- a/src/features/payments/shared/components/PaymentSuccessView.tsx +++ b/src/features/payments/shared/components/PaymentSuccessView.tsx @@ -39,6 +39,7 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { usePointsConfetti } from '@/hooks/usePointsConfetti' +import { useAppReviewNudge } from '@/hooks/useAppReviewNudge' import { PeanutCheering } from '@/assets/mascot' import { useAppHaptic } from '@/hooks/useAppHaptic' import posthog from 'posthog-js' @@ -287,6 +288,9 @@ const PaymentSuccessView = ({ triggerHaptic() }, [triggerHaptic]) + // type REQUEST is the "request created" screen — a link made, not money moved + useAppReviewNudge(authUser?.user.userId, 'payment_completed', type !== 'REQUEST') + return (
diff --git a/src/hooks/__tests__/useAppReviewNudge.test.tsx b/src/hooks/__tests__/useAppReviewNudge.test.tsx new file mode 100644 index 0000000000..98e624f634 --- /dev/null +++ b/src/hooks/__tests__/useAppReviewNudge.test.tsx @@ -0,0 +1,92 @@ +import { act, renderHook } from '@testing-library/react' +import { useAppReviewNudge } from '@/hooks/useAppReviewNudge' + +const requestAppReviewMock = jest.fn() +jest.mock('@/utils/app-review', () => ({ + requestAppReview: (...args: unknown[]) => requestAppReviewMock(...args), +})) + +const SETTLE_MS = 2_500 + +describe('useAppReviewNudge', () => { + beforeEach(() => { + jest.useFakeTimers() + requestAppReviewMock.mockReset() + requestAppReviewMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const settle = (ms = SETTLE_MS) => + act(() => { + jest.advanceTimersByTime(ms) + }) + + it('asks once the success screen has settled, with the user and trigger', () => { + renderHook(() => useAppReviewNudge('user-aaa', 'payment_completed', true)) + + settle(SETTLE_MS - 1) + expect(requestAppReviewMock).not.toHaveBeenCalled() + + settle(1) + expect(requestAppReviewMock).toHaveBeenCalledTimes(1) + expect(requestAppReviewMock).toHaveBeenCalledWith('user-aaa', 'payment_completed') + }) + + it('never asks a user who left the success screen before it settled', () => { + const { unmount } = renderHook(() => useAppReviewNudge('user-aaa', 'reward_claimed', true)) + + settle(SETTLE_MS - 100) + unmount() + settle(SETTLE_MS) + + expect(requestAppReviewMock).not.toHaveBeenCalled() + }) + + it('cancels the ask when the success is no longer confirmed', () => { + const { rerender } = renderHook(({ enabled }) => useAppReviewNudge('user-aaa', 'money_received', enabled), { + initialProps: { enabled: true }, + }) + + settle(SETTLE_MS - 100) + rerender({ enabled: false }) + settle(SETTLE_MS) + + expect(requestAppReviewMock).not.toHaveBeenCalled() + }) + + it('banks one moment per mount however often the surface re-renders', () => { + // HomeCarouselCTA's `enabled` flips as the perk modal opens and closes + const { rerender } = renderHook(({ enabled }) => useAppReviewNudge('user-aaa', 'reward_claimed', enabled), { + initialProps: { enabled: true }, + }) + + settle() + expect(requestAppReviewMock).toHaveBeenCalledTimes(1) + + rerender({ enabled: false }) + settle() + rerender({ enabled: true }) + settle() + + expect(requestAppReviewMock).toHaveBeenCalledTimes(1) + }) + + it('does not ask before the success is confirmed', () => { + renderHook(() => useAppReviewNudge('user-aaa', 'qr_payment_completed', false)) + + settle() + + expect(requestAppReviewMock).not.toHaveBeenCalled() + }) + + it('does not ask when there is no user to bank the moment against', () => { + renderHook(() => useAppReviewNudge(undefined, 'payment_completed', true)) + + settle() + + expect(requestAppReviewMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useAppReviewNudge.ts b/src/hooks/useAppReviewNudge.ts new file mode 100644 index 0000000000..f961064c66 --- /dev/null +++ b/src/hooks/useAppReviewNudge.ts @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { requestAppReview, type AppReviewTrigger } from '@/utils/app-review' + +/** + * Delay before the ask, so the success screen's own celebration (sound, + * confetti, the number the user came to see) plays out first. The OS sheet + * arrives at a stopping point rather than on top of the moment. + */ +const SETTLE_MS = 2_500 + +/** + * Ask for an app-store review on the tail of a happy moment. + * + * Mount this on a success surface, gated on the success actually being + * confirmed (`enabled`). Fires at most once per mount, and only if the surface + * is still on screen when the timer lands — a user who navigated away is no + * longer at a stopping point. + * + * Takes `userId` rather than reading the store, so it stays free of any + * provider/redux dependency and can mount on any success surface. + * + * Every other constraint (native-only, engagement floor, cooldown, recent + * friction) lives in requestAppReview, so call sites stay one line. + */ +export function useAppReviewNudge(userId: string | undefined, trigger: AppReviewTrigger, enabled: boolean): void { + const fired = useRef(false) + + useEffect(() => { + if (!enabled || !userId || fired.current) return + const timer = setTimeout(() => { + fired.current = true + void requestAppReview(userId, trigger) + }, SETTLE_MS) + return () => clearTimeout(timer) + }, [enabled, userId, trigger]) +} diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 6147a21f6b..611e864488 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -556,6 +556,8 @@ "cardProhibitedActivities": "Prohibited Activities Policy", "securityDisclosure": "Security Disclosure" }, + "rateHeading": "Rate the app", + "rate": "Leave a review", "version": "Version {version}", "beta": { "heading": "Beta updates", @@ -3568,13 +3570,6 @@ "title": "The Peanut app is here!", "description": "Faster, smoother, and it pings you when money lands." }, - "review": { - "title": "Loving Peanut so far?", - "description": "A quick rating helps other people find us.", - "loveIt": "Love it", - "meh": "Could be better", - "supportPrefill": "I have some feedback about the app, here's what could be better: " - }, "downloadNow": "Download now", "smartLink": { "redirecting": "Taking you to the store…", diff --git a/src/i18n/app/messages/en.marketing.json b/src/i18n/app/messages/en.marketing.json index 58181febe7..c0b667ad50 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -130,13 +130,6 @@ "title": "The Peanut app is here!", "description": "Faster, smoother, and it pings you when money lands." }, - "review": { - "title": "Loving Peanut so far?", - "description": "A quick rating helps other people find us.", - "loveIt": "Love it", - "meh": "Could be better", - "supportPrefill": "I have some feedback about the app, here's what could be better: " - }, "downloadNow": "Download now", "smartLink": { "redirecting": "Taking you to the store…", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 909c52c21b..0d64fa6a37 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -556,6 +556,8 @@ "cardProhibitedActivities": "Política de actividades prohibidas", "securityDisclosure": "Divulgación de seguridad" }, + "rateHeading": "Calificar la app", + "rate": "Dejar una reseña", "version": "Versión {version}", "beta": { "heading": "Actualizaciones beta", @@ -3568,13 +3570,6 @@ "title": "¡La app de Peanut ya está aquí!", "description": "Más rápida, más fluida y te avisa cuando llega tu dinero." }, - "review": { - "title": "¿Te está gustando Peanut?", - "description": "Una calificación rápida ayuda a que otros nos encuentren.", - "loveIt": "Me encanta", - "meh": "Podría mejorar", - "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: " - }, "downloadNow": "Descargar ahora", "smartLink": { "redirecting": "Te llevamos a la tienda…", diff --git a/src/i18n/app/messages/es-419.marketing.json b/src/i18n/app/messages/es-419.marketing.json index 5f5d1bf072..51b7c50cbb 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -130,13 +130,6 @@ "title": "¡La app de Peanut ya está aquí!", "description": "Más rápida, más fluida y te avisa cuando llega tu dinero." }, - "review": { - "title": "¿Te está gustando Peanut?", - "description": "Una calificación rápida ayuda a que otros nos encuentren.", - "loveIt": "Me encanta", - "meh": "Podría mejorar", - "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: " - }, "downloadNow": "Descargar ahora", "smartLink": { "redirecting": "Te llevamos a la tienda…", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 32d9e9694f..8e477f1c23 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -556,6 +556,8 @@ "cardProhibitedActivities": "Política de Atividades Proibidas", "securityDisclosure": "Divulgação de Segurança" }, + "rateHeading": "Avaliar o app", + "rate": "Deixar uma avaliação", "version": "Versão {version}", "beta": { "heading": "Atualizações beta", @@ -3568,13 +3570,6 @@ "title": "O app do Peanut chegou!", "description": "Mais rápido, mais fluido e te avisa quando o dinheiro chega." }, - "review": { - "title": "Está gostando do Peanut?", - "description": "Uma avaliação rápida ajuda outras pessoas a nos encontrar.", - "loveIt": "Adorei", - "meh": "Pode melhorar", - "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: " - }, "downloadNow": "Baixar agora", "smartLink": { "redirecting": "Levando você para a loja…", diff --git a/src/i18n/app/messages/pt-BR.marketing.json b/src/i18n/app/messages/pt-BR.marketing.json index c4ef89a490..187fa0bd2c 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -130,13 +130,6 @@ "title": "O app do Peanut chegou!", "description": "Mais rápido, mais fluido e te avisa quando o dinheiro chega." }, - "review": { - "title": "Está gostando do Peanut?", - "description": "Uma avaliação rápida ajuda outras pessoas a nos encontrar.", - "loveIt": "Adorei", - "meh": "Pode melhorar", - "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: " - }, "downloadNow": "Baixar agora", "smartLink": { "redirecting": "Levando você para a loja…", diff --git a/src/utils/__tests__/app-review-friction.test.ts b/src/utils/__tests__/app-review-friction.test.ts new file mode 100644 index 0000000000..690310d94b --- /dev/null +++ b/src/utils/__tests__/app-review-friction.test.ts @@ -0,0 +1,59 @@ +// the friction suppressor: recent trouble silences the review ask + +import { hadRecentFriction, noteAppReviewFriction } from '../app-review-friction' + +const KEY = 'peanut:app-review-friction' +const DAY_MS = 86_400_000 + +describe('app-review friction', () => { + beforeEach(() => window.localStorage.clear()) + + it('reports no friction on a clean device', () => { + expect(hadRecentFriction()).toBe(false) + }) + + it.each([ + 'send_failed', + 'withdraw_failed', + 'card_withdraw_failed', + 'claim_link_failed', + 'backend_error_shown', + 'kyc_rejected', + ])('records %s as friction', (event) => { + noteAppReviewFriction(event) + + expect(hadRecentFriction()).toBe(true) + }) + + it('keeps the stamp from a failed collateral spend that then succeeds on retry', () => { + noteAppReviewFriction('card_withdraw_failed') + noteAppReviewFriction('qr_payment_completed') + + expect(hadRecentFriction()).toBe(true) + }) + + it('ignores events that are not failures', () => { + noteAppReviewFriction('send_link_created') + noteAppReviewFriction('points_earned') + + expect(hadRecentFriction()).toBe(false) + }) + + it('lets friction expire after a week', () => { + window.localStorage.setItem(KEY, String(Date.now() - 8 * DAY_MS)) + + expect(hadRecentFriction()).toBe(false) + }) + + it('still counts friction from six days ago', () => { + window.localStorage.setItem(KEY, String(Date.now() - 6 * DAY_MS)) + + expect(hadRecentFriction()).toBe(true) + }) + + it('treats an unreadable stamp as no friction rather than throwing', () => { + window.localStorage.setItem(KEY, 'not-a-timestamp') + + expect(hadRecentFriction()).toBe(false) + }) +}) diff --git a/src/utils/__tests__/app-review.test.ts b/src/utils/__tests__/app-review.test.ts new file mode 100644 index 0000000000..424d6692d5 --- /dev/null +++ b/src/utils/__tests__/app-review.test.ts @@ -0,0 +1,139 @@ +// tests for the app-review request budget — the only control we have, since +// neither OS reports whether the sheet appeared + +import { requestAppReview } from '../app-review' +import { getUserPreferences, updateUserPreferences } from '../general.utils' +import { isNativeBridge } from '../capacitor' +import { isDemoMode } from '../demo' +import { hadRecentFriction } from '../app-review-friction' +import posthog from 'posthog-js' + +const requestReview = jest.fn, []>() + +jest.mock('@capgo/capacitor-in-app-review', () => ({ + CapgoInAppReview: { requestReview: () => requestReview() }, +})) +jest.mock('../capacitor', () => ({ isNativeBridge: jest.fn(), openExternalUrl: jest.fn() })) +jest.mock('../demo', () => ({ isDemoMode: jest.fn() })) +jest.mock('../app-review-friction', () => ({ hadRecentFriction: jest.fn() })) +jest.mock('../general.utils', () => ({ getUserPreferences: jest.fn(), updateUserPreferences: jest.fn() })) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) + +const USER = 'user-1' +const DAY_MS = 86_400_000 +const daysAgo = (n: number) => new Date(Date.now() - n * DAY_MS).toISOString() + +/** The stored nudge state after the last updateUserPreferences call. */ +const lastWrite = () => (updateUserPreferences as jest.Mock).mock.calls.at(-1)?.[1]?.reviewNudge + +function given(moments: number, requestedAt: string[] = []) { + ;(getUserPreferences as jest.Mock).mockReturnValue({ reviewNudge: { moments, requestedAt } }) +} + +describe('requestAppReview', () => { + beforeEach(() => { + jest.clearAllMocks() + requestReview.mockResolvedValue(undefined) + ;(isNativeBridge as jest.Mock).mockReturnValue(true) + ;(isDemoMode as jest.Mock).mockReturnValue(false) + ;(hadRecentFriction as jest.Mock).mockReturnValue(false) + given(1) + }) + + it('asks once the engagement floor is met', async () => { + await requestAppReview(USER, 'reward_claimed') + + expect(requestReview).toHaveBeenCalledTimes(1) + expect(posthog.capture).toHaveBeenCalledWith( + 'review_requested', + expect.objectContaining({ trigger: 'reward_claimed', request_count: 1, days_since_last: null }) + ) + }) + + it('banks the first moment without asking', async () => { + given(0) + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + expect(lastWrite()).toEqual({ moments: 1, requestedAt: [] }) + }) + + it('banks moments even while suppressed, so the ask is earned by the cooldown edge', async () => { + const recent = daysAgo(10) + given(4, [recent]) + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + expect(lastWrite()).toEqual({ moments: 5, requestedAt: [recent] }) + }) + + it('stays silent for a user we recently failed', async () => { + ;(hadRecentFriction as jest.Mock).mockReturnValue(true) + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + }) + + it('holds the 120-day floor between requests, then asks again', async () => { + given(9, [daysAgo(119)]) + await requestAppReview(USER, 'money_received') + expect(requestReview).not.toHaveBeenCalled() + + given(9, [daysAgo(121)]) + await requestAppReview(USER, 'money_received') + expect(requestReview).toHaveBeenCalledTimes(1) + expect(posthog.capture).toHaveBeenCalledWith( + 'review_requested', + expect.objectContaining({ days_since_last: 121 }) + ) + }) + + it('spends at most two requests a year, keeping one of Apple’s three in reserve', async () => { + given(9, [daysAgo(300), daysAgo(150)]) + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + }) + + it('lets the budget recover once a request ages out of the year', async () => { + given(9, [daysAgo(400), daysAgo(200)]) + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).toHaveBeenCalledTimes(1) + }) + + it('does not spend the request when the native flow fails to start', async () => { + requestReview.mockRejectedValue(new Error('no play services')) + + await requestAppReview(USER, 'payment_completed') + + expect(lastWrite()).toEqual({ moments: 2, requestedAt: [] }) + expect(posthog.capture).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'without a native bridge (web, or a capacitor-flavoured preview)', + () => (isNativeBridge as jest.Mock).mockReturnValue(false), + ], + ['in demo mode', () => (isDemoMode as jest.Mock).mockReturnValue(true)], + ])('never asks %s', async (_label, arrange) => { + arrange() + + await requestAppReview(USER, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + expect(updateUserPreferences).not.toHaveBeenCalled() + }) + + it('ignores a logged-out caller', async () => { + await requestAppReview(undefined, 'payment_completed') + + expect(requestReview).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/app-review-friction.ts b/src/utils/app-review-friction.ts new file mode 100644 index 0000000000..d61d58e868 --- /dev/null +++ b/src/utils/app-review-friction.ts @@ -0,0 +1,57 @@ +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +/** + * The app-review nudge's friction suppressor. + * + * Split out of app-review.ts on purpose: this half is imported by + * instrumentation-client's `before_send`, which runs before anything else on the + * page, and must not pull Sentry / Capacitor / user-preferences in behind it. + */ + +/** Recent trouble outranks a good moment: don't ask a user we just failed. */ +const QUIET_DAYS = 7 + +const DAY_MS = 86_400_000 + +/** + * Device-scoped rather than keyed by userId: the events below fire from screens + * where no peanut user is resolved (guest claim, a backend error during login). + */ +const FRICTION_KEY = 'peanut:app-review-friction' + +/** + * Analytics events that mean the user just had a bad time. Any of them silences + * the ask for QUIET_DAYS. This is the honest version of what the old + * "Could be better" branch was reaching for: suppress the question rather than + * filter the answer. + */ +const FRICTION_EVENTS = new Set([ + ANALYTICS_EVENTS.SEND_FAILED, + ANALYTICS_EVENTS.SEND_LINK_FAILED, + ANALYTICS_EVENTS.CLAIM_LINK_FAILED, + ANALYTICS_EVENTS.DEPOSIT_FAILED, + ANALYTICS_EVENTS.WITHDRAW_FAILED, + ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, + ANALYTICS_EVENTS.CARD_APPLY_FAILED, + ANALYTICS_EVENTS.BACKEND_ERROR_SHOWN, + ANALYTICS_EVENTS.KYC_REJECTED, +]) + +/** Call from posthog's `before_send`. Never throws — analytics must not break the app. */ +export function noteAppReviewFriction(eventName: string): void { + if (!FRICTION_EVENTS.has(eventName)) return + try { + window.localStorage.setItem(FRICTION_KEY, String(Date.now())) + } catch { + // private mode / quota — degrades to asking a user we should have skipped + } +} + +export function hadRecentFriction(): boolean { + try { + const at = Number(window.localStorage.getItem(FRICTION_KEY)) + return Number.isFinite(at) && at > 0 && Date.now() - at < QUIET_DAYS * DAY_MS + } catch { + return false + } +} diff --git a/src/utils/app-review.ts b/src/utils/app-review.ts new file mode 100644 index 0000000000..beba6db352 --- /dev/null +++ b/src/utils/app-review.ts @@ -0,0 +1,110 @@ +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { REVIEW_URL } from '@/constants/migration.consts' +import { isNativeBridge, openExternalUrl } from '@/utils/capacitor' +import { hadRecentFriction } from '@/utils/app-review-friction' +import { isDemoMode } from '@/utils/demo' +import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' + +/** + * Native app-store review requests. + * + * App Store Review Guideline 5.6.1 disallows custom review prompts, so the ask + * is the OS sheet and nothing else: no pre-question, no "are you enjoying it?", + * no branch that routes unhappy users somewhere other than the store (5.6.3, + * and Google's In-App Review guidelines forbid the pre-question by name). + * + * The OS returns no signal — not whether the sheet appeared, not whether a + * rating was left — so the budget below is the only control we have. iOS shows + * at most 3 prompts per 365 days and may decline silently; Google's quota is + * undocumented. We stay under Apple's ceiling deliberately: a request spent on + * a weak moment is a request unavailable for a strong one. + */ + +/** Happy moments to bank before the first ask — HIG's "demonstrated engagement". */ +const MIN_QUALIFYING_MOMENTS = 2 + +/** Floor between two requests. Dismissal is never permanent, just expensive. */ +const REQUEST_COOLDOWN_DAYS = 120 + +/** Our own ceiling, one under Apple's 3/365, so a great moment always has budget. */ +const MAX_REQUESTS_PER_YEAR = 2 + +const DAY_MS = 86_400_000 + +/** + * Open the store's write-a-review page — the user-initiated "Rate Peanut" row, + * never a prompt. + * + * Deliberately not openExternalUrl: that opens an in-app sheet + * (SFSafariViewController on iOS), which does not follow universal links, so + * `?action=write-review` would render the web listing with no composer on it. + * AppLauncher hands the url to the OS, which opens the App Store / Play app. + */ +export async function openStoreReviewPage(store: 'ios' | 'android'): Promise { + try { + const { AppLauncher } = await import('@capacitor/app-launcher') + // it reports a failed launch in `completed`, it does not throw — an + // unchecked call is a dead tap with no error anywhere + const { completed } = await AppLauncher.openUrl({ url: REVIEW_URL[store] }) + if (completed) return + } catch { + // plugin missing (web, or a binary predating it) — fall through + } + // the in-app sheet still reaches the listing, just without the composer + await openExternalUrl(REVIEW_URL[store]) +} + +/** Which happy moment triggered the request — the `trigger` property on the event. */ +export type AppReviewTrigger = 'reward_claimed' | 'payment_completed' | 'money_received' | 'qr_payment_completed' + +/** + * Record a happy moment and, if the budget allows, ask the OS for a review. + * + * The moment is always banked — the counter is what earns the first ask — but + * the request itself is rate-limited. Resolves once the flow settles; callers + * should not await it for anything user-visible. + */ +export async function requestAppReview(userId: string | undefined, trigger: AppReviewTrigger): Promise { + // isNativeBridge, not isCapacitor: a capacitor-flavoured web preview has the + // plugin's web adapter, which resolves without showing anything — and would + // still spend a request from the budget + if (!userId || !isNativeBridge() || isDemoMode()) return + + const nudge = getUserPreferences(userId)?.reviewNudge + const moments = (nudge?.moments ?? 0) + 1 + // Bank the moment before any gate below can return: a user who is inside a + // cooldown still accrues engagement toward the ask on the far side of it. + const requestedAt = nudge?.requestedAt ?? [] + updateUserPreferences(userId, { reviewNudge: { moments, requestedAt } }) + + if (moments < MIN_QUALIFYING_MOMENTS) return + if (hadRecentFriction()) return + + const now = Date.now() + const last = requestedAt.length ? Date.parse(requestedAt[requestedAt.length - 1]) : NaN + const daysSinceLast = Number.isFinite(last) ? Math.floor((now - last) / DAY_MS) : null + if (daysSinceLast !== null && daysSinceLast < REQUEST_COOLDOWN_DAYS) return + if (requestedAt.filter((at) => now - Date.parse(at) < 365 * DAY_MS).length >= MAX_REQUESTS_PER_YEAR) return + + try { + const { CapgoInAppReview } = await import('@capgo/capacitor-in-app-review') + await CapgoInAppReview.requestReview() + } catch { + // Android rejects when the Play review flow can't start (no Play + // Services, sideloaded build). Nothing to recover, and nothing the user + // should ever see — but the request must not be recorded as spent. + return + } + + // Keep only what the two windows above read. + const history = [...requestedAt, new Date(now).toISOString()].slice(-MAX_REQUESTS_PER_YEAR - 1) + updateUserPreferences(userId, { reviewNudge: { moments, requestedAt: history } }) + + posthog.capture(ANALYTICS_EVENTS.REVIEW_REQUESTED, { + trigger, + request_count: history.length, + days_since_last: daysSinceLast, + qualifying_moments: moments, + }) +} diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 7335d4cca0..07310315fa 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -419,8 +419,10 @@ export type UserPreferences = { * legacy permanent `notifModalClosed` so we can re-ask after a cooldown * during the migration window. */ notifModalClosedAt?: string - /** ISO timestamp the app-review prompt was shown (asked once, ever). */ - reviewPromptShownAt?: string + /** App-review nudge budget (see utils/app-review.ts). `moments` counts + * qualifying happy moments seen; `requestedAt` holds the ISO timestamps of + * past OS review requests, oldest first. */ + reviewNudge?: { moments: number; requestedAt: string[] } /** Dismissal fingerprints (`bridgeTaskDismissalKey`: key|requirement|due) * of the pending Bridge verification tasks the user individually * dismissed on /home. A task that turns blocking or changes substance