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\Features\SubscriptionExperiment;
use App\Models\AccountBalance;
use App\Models\User;
use App\Models\UserLead;
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
{
$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 [
'accountsCount' => $accounts->count(),
'accountsCount' => $user->accounts()->count(),
'transactionsCount' => $user->transactions()->count(),
'categoriesCount' => $user->categories()->count(),
'automationRulesCount' => $user->automationRules()->count(),
'balancesByCurrency' => $balancesByCurrency,
];
}

View File

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

View File

@ -1,3 +1,4 @@
import { SupportDialog } from '@/components/support-dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCountUp } from '@/hooks/use-count-up';
@ -15,12 +16,13 @@ import {
CheckIcon,
FolderIcon,
LandmarkIcon,
LifeBuoy,
LockIcon,
PiggyBankIcon,
ReceiptIcon,
TrendingUpIcon,
UsersIcon,
WalletIcon,
XIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
@ -28,8 +30,6 @@ interface PaywallStats {
accountsCount: number;
transactionsCount: number;
categoriesCount: number;
automationRulesCount: number;
balancesByCurrency: Record<string, number>;
}
interface ExperimentOffer {
@ -114,12 +114,12 @@ const socialProofs = [
{
icon: TrendingUpIcon,
highlightKey: '15% more savings',
textKey: 'after 3 months with Whisper Money',
textKey: 'after 3 months',
},
{
icon: PiggyBankIcon,
highlightKey: '23% better',
textKey: 'spending awareness reported',
textKey: 'spending awareness',
},
{
icon: LockIcon,
@ -128,8 +128,8 @@ const socialProofs = [
},
{
icon: UsersIcon,
highlightKey: '1,200+ users',
textKey: 'taking control of their finances',
highlightKey: '2,500+ users',
textKey: 'trusting us',
},
];
@ -150,7 +150,7 @@ function SocialProofSlider() {
<div className="flex flex-col items-center gap-4">
<div
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" />
</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 }) {
const hasData =
stats.accountsCount > 0 ||
@ -282,11 +247,6 @@ function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
delay={300}
/>
)}
{Object.keys(stats.balancesByCurrency).length > 0 && (
<BalanceDisplay
balancesByCurrency={stats.balancesByCurrency}
/>
)}
</CardContent>
</Card>
);
@ -405,8 +365,10 @@ function PricingSection({
canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer;
}) {
const { auth } = usePage<SharedData>().props;
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
const [escapeVisible, setEscapeVisible] = useState(false);
const [supportOpen, setSupportOpen] = useState(false);
const locale = useLocale();
const selectedPlanData = planEntries.find(
@ -418,15 +380,50 @@ function PricingSection({
: '';
useEffect(() => {
if (!canUseFreePlan) {
return;
}
const timer = setTimeout(() => setFreeButtonVisible(true), 5000);
const delay = canUseFreePlan ? 5000 : 7000;
const timer = setTimeout(() => setEscapeVisible(true), delay);
return () => clearTimeout(timer);
}, [canUseFreePlan]);
const continueForFree = () => router.visit(dashboard().url);
const revealClasses = escapeVisible
? 'opacity-100'
: 'pointer-events-none opacity-0';
return (
<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 && (
<FeaturesSection
features={selectedPlanData.features.filter(
@ -479,24 +476,45 @@ function PricingSection({
</div>
)}
{canUseFreePlan && (
{canUseFreePlan ? (
<div
className={cn(
'transition-opacity duration-1000',
freeButtonVisible
? 'opacity-100'
: 'pointer-events-none opacity-0',
'hidden transition-opacity duration-1000 md:block',
revealClasses,
)}
>
<Button
variant="ghost"
className="w-full"
onClick={() => router.visit(dashboard().url)}
onClick={continueForFree}
>
{__('Continue for free')}
</Button>
</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>
);
}

View File

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