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:
parent
41ac3e90e1
commit
5621e90879
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue