diff --git a/.env.example b/.env.example index 168828e1..c626ac77 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,18 @@ EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID= # instead of the rate the rewards API reports. On unless set to "false" — flip it # once the admin-configured rates are live. EXPO_PUBLIC_HARDCODED_TIER_CASHBACK=true + +# Trustpilot Review Collector widget (web/desktop only; native uses the OS review +# sheet instead). Only the business unit id is required — the widget renders +# nothing while it is empty. Take it from the Trustpilot Business console, under +# the widget embed code as `data-businessunit-id`. +EXPO_PUBLIC_TRUSTPILOT_BUSINESS_UNIT_ID= +# Widget template. Defaults to the free Review Collector; override only to swap +# the layout. Template ids are the same for every business and are not secrets. +EXPO_PUBLIC_TRUSTPILOT_TEMPLATE_ID=56278e9abfbbba0bdcd568bc +# The domain reviews are filed under, as registered with Trustpilot. +EXPO_PUBLIC_TRUSTPILOT_DOMAIN=solid.xyz +EXPO_PUBLIC_TRUSTPILOT_LOCALE=en-US +# Where "Write a review" goes if the widget script is blocked. Defaults to +# https://www.trustpilot.com/evaluate/. +EXPO_PUBLIC_TRUSTPILOT_REVIEW_URL= diff --git a/app/(protected)/(tabs)/settings/index.tsx b/app/(protected)/(tabs)/settings/index.tsx index 71bc2ca0..545c7e06 100644 --- a/app/(protected)/(tabs)/settings/index.tsx +++ b/app/(protected)/(tabs)/settings/index.tsx @@ -11,6 +11,7 @@ import Navbar from '@/components/Navbar'; import WhatsNewButton from '@/components/Navbar/WhatsNewButton'; import PageLayout from '@/components/PageLayout'; import { SettingsCard } from '@/components/Settings'; +import TrustpilotReviewCard from '@/components/Trustpilot/TrustpilotReviewCard'; import { BackButton } from '@/components/ui/back-button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -297,6 +298,8 @@ const MobileSettings = () => { {rowGroups.map((rows, index) => ( ))} + {/* Web/desktop only — native asks for a rating through the OS sheet. */} + diff --git a/app/_layout.tsx b/app/_layout.tsx index 3adb6f0b..ce294517 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -40,7 +40,7 @@ import AppErrorBoundary from '@/components/ErrorBoundary'; import Intercom from '@/components/Intercom/index'; import { LazyThirdwebProvider } from '@/components/LazyThirdwebProvider'; import LazyWhatsNewModal from '@/components/LazyWhatsNewModal'; -import CardDepositStoreReviewTrigger from '@/components/StoreReview/CardDepositStoreReviewTrigger'; +import AppOpenStoreReviewTrigger from '@/components/StoreReview/AppOpenStoreReviewTrigger'; import CashbackStoreReviewTrigger from '@/components/StoreReview/CashbackStoreReviewTrigger'; import ThirdwebConnectionBridge from '@/components/ThirdwebConnectionBridge'; import { toastProps } from '@/components/Toast'; @@ -451,7 +451,7 @@ function RootLayout() { {hasSelectedUser && Platform.OS !== 'web' && ( <> - + )} diff --git a/components/StoreReview/AppOpenStoreReviewTrigger.tsx b/components/StoreReview/AppOpenStoreReviewTrigger.tsx new file mode 100644 index 00000000..f8faee3b --- /dev/null +++ b/components/StoreReview/AppOpenStoreReviewTrigger.tsx @@ -0,0 +1,13 @@ +import { useAppOpenStoreReview } from '@/hooks/useAppOpenStoreReview'; + +/** + * Headless component that records app opens and asks the user to rate the app + * (via the native in-app store review sheet) once their card is genuinely in + * use and they have come back to it — funded twice for a Rain cardholder, or + * holding something the card can spend for a Wirex one. Renders nothing; mount + * it once inside the authenticated app tree. + */ +export default function AppOpenStoreReviewTrigger() { + useAppOpenStoreReview(); + return null; +} diff --git a/components/StoreReview/CardDepositStoreReviewTrigger.tsx b/components/StoreReview/CardDepositStoreReviewTrigger.tsx deleted file mode 100644 index 2b97986e..00000000 --- a/components/StoreReview/CardDepositStoreReviewTrigger.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useCardDepositStoreReview } from '@/hooks/useCardDepositStoreReview'; - -/** - * Headless component that records app opens and asks the user to rate the app - * (via the native in-app store review sheet) once they've funded their card - * twice and come back to it. Renders nothing; mount it once inside the - * authenticated app tree. - */ -export default function CardDepositStoreReviewTrigger() { - useCardDepositStoreReview(); - return null; -} diff --git a/components/Trustpilot/TrustpilotReviewCard.tsx b/components/Trustpilot/TrustpilotReviewCard.tsx new file mode 100644 index 00000000..7f0e21e9 --- /dev/null +++ b/components/Trustpilot/TrustpilotReviewCard.tsx @@ -0,0 +1,43 @@ +import { Platform, View } from 'react-native'; + +import TrustpilotWidget from '@/components/Trustpilot/TrustpilotWidget'; +import { Text } from '@/components/ui/text'; +import { isTrustpilotConfigured } from '@/constants/trustpilot'; +import { cn } from '@/lib/utils'; + +interface TrustpilotReviewCardProps { + /** Context string for analytics, e.g. where in the app this card sits. */ + analyticsContext?: string; + className?: string; +} + +/** + * "Enjoying Solid?" card wrapping the Trustpilot Review Collector widget, styled to sit + * in the same stack as the settings rows. + * + * Settings is the placement on purpose. The widget is standing UI rather than a prompt — + * it cannot be timed to a happy moment the way the native review sheet is — so putting + * it anywhere a user passes through on their way to something else would be noise on + * every visit. Here it is found by someone who came looking for account actions, and + * costs nothing to everyone else. + * + * Web only, and only once Trustpilot is configured; renders nothing otherwise. The + * native apps ask for a rating through the OS review sheet instead, which App Store + * guidelines require in-app rating prompts to use. + */ +export default function TrustpilotReviewCard({ + analyticsContext = 'settings', + className, +}: TrustpilotReviewCardProps) { + if (Platform.OS !== 'web' || !isTrustpilotConfigured()) return null; + + return ( + + Enjoying Solid? + + Tell others what you think — it takes a minute and it genuinely helps. + + + + ); +} diff --git a/components/Trustpilot/TrustpilotWidget.tsx b/components/Trustpilot/TrustpilotWidget.tsx new file mode 100644 index 00000000..77a6da2d --- /dev/null +++ b/components/Trustpilot/TrustpilotWidget.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Platform, Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { + isTrustpilotConfigured, + TRUSTPILOT_BOOTSTRAP_SRC, + TRUSTPILOT_BUSINESS_UNIT_ID, + TRUSTPILOT_DOMAIN, + TRUSTPILOT_LOCALE, + TRUSTPILOT_REVIEW_URL, + TRUSTPILOT_TEMPLATE_ID, +} from '@/constants/trustpilot'; +import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; + +declare global { + interface Window { + Trustpilot?: { + /** Mounts a widget into one already-rendered `.trustpilot-widget` node. */ + loadFromElement?: (element: HTMLElement | null, forceReload?: boolean) => void; + }; + } +} + +/** How long the bootstrap script gets before we fall back to a plain link. */ +const SCRIPT_TIMEOUT_MS = 8000; + +/** Rendered height of the Review Collector template, per Trustpilot's embed code. */ +const DEFAULT_HEIGHT = 52; + +type ScriptState = 'loading' | 'ready' | 'failed'; + +interface TrustpilotWidgetProps { + /** Context string for analytics, e.g. where in the app this instance sits. */ + analyticsContext?: string; + /** Widget height in px; the Review Collector template wants 52. */ + height?: number; + className?: string; +} + +/** + * Loads Trustpilot's widget bootstrap exactly once per page. + * + * The script registers `window.Trustpilot` globally and is shared by every widget on + * the page, so a second `