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 8ab36420ff..1c145f4c43 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 59f87ee0e6..63f0d21f09 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -5,6 +5,7 @@ import { inferSentryEnvironment } from '@/utils/sentry-env' import { withoutBrowserTracing } from '@/utils/sentry-integrations' import { posthogErrorMirror } from '@/utils/sentry-posthog-mirror' import { whenIdle } from '@/utils/defer-analytics' +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 @@ -48,7 +49,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 85f6f90441..a30adecc44 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.45.9_@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 d32f709717..69548ee504 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.45.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c42614bafc..e93eb00865 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) @@ -602,6 +608,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: @@ -670,6 +681,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: @@ -8916,6 +8932,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 @@ -8990,6 +9010,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 @@ -10194,7 +10218,7 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) lodash: 4.18.1 pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -10204,7 +10228,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.12 debug: 4.4.3(supports-color@10.2.2) - semver: 7.7.3 + semver: 7.8.5 superstruct: 1.0.4 transitivePeerDependencies: - supports-color @@ -10218,7 +10242,7 @@ snapshots: '@types/debug': 4.1.12 debug: 4.4.3(supports-color@10.2.2) pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -10232,7 +10256,7 @@ snapshots: '@types/debug': 4.1.12 debug: 4.4.3(supports-color@10.2.2) pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -10600,7 +10624,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.3 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -10612,7 +10636,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.3 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -10884,7 +10908,7 @@ snapshots: extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.3 + semver: 7.8.5 tar-fs: 3.1.1 yargs: 17.7.2 transitivePeerDependencies: @@ -16312,7 +16336,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 make-error@1.3.6: {} @@ -17927,8 +17951,7 @@ snapshots: semver@7.7.3: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} serialize-javascript@7.0.5: {} diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 370f086476..2ecbc2e3ac 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -76,6 +76,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' @@ -1207,6 +1208,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 62e2642e71..751104e932 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' @@ -171,6 +172,9 @@ export const SuccessClaimLinkView = ({ triggerHaptic() }, [transactionHash, triggerHaptic]) + // same gate as the haptic: a confirmed claim, never the optimistic mount + useAppReviewNudge(authUser?.user.userId, 'money_received', !!transactionHash && !claimFailure) + // The optimistic 202 lands here with no hash and no outcome yet. Rendering // the success card now would claim money that has not moved — and would // keep claiming it for as long as the poll is slow or failing. 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 fc85d72ea2..d098655b35 100644 --- a/src/components/Profile/views/About.view.tsx +++ b/src/components/Profile/views/About.view.tsx @@ -8,6 +8,9 @@ import { BetaUpdatesCard, useBetaUpdatesAccess } from '@/components/Profile/comp import { useToast } from '@/components/0_Bruddle/Toast' import { LEGAL_POLICIES } from '@/constants/legal-policies' import { useAppVersion } from '@/hooks/useAppVersion' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { openStoreReviewPage } from '@/utils/app-review' +import { isCapacitor } from '@/utils/capacitor' import { useSafeBack } from '@/hooks/useSafeBack' import { useTranslations } from 'next-intl' import { useEffect, useRef, useState } from 'react' @@ -29,6 +32,12 @@ export const AboutView = ({ appVersion }: { appVersion: string }) => { const toast = useToast() const betaAccess = useBetaUpdatesAccess() const betaCardRef = useRef(null) + const { deviceType } = useDeviceType() + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + // false through SSR and the first client render — a render-time isCapacitor() + // disagrees with the prerendered html and hard-fails hydration in the WebView + const [isNative, setIsNative] = useState(false) + useEffect(() => setIsNative(isCapacitor()), []) useEffect(() => { if (betaRevealed) betaCardRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }) @@ -72,6 +81,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 b6e54e0f26..ce07245689 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', @@ -376,7 +383,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/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 b4cec13bfa..a2c249c4ca 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/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 e623091e18..81065ddcaf 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -537,6 +537,8 @@ "cardProhibitedActivities": "Prohibited Activities Policy", "securityDisclosure": "Security Disclosure" }, + "rateHeading": "Rate the app", + "rate": "Leave a review", "version": "Version {version}", "beta": { "heading": "Beta updates", @@ -3508,13 +3510,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 e636645144..0e14f27ccc 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -129,13 +129,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 b9b96e27c4..20d98305ca 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -537,6 +537,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", @@ -3508,13 +3510,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 230f6b00eb..1d8d2b6c97 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -129,13 +129,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 01c7dbd65b..ac89b9caf5 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -537,6 +537,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", @@ -3508,13 +3510,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 0fbe7b0370..2e510745e4 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -129,13 +129,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..3914c26f41 --- /dev/null +++ b/src/utils/__tests__/app-review-friction.test.ts @@ -0,0 +1,48 @@ +// 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', 'claim_link_failed', 'backend_error_shown', 'kyc_rejected'])( + 'records %s as friction', + (event) => { + noteAppReviewFriction(event) + + 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..2580031ed8 --- /dev/null +++ b/src/utils/__tests__/app-review.test.ts @@ -0,0 +1,136 @@ +// 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 { isCapacitor } 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', () => ({ isCapacitor: 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) + ;(isCapacitor 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([ + ['on the web', () => (isCapacitor 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..12bfde92a3 --- /dev/null +++ b/src/utils/app-review-friction.ts @@ -0,0 +1,56 @@ +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_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..12bf71bd44 --- /dev/null +++ b/src/utils/app-review.ts @@ -0,0 +1,107 @@ +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { REVIEW_URL } from '@/constants/migration.consts' +import { isCapacitor, 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 { + if (!userId || !isCapacitor() || 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