From 5621e9087964938dac01526403caf03dac871218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 18 Jul 2026 11:13:14 +0200 Subject: [PATCH] feat: streamline the subscription paywall and add a support escape hatch (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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":** https://github.com/user-attachments/assets/f3c943d8-5dfd-4b08-8f94-1683d38b11f7 **Desktop — without "Continue for free" (support button):** https://github.com/user-attachments/assets/f7542b41-a0d1-491e-ab8b-4734d1c77af2 **Mobile — with "Continue for free":** https://github.com/user-attachments/assets/6265ab86-cea2-4ade-9720-17ea8b003b1c **Mobile — without "Continue for free" (support button):** https://github.com/user-attachments/assets/e53d68ee-3ff9-4091-bba8-028489dd0b8d --- .../Controllers/SubscriptionController.php | 23 +-- lang/es.json | 8 +- resources/js/pages/subscription/paywall.tsx | 136 ++++++++++-------- tests/Feature/SubscriptionTest.php | 5 - 4 files changed, 83 insertions(+), 89 deletions(-) diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 39e6db5f..fbddaac5 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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} + * @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, ]; } diff --git a/lang/es.json b/lang/es.json index 63c2215f..2578aead 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx index 111d4a34..3e9d8496 100644 --- a/resources/js/pages/subscription/paywall.tsx +++ b/resources/js/pages/subscription/paywall.tsx @@ -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; } 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() {
@@ -210,41 +210,6 @@ function StatItem({ ); } -function BalanceDisplay({ - balancesByCurrency, -}: { - balancesByCurrency: Record; -}) { - const locale = useLocale(); - const entries = Object.entries(balancesByCurrency); - - if (entries.length === 0) { - return null; - } - - return ( -
- -
- {entries.map(([currency, amount]) => ( - - {formatCurrency( - Math.abs(amount), - currency, - locale, - 0, - 0, - )} - - ))} -
- - {__('Balance')} - -
- ); -} - 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 && ( - - )} ); @@ -405,8 +365,10 @@ function PricingSection({ canManageConnectionsForFreePlan: boolean; offer: ExperimentOffer; }) { + const { auth } = usePage().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 (
+ {canUseFreePlan ? ( +
+ +
+ ) : ( +
+ +
+ )} + {selectedPlanData && ( )} - {canUseFreePlan && ( + {canUseFreePlan ? (
+ ) : ( +
+ +
)} + +
); } diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 3b41ad8a..898bb6ae 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -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) ); });