Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>.
EXPO_PUBLIC_TRUSTPILOT_REVIEW_URL=
3 changes: 3 additions & 0 deletions app/(protected)/(tabs)/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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';
Expand Down Expand Up @@ -297,13 +298,15 @@
{rowGroups.map((rows, index) => (
<SettingsRowGroup key={index} rows={rows} />
))}
{/* Web/desktop only — native asks for a rating through the OS sheet. */}
<TrustpilotReviewCard analyticsContext="settings" />
</View>
</View>
</PageLayout>
);
};

const DesktopSettings = () => {

Check warning on line 309 in app/(protected)/(tabs)/settings/index.tsx

View workflow job for this annotation

GitHub Actions / lint

'DesktopSettings' is assigned a value but never used. Allowed unused vars must match /^_/u
const { handleLogout } = useUser();
const { isDesktop } = useDimension();
const { status: notificationStatus, request: requestNotificationPermission } =
Expand Down
4 changes: 2 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -451,7 +451,7 @@ function RootLayout() {
{hasSelectedUser && Platform.OS !== 'web' && (
<>
<CashbackStoreReviewTrigger />
<CardDepositStoreReviewTrigger />
<AppOpenStoreReviewTrigger />
</>
)}
</BottomSheetModalProvider>
Expand Down
13 changes: 13 additions & 0 deletions components/StoreReview/AppOpenStoreReviewTrigger.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 0 additions & 12 deletions components/StoreReview/CardDepositStoreReviewTrigger.tsx

This file was deleted.

43 changes: 43 additions & 0 deletions components/Trustpilot/TrustpilotReviewCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className={cn('overflow-hidden rounded-xl bg-[#1c1c1c] px-5 py-4', className)}>
<Text className="text-base font-bold text-white">Enjoying Solid?</Text>
<Text className="mt-1 text-sm text-[#ACACAC]">
Tell others what you think — it takes a minute and it genuinely helps.
</Text>
<TrustpilotWidget analyticsContext={analyticsContext} className="mt-3" />
</View>
);
}
186 changes: 186 additions & 0 deletions components/Trustpilot/TrustpilotWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 `<script>` tag would re-run the same registration for nothing.
* Mounting is per-element (`loadFromElement`), which is what lets one script serve
* however many widgets end up on screen.
*/
const loadBootstrapScript = (): Promise<void> => {
if (typeof document === 'undefined') return Promise.reject(new Error('No document'));
if (window.Trustpilot?.loadFromElement) return Promise.resolve();

const existing = document.querySelector<HTMLScriptElement>(
`script[src="${TRUSTPILOT_BOOTSTRAP_SRC}"]`,
);

const script = existing ?? document.createElement('script');
const promise = new Promise<void>((resolve, reject) => {
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener('error', () => reject(new Error('Trustpilot script failed')), {
once: true,
});
});

if (!existing) {
script.src = TRUSTPILOT_BOOTSTRAP_SRC;
script.async = true;
document.head.appendChild(script);
}

return promise;
};

/**
* Trustpilot's free **Review Collector** widget: stars plus a button that opens Solid's
* review form on trustpilot.com.
*
* Web only. The widget is a DOM script with no React Native build, and the native apps
* have the OS review sheet instead — which is also the only in-app rating prompt the
* App Store guidelines permit. On native this renders nothing at all.
*
* Renders nothing, too, when {@link isTrustpilotConfigured} is false, so a build without
* `EXPO_PUBLIC_TRUSTPILOT_BUSINESS_UNIT_ID` shows no empty frame where a widget should
* be. When the id *is* set but the script cannot load — an ad blocker, a corporate
* proxy, an offline first paint — the fallback below keeps the call to action working,
* because a blocked script should cost us the branding, not the review.
*/
export default function TrustpilotWidget({
analyticsContext,
height = DEFAULT_HEIGHT,
className,
}: TrustpilotWidgetProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scriptState, setScriptState] = useState<ScriptState>('loading');

const configured = isTrustpilotConfigured();
const isWeb = Platform.OS === 'web';

useEffect(() => {
if (!isWeb || !configured) return;

let cancelled = false;
const timeout = setTimeout(() => {
if (!cancelled) setScriptState('failed');
}, SCRIPT_TIMEOUT_MS);

loadBootstrapScript()
.then(() => {
if (cancelled) return;
// The node has to exist before `loadFromElement` is called — the script
// reads its `data-*` attributes to know which widget to render.
window.Trustpilot?.loadFromElement?.(containerRef.current, true);
setScriptState('ready');
track(TRACKING_EVENTS.TRUSTPILOT_WIDGET_SHOWN, { context: analyticsContext });
})
.catch(() => {
if (cancelled) return;
setScriptState('failed');
track(TRACKING_EVENTS.TRUSTPILOT_WIDGET_UNAVAILABLE, { context: analyticsContext });
})
.finally(() => clearTimeout(timeout));

return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [isWeb, configured, analyticsContext]);

// Only reachable on web — everything below the `isWeb` guard is.
const openReviewForm = useCallback(() => {
track(TRACKING_EVENTS.TRUSTPILOT_REVIEW_LINK_OPENED, { context: analyticsContext });
window.open(TRUSTPILOT_REVIEW_URL, '_blank', 'noopener,noreferrer');
}, [analyticsContext]);

if (!isWeb || !configured) return null;

if (scriptState === 'failed') {
return (
<Pressable
onPress={openReviewForm}
className={cn('items-center justify-center py-2 active:opacity-70', className)}
accessibilityRole="link"
accessibilityLabel="Review Solid on Trustpilot"
>
<Text className="text-base font-medium text-white">Review us on Trustpilot</Text>
</Pressable>
);
}

return (
<View className={className}>
{/*
Trustpilot's own embed markup, verbatim: the script finds this node by class
and reads every `data-*` attribute off it, so the shape is theirs rather than
ours. The inner anchor is the no-JS fallback Trustpilot ships with the embed —
it is replaced by the rendered widget the moment the script mounts.
*/}
<div
ref={containerRef}
className="trustpilot-widget"
data-locale={TRUSTPILOT_LOCALE}
data-template-id={TRUSTPILOT_TEMPLATE_ID}
data-businessunit-id={TRUSTPILOT_BUSINESS_UNIT_ID}
data-style-height={`${height}px`}
data-style-width="100%"
data-theme="dark"
style={{ minHeight: height }}
>
<a
href={`https://www.trustpilot.com/review/${TRUSTPILOT_DOMAIN}`}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
track(TRACKING_EVENTS.TRUSTPILOT_REVIEW_LINK_OPENED, {
context: analyticsContext,
})
}
>
Trustpilot
</a>
</div>
</View>
);
}
68 changes: 68 additions & 0 deletions components/Trustpilot/__tests__/TrustpilotWidget.native.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import { Platform } from 'react-native';

import TrustpilotReviewCard from '@/components/Trustpilot/TrustpilotReviewCard';
import TrustpilotWidget from '@/components/Trustpilot/TrustpilotWidget';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { act, create } = require('react-test-renderer');

jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
// `cn` alone, because `@/lib/utils` is a barrel that reaches wagmi and viem —
// ESM that this jest config does not transform.
jest.mock('@/lib/utils', () => ({
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
}));

// Reported as fully configured on purpose. An unconfigured build returns null
// one branch earlier, so testing the default would prove nothing about the
// platform guard — which is the thing that actually protects native.
jest.mock('@/constants/trustpilot', () => ({
...jest.requireActual('@/constants/trustpilot'),
TRUSTPILOT_BUSINESS_UNIT_ID: 'abc123',
isTrustpilotConfigured: () => true,
}));

/**
* The Trustpilot components live in plain `.tsx` files rather than `.web.tsx`
* siblings, so Metro ships them in the native bundle too. That is only safe
* because every web-only construct sits behind a `Platform.OS === 'web'` guard:
* the raw `<div>`/`<a>` elements React Native has no renderer for, the
* `document.createElement` that injects the widget script, and the
* `window.open` behind the fallback link.
*
* A guard like that is easy to move above the code it protects during a later
* edit, and the failure would only ever appear on a device — a red screen on
* the settings screen, invisible to `tsc` and to any web build. So it is pinned
* here rather than left to the comment that explains it.
*/
describe('Trustpilot on native', () => {
it('is running on a native platform', () => {
// Guards the test itself: under a web-flavoured preset every assertion
// below would pass for entirely the wrong reason.
expect(Platform.OS).not.toBe('web');
});

it('renders the widget as nothing, touching no DOM API', () => {
let tree: any;
expect(() => {
act(() => {
tree = create(<TrustpilotWidget analyticsContext="test" />);
});
}).not.toThrow();

expect(tree.toJSON()).toBeNull();
});

it('renders the settings card as nothing', () => {
let tree: any;
expect(() => {
act(() => {
tree = create(<TrustpilotReviewCard />);
});
}).not.toThrow();

expect(tree.toJSON()).toBeNull();
});
});
Loading
Loading