feat: streamline the subscription paywall and add a support escape hatch (#694)

## What

Several related tweaks to the subscription paywall shown before a user
subscribes:

1. **Remove the Balance stat** from the top stat card.
2. **Mobile dismiss (X)** replacing the bottom "Continue for free"
button on small screens (when the free plan is available).
3. **Support button** when the paywall *can't* be skipped.
4. **Copy updates** to the social-proof slider.

## 1. Remove the Balance stat

The Balance column showed summed account balances per currency (e.g.
`58.031 MXN 65 US$`). As a variable-length, multi-currency string it
overflowed the stat card on mobile and broke the four-column layout. The
remaining stats (Accounts, Transactions, Categories) are short integer
counts, so it now reads as a clean three-column row.

- `SubscriptionController@getUserStats`: also drops the
`balancesByCurrency` computation — which ran an **N+1 query** (one
`AccountBalance` lookup per account) — and the never-rendered
`automationRulesCount`.

## 2. Mobile dismiss (X)

The "Continue for free" escape (shown only when `canUseFreePlan`) now
renders on **mobile** as a dismiss **X** fixed to the top-right corner
instead of a full-width bottom button, matching the common
mobile-paywall pattern. Same 5s delayed fade-in, same action (continue
on the free plan). On **desktop (md+)** the bottom button is unchanged.
Reuses the app's `MobileBackButton` treatment (44px target, rounded
pill, border/shadow/backdrop-blur) for legibility.

## 3. Support escape hatch (`!canUseFreePlan`)

When the user has **no** free-plan escape, the paywall previously
offered no way out. It now shows a subtle **help/support** affordance in
the same slots the free-plan escape uses — bottom button on desktop,
top-right corner on mobile. It fades in slightly later than the
free-plan escape (**7s vs 5s**) and is deliberately low-key (muted
ghost, no pill) so it doesn't compete with the subscribe CTA. It opens
the existing `SupportDialog` (join the community / email support) — the
same one behind the user-menu "Support" entry.

The two escapes are mutually exclusive per page load, so the free-button
timer collapses into one `escapeVisible` timer whose delay depends on
`canUseFreePlan`.

## 4. Copy updates

Shortened the social-proof lines (`taking control of their finances` →
`trusting us`, etc.), bumped the user count to `2,500+ users`, slightly
reduced the proof icon. `lang/es.json` updated to match.

## QA

Real browser QA across all four states (see Demo), each ending by
exercising the actual action:
- **With free plan** → the X (mobile) / "Continue for free" (desktop)
navigates to `/dashboard`.
- **Without free plan** → the "Need help?" button opens the support
modal (Join the community / Email support).

No console errors from the paywall.

## Demo

**Desktop — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-desktop-with-free.mp4 here -->


https://github.com/user-attachments/assets/f3c943d8-5dfd-4b08-8f94-1683d38b11f7



**Desktop — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-desktop-no-free.mp4 here -->


https://github.com/user-attachments/assets/f7542b41-a0d1-491e-ab8b-4734d1c77af2



**Mobile — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-mobile-with-free.mp4 here -->


https://github.com/user-attachments/assets/6265ab86-cea2-4ade-9720-17ea8b003b1c



**Mobile — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-mobile-no-free.mp4 here -->


https://github.com/user-attachments/assets/e53d68ee-3ff9-4091-bba8-028489dd0b8d
This commit is contained in:
Víctor Falcón 2026-07-18 11:13:14 +02:00 committed by GitHub
parent 41ac3e90e1
commit 5621e90879
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 83 additions and 89 deletions

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Actions\Subscription\RefundSelfServe; use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment; use App\Features\SubscriptionExperiment;
use App\Models\AccountBalance;
use App\Models\User; use App\Models\User;
use App\Models\UserLead; use App\Models\UserLead;
use App\Services\Discord\DiscordWebhook; use App\Services\Discord\DiscordWebhook;
@ -52,32 +51,14 @@ class SubscriptionController extends Controller
} }
/** /**
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int, automationRulesCount: int, balancesByCurrency: array<string, int>} * @return array{accountsCount: int, transactionsCount: int, categoriesCount: int}
*/ */
private function getUserStats(User $user): array private function getUserStats(User $user): array
{ {
$accounts = $user->accounts()->get();
$balancesByCurrency = [];
foreach ($accounts as $account) {
$latestBalance = AccountBalance::query()
->where('account_id', $account->id)
->orderBy('balance_date', 'desc')
->value('balance') ?? 0;
$currency = $account->currency_code;
if (! isset($balancesByCurrency[$currency])) {
$balancesByCurrency[$currency] = 0;
}
$balancesByCurrency[$currency] += $latestBalance;
}
return [ return [
'accountsCount' => $accounts->count(), 'accountsCount' => $user->accounts()->count(),
'transactionsCount' => $user->transactions()->count(), 'transactionsCount' => $user->transactions()->count(),
'categoriesCount' => $user->categories()->count(), 'categoriesCount' => $user->categories()->count(),
'automationRulesCount' => $user->automationRules()->count(),
'balancesByCurrency' => $balancesByCurrency,
]; ];
} }

View File

@ -179,7 +179,7 @@
"/12 min": "/12 min", "/12 min": "/12 min",
"/month": "/mes", "/month": "/mes",
"/year": "/año", "/year": "/año",
"1,200+ users": "1.200+ usuarios", "2,500+ users": "2.500+ usuarios",
"1. Data Controller": "1. Controlador de Datos", "1. Data Controller": "1. Controlador de Datos",
"10. Children's Privacy": "10. Privacidad de los Niños", "10. Children's Privacy": "10. Privacidad de los Niños",
"10. Limitation of Liability": "10. Limitación de Responsabilidad", "10. Limitation of Liability": "10. Limitación de Responsabilidad",
@ -2049,7 +2049,7 @@
"account.": "cuenta.", "account.": "cuenta.",
"accounts": "cuentas", "accounts": "cuentas",
"acres": "acres", "acres": "acres",
"after 3 months with Whisper Money": "después de 3 meses con Whisper Money", "after 3 months": "después de 3 meses",
"amber": "ámbar", "amber": "ámbar",
"balance": "balance", "balance": "balance",
"balance record": "registro de balance", "balance record": "registro de balance",
@ -2116,10 +2116,10 @@
"row(s) total": "fila(s) en total", "row(s) total": "fila(s) en total",
"rule(s) total.": "regla(s) en total.", "rule(s) total.": "regla(s) en total.",
"slate": "pizarra", "slate": "pizarra",
"spending awareness reported": "conocimiento del gasto reportado", "spending awareness": "conocimiento del gasto",
"stone": "piedra", "stone": "piedra",
"summary_large_image": "summary_large_image", "summary_large_image": "summary_large_image",
"taking control of their finances": "tomando el control de sus finanzas", "trusting us": "registrados",
"teal": "cerceta", "teal": "cerceta",
"this month": "este mes", "this month": "este mes",
"to confirm.": "para confirmar.", "to confirm.": "para confirmar.",

View File

@ -1,3 +1,4 @@
import { SupportDialog } from '@/components/support-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCountUp } from '@/hooks/use-count-up'; import { useCountUp } from '@/hooks/use-count-up';
@ -15,12 +16,13 @@ import {
CheckIcon, CheckIcon,
FolderIcon, FolderIcon,
LandmarkIcon, LandmarkIcon,
LifeBuoy,
LockIcon, LockIcon,
PiggyBankIcon, PiggyBankIcon,
ReceiptIcon, ReceiptIcon,
TrendingUpIcon, TrendingUpIcon,
UsersIcon, UsersIcon,
WalletIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -28,8 +30,6 @@ interface PaywallStats {
accountsCount: number; accountsCount: number;
transactionsCount: number; transactionsCount: number;
categoriesCount: number; categoriesCount: number;
automationRulesCount: number;
balancesByCurrency: Record<string, number>;
} }
interface ExperimentOffer { interface ExperimentOffer {
@ -114,12 +114,12 @@ const socialProofs = [
{ {
icon: TrendingUpIcon, icon: TrendingUpIcon,
highlightKey: '15% more savings', highlightKey: '15% more savings',
textKey: 'after 3 months with Whisper Money', textKey: 'after 3 months',
}, },
{ {
icon: PiggyBankIcon, icon: PiggyBankIcon,
highlightKey: '23% better', highlightKey: '23% better',
textKey: 'spending awareness reported', textKey: 'spending awareness',
}, },
{ {
icon: LockIcon, icon: LockIcon,
@ -128,8 +128,8 @@ const socialProofs = [
}, },
{ {
icon: UsersIcon, icon: UsersIcon,
highlightKey: '1,200+ users', highlightKey: '2,500+ users',
textKey: 'taking control of their finances', textKey: 'trusting us',
}, },
]; ];
@ -150,7 +150,7 @@ function SocialProofSlider() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div <div
key={`icon-${currentIndex}`} key={`icon-${currentIndex}`}
className="flex h-16 w-16 animate-in items-center justify-center rounded-full bg-emerald-100 duration-500 zoom-in-95 fade-in dark:bg-emerald-900/30" className="flex h-14 w-14 animate-in items-center justify-center rounded-full bg-emerald-100 duration-500 zoom-in-95 fade-in dark:bg-emerald-900/30"
> >
<Icon className="h-8 w-8 text-emerald-600 dark:text-emerald-400" /> <Icon className="h-8 w-8 text-emerald-600 dark:text-emerald-400" />
</div> </div>
@ -210,41 +210,6 @@ function StatItem({
); );
} }
function BalanceDisplay({
balancesByCurrency,
}: {
balancesByCurrency: Record<string, number>;
}) {
const locale = useLocale();
const entries = Object.entries(balancesByCurrency);
if (entries.length === 0) {
return null;
}
return (
<div className="flex flex-1 flex-col items-center gap-0.5">
<WalletIcon className="mb-1.5 h-4 w-4 text-emerald-500" />
<div className="flex flex-col items-center">
{entries.map(([currency, amount]) => (
<span key={currency} className="text-xl font-bold">
{formatCurrency(
Math.abs(amount),
currency,
locale,
0,
0,
)}
</span>
))}
</div>
<span className="text-xs text-muted-foreground">
{__('Balance')}
</span>
</div>
);
}
function FinancialSnapshot({ stats }: { stats: PaywallStats }) { function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
const hasData = const hasData =
stats.accountsCount > 0 || stats.accountsCount > 0 ||
@ -282,11 +247,6 @@ function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
delay={300} delay={300}
/> />
)} )}
{Object.keys(stats.balancesByCurrency).length > 0 && (
<BalanceDisplay
balancesByCurrency={stats.balancesByCurrency}
/>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@ -405,8 +365,10 @@ function PricingSection({
canManageConnectionsForFreePlan: boolean; canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer; offer: ExperimentOffer;
}) { }) {
const { auth } = usePage<SharedData>().props;
const [selectedPlan, setSelectedPlan] = useState(defaultPlan); const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false); const [escapeVisible, setEscapeVisible] = useState(false);
const [supportOpen, setSupportOpen] = useState(false);
const locale = useLocale(); const locale = useLocale();
const selectedPlanData = planEntries.find( const selectedPlanData = planEntries.find(
@ -418,15 +380,50 @@ function PricingSection({
: ''; : '';
useEffect(() => { useEffect(() => {
if (!canUseFreePlan) { const delay = canUseFreePlan ? 5000 : 7000;
return; const timer = setTimeout(() => setEscapeVisible(true), delay);
}
const timer = setTimeout(() => setFreeButtonVisible(true), 5000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [canUseFreePlan]); }, [canUseFreePlan]);
const continueForFree = () => router.visit(dashboard().url);
const revealClasses = escapeVisible
? 'opacity-100'
: 'pointer-events-none opacity-0';
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{canUseFreePlan ? (
<div
className={cn(
'fixed top-4 right-4 z-50 transition-opacity duration-1000 md:hidden',
revealClasses,
)}
>
<button
onClick={continueForFree}
aria-label={__('Continue for free')}
className="flex size-11 items-center justify-center rounded-full border border-border/75 bg-sidebar/50 text-primary shadow-lg shadow-black/20 backdrop-blur transition-all duration-200 hover:bg-sidebar/80 active:scale-95"
>
<XIcon className="size-5" />
</button>
</div>
) : (
<div
className={cn(
'fixed top-4 right-4 z-50 transition-opacity duration-1000 md:hidden',
revealClasses,
)}
>
<button
onClick={() => setSupportOpen(true)}
aria-label={__('Need help?')}
className="flex size-11 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground"
>
<LifeBuoy className="size-5" />
</button>
</div>
)}
{selectedPlanData && ( {selectedPlanData && (
<FeaturesSection <FeaturesSection
features={selectedPlanData.features.filter( features={selectedPlanData.features.filter(
@ -479,24 +476,45 @@ function PricingSection({
</div> </div>
)} )}
{canUseFreePlan && ( {canUseFreePlan ? (
<div <div
className={cn( className={cn(
'transition-opacity duration-1000', 'hidden transition-opacity duration-1000 md:block',
freeButtonVisible revealClasses,
? 'opacity-100'
: 'pointer-events-none opacity-0',
)} )}
> >
<Button <Button
variant="ghost" variant="ghost"
className="w-full" className="w-full"
onClick={() => router.visit(dashboard().url)} onClick={continueForFree}
> >
{__('Continue for free')} {__('Continue for free')}
</Button> </Button>
</div> </div>
) : (
<div
className={cn(
'hidden transition-opacity duration-1000 md:block',
revealClasses,
)}
>
<Button
variant="ghost"
size="sm"
className="w-full text-muted-foreground"
onClick={() => setSupportOpen(true)}
>
<LifeBuoy className="size-4" />
{__('Need help?')}
</Button>
</div>
)} )}
<SupportDialog
open={supportOpen}
onOpenChange={setSupportOpen}
user={auth.user}
/>
</div> </div>
); );
} }

View File

@ -2,7 +2,6 @@
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Account; use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Models\Category; use App\Models\Category;
use App\Models\Transaction; use App\Models\Transaction;
@ -50,7 +49,6 @@ test('paywall page includes user stats', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$account = Account::factory()->for($user)->create(['currency_code' => 'USD']); $account = Account::factory()->for($user)->create(['currency_code' => 'USD']);
AccountBalance::factory()->for($account)->create(['balance' => 150000]);
Transaction::factory()->count(3)->for($user)->for($account)->create(); Transaction::factory()->count(3)->for($user)->for($account)->create();
Category::factory()->count(2)->for($user)->create(); Category::factory()->count(2)->for($user)->create();
@ -64,11 +62,8 @@ test('paywall page includes user stats', function () {
->has('stats.accountsCount') ->has('stats.accountsCount')
->has('stats.transactionsCount') ->has('stats.transactionsCount')
->has('stats.categoriesCount') ->has('stats.categoriesCount')
->has('stats.automationRulesCount')
->has('stats.balancesByCurrency')
->where('stats.accountsCount', 1) ->where('stats.accountsCount', 1)
->where('stats.transactionsCount', 3) ->where('stats.transactionsCount', 3)
->where('stats.balancesByCurrency.USD', 150000)
); );
}); });