Add a support button to the paywall when it can't be skipped

When the user has no free-plan escape (canUseFreePlan is false) the
paywall previously offered no way out at all. Add 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 separate
free-button timer collapses into one escapeVisible timer whose delay
depends on canUseFreePlan.
This commit is contained in:
Víctor Falcón 2026-07-18 08:35:22 +02:00
parent eec0acfd63
commit 83f915d725
1 changed files with 50 additions and 10 deletions

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,6 +16,7 @@ import {
CheckIcon,
FolderIcon,
LandmarkIcon,
LifeBuoy,
LockIcon,
PiggyBankIcon,
ReceiptIcon,
@ -363,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(
@ -376,25 +380,23 @@ 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 freeRevealClasses = freeButtonVisible
const revealClasses = escapeVisible
? 'opacity-100'
: 'pointer-events-none opacity-0';
return (
<div className="flex flex-col gap-4">
{canUseFreePlan && (
{canUseFreePlan ? (
<div
className={cn(
'fixed top-4 right-4 z-50 transition-opacity duration-1000 md:hidden',
freeRevealClasses,
revealClasses,
)}
>
<button
@ -405,6 +407,21 @@ function PricingSection({
<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 && (
@ -459,11 +476,11 @@ function PricingSection({
</div>
)}
{canUseFreePlan && (
{canUseFreePlan ? (
<div
className={cn(
'hidden transition-opacity duration-1000 md:block',
freeRevealClasses,
revealClasses,
)}
>
<Button
@ -474,7 +491,30 @@ function PricingSection({
{__('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>
);
}