feat(pricing): update landing page pricing table (#207)

## Summary

- **Monthly equivalent pricing**: yearly plan now shows the per-month
price (e.g. €3.90/month) with a \"Billed annually at €46.80\" note
beneath, matching the paywall behaviour
- **Plan rename**: \"Pro Monthly\" and \"Pro Yearly\" renamed to
\"Standard Monthly\" and \"Standard Yearly\" in config
- **Free plan card**: when the `open-banking` feature flag is active, a
Free card is shown first in the pricing grid — same features as paid
plans but without \"Connect bank accounts\" and \"Priority support\"
- **Connect bank accounts feature**: conditionally prepended to paid
plan feature lists when `open-banking` is enabled
- **Grid layout**: column count dynamically accounts for the optional
free plan card

## Screenshots
<img width="1172" height="862" alt="image"
src="https://github.com/user-attachments/assets/1f2895ed-12fe-4b32-a117-19a584014ae7"
/>
<img width="1173" height="821" alt="image"
src="https://github.com/user-attachments/assets/6f56a697-9908-43c6-9b15-6dd8cec35826"
/>
This commit is contained in:
Víctor Falcón 2026-03-05 13:39:52 +00:00 committed by GitHub
parent dc6f12989e
commit 21b03c7c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 40 deletions

View File

@ -47,7 +47,7 @@ return [
'plans' => [
'monthly' => [
'name' => 'Pro Monthly',
'name' => 'Standard Monthly',
'price' => 7.80,
'original_price' => null,
'stripe_lookup_key' => env('STRIPE_PRO_MONTHLY_LOOKUP_KEY', 'whisper_pro_monthly'),
@ -63,7 +63,7 @@ return [
],
],
'yearly' => [
'name' => 'Pro Yearly',
'name' => 'Standard Yearly',
'price' => 46.80,
'original_price' => 93.60,
'stripe_lookup_key' => env('STRIPE_PRO_YEARLY_LOOKUP_KEY', 'whisper_pro_yearly'),
@ -106,7 +106,7 @@ return [
|
*/
'default_plan' => 'monthly',
'default_plan' => 'yearly',
/*
|--------------------------------------------------------------------------

View File

@ -272,11 +272,13 @@
"Connect your Binance account using your API Key and Secret.": "Conecta tu cuenta de Binance usando tu Clave API y Secreto.",
"Connect your Bitpanda account using your API Key.": "Conecta tu cuenta de Bitpanda usando tu Clave API.",
"Connect your Indexa Capital account using your API token.": "Conecta tu cuenta de Indexa Capital usando tu token API.",
"Connect bank accounts": "Conectar cuentas bancarias",
"Connect your bank and sync transactions automatically.": "Conecta tu banco y sincroniza las transacciones automáticamente.",
"Connected": "Conectado",
"Connected account": "Cuenta conectada",
"Connected Bank Accounts": "Cuentas Bancarias Conectadas",
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las cuentas conectadas son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.",
"Connected banks": "Bancos conectados",
"Connecting...": "Conectando...",
"Connecting the dots in your finances...": "Conectando los puntos de tus finanzas...",
"Connections": "Conexiones",
@ -477,12 +479,15 @@
"Forgot password": "Olvidé mi contraseña",
"Forgot password?": "¿Olvidaste tu contraseña?",
"Frequently Asked Questions": "Preguntas Frecuentes",
"Free": "Gratis",
"From": "Desde",
"Full name": "Nombre completo",
"Fully transparent and open source. Review the code yourself.": "Totalmente transparente y de código abierto. Revisa el código tú mismo.",
"Gain/loss": "Ganancia/pérdida",
"Get Started": "Comenzar",
"Get Started Free": "Empieza Gratis",
"Get Started for Free": "Empieza Gratis",
"Get started at no cost. No bank connections included.": "Empieza sin coste. Sin conexiones bancarias incluidas.",
"Get notified when you're close to your limit. Never overspend again.": "Recibe notificaciones cuando estés cerca de tu límite. Nunca vuelvas a gastar de más.",
"Get help when you need it with priority email support.": "Obtén ayuda cuando la necesites con soporte prioritario por correo electrónico.",
"Get a clear picture of where your money goes every month.": "Obtén una imagen clara de a dónde va tu dinero cada mes.",
@ -964,6 +969,7 @@
"Switch to monthly view": "Cambiar a vista mensual",
"Sync Now": "Sincronizar Ahora",
"Sync now": "Sincronizar ahora",
"Sync transactions and balances automatically. Forget about manually importing CSVs from your bank.": "Sincroniza transacciones y saldos automáticamente. Olvídate de importar CSVs manualmente desde tu banco.",
"Sync your encrypted financial data across multiple devices via cloud storage": "Sincroniza tus datos financieros encriptados en múltiples dispositivos a través de almacenamiento en la nube",
"Sync your encrypted financial data across\\n multiple devices via cloud storage": "Sincroniza tus datos financieros cifrados en varios dispositivos a través del almacenamiento en la nube",
"Syncing": "Sincronizando",
@ -1345,6 +1351,7 @@
"finance app, budgeting, expense tracking, secure finance, personal finance, money management, privacy, privacy-first finance app": "aplicación financiera, presupuesto, seguimiento de gastos, finanzas seguras, finanzas personales, gestión del dinero, privacidad, aplicación financiera centrada en la privacidad",
"for the last 12 months": "en los últimos 12 meses",
"for the last 30 days": "en los últimos 30 días",
"forever": "siempre",
"fuchsia": "fucsia",
"gray": "gris",
"green": "verde",

View File

@ -13,6 +13,7 @@ import { Head, router, usePage } from '@inertiajs/react';
import {
CheckIcon,
FolderIcon,
LandmarkIcon,
LockIcon,
PiggyBankIcon,
ReceiptIcon,
@ -43,10 +44,6 @@ function getEquivalentBillingLabel(
return t('one-time');
}
if (billingPeriod === 'year') {
return t('/month');
}
return t('/month');
}
@ -232,6 +229,42 @@ function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
);
}
function FeaturesSection({ features }: { features: string[] }) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 rounded-lg border border-emerald-100/50 bg-emerald-50/25 px-4 py-3 dark:border-emerald-800/50 dark:bg-emerald-950/20">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/50">
<LandmarkIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold">
{__('Connected banks')}
</p>
<p className="text-xs text-balance text-muted-foreground">
{__(
'Sync transactions and balances automatically. Forget about manually importing CSVs from your bank.',
)}
</p>
</div>
<CheckIcon className="h-4 w-4 shrink-0 text-emerald-500" />
</div>
{features.length > 0 && (
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-1">
{features.map((feature) => (
<li key={feature} className="flex items-center gap-1.5">
<CheckIcon className="size-3.5 shrink-0 text-emerald-500" />
<span className="text-xs text-muted-foreground">
{__(feature)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
function CompactPlanCard({
plan,
isSelected,
@ -298,18 +331,34 @@ function PricingSection({
planEntries,
defaultPlan,
currency,
canUseFreePlan,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
canUseFreePlan: boolean;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
const selectedPlanData = planEntries.find(
([key]) => key === selectedPlan,
)?.[1];
useEffect(() => {
if (!canUseFreePlan) {
return;
}
const timer = setTimeout(() => setFreeButtonVisible(true), 5000);
return () => clearTimeout(timer);
}, [canUseFreePlan]);
return (
<div className="flex flex-col gap-4">
{selectedPlanData && (
<FeaturesSection features={selectedPlanData.features} />
)}
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<CompactPlanCard
@ -331,17 +380,23 @@ function PricingSection({
</Button>
</a>
{selectedPlanData && (
<ul className="grid grid-cols-2 gap-x-4 gap-y-1 px-6">
{selectedPlanData.features.slice(0, 4).map((feature) => (
<li key={feature} className="flex items-center gap-1.5">
<CheckIcon className="size-3 shrink-0 text-emerald-500" />
<span className="text-xs text-muted-foreground">
{__(feature)}
</span>
</li>
))}
</ul>
{canUseFreePlan && (
<div
className={cn(
'transition-opacity duration-1000',
freeButtonVisible
? 'opacity-100'
: 'pointer-events-none opacity-0',
)}
>
<Button
variant="ghost"
className="w-full"
onClick={() => router.visit(dashboard().url)}
>
{__('Continue for free')}
</Button>
</div>
)}
</div>
);
@ -370,18 +425,8 @@ export default function Paywall() {
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
canUseFreePlan={canUseFreePlan}
/>
{canUseFreePlan && (
<div className="text-center">
<button
onClick={() => router.visit(dashboard().url)}
className="text-sm text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
>
{__('Continue for free')}
</button>
</div>
)}
</div>
</div>
</>

View File

@ -43,7 +43,7 @@ function getBillingLabel(billingPeriod: string | null): string {
if (!billingPeriod) {
return 'one-time';
}
return `/${billingPeriod}`;
return '/month';
}
function FeatureScreenshot({
@ -99,6 +99,51 @@ function FeatureCard({
);
}
function FreePlanCard({ planFeatures }: { planFeatures: string[] }) {
return (
<div className="flex flex-col overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex flex-1 flex-col p-6 pt-2 sm:pt-12">
<h3 className="text-lg font-semibold">{__('Free')}</h3>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-4xl font-bold tracking-tight">
{__('Free')}
</span>
<span className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__('forever')}
</span>
</div>
<p className="mt-3 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Get started at no cost. No bank connections included.',
)}
</p>
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
<ul className="flex-1 space-y-2.5">
{planFeatures.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="text-sm">{__(feature)}</span>
</li>
))}
</ul>
<Link href="/register" className="mt-8">
<Button
className="w-full cursor-pointer border-[#e3e3e0] bg-transparent py-5 text-base text-[#1b1b18] shadow-sm transition-all hover:bg-[#f5f5f4] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:bg-[#1f1f1e]"
variant="outline"
>
{__('Get Started Free')}
</Button>
</Link>
</div>
</div>
);
}
function LandingPlanCard({
plan,
isDefault,
@ -114,6 +159,10 @@ function LandingPlanCard({
currency: string;
locale: string;
}) {
const { features } = usePage<SharedData>().props;
const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
return (
<div
className={cn(
@ -125,7 +174,7 @@ function LandingPlanCard({
<div
className={cn(
'px-6 pt-6 text-xs font-semibold uppercase',
isDefault && 'text-[#1b1b18] dark:text-[#aaa]',
isDefault && 'text-[#1b1b18]/75 dark:text-[#aaa]',
isBestValue &&
!isDefault &&
'text-[#706f6c] dark:text-[#A1A09A]',
@ -135,27 +184,45 @@ function LandingPlanCard({
</div>
)}
<div className="flex flex-1 flex-col p-6 pt-2">
<div
className={cn(
'flex flex-1 flex-col p-6 pt-2',
!isDefault && !isBestValue && 'sm:pt-12',
)}
>
<h3 className="text-lg font-semibold">{__(plan.name)}</h3>
<div className="mt-3 flex items-baseline gap-2">
{plan.original_price && (
<span className="text-lg font-medium text-[#706f6c] line-through dark:text-[#A1A09A]">
{formatCurrency(
plan.original_price * 100,
(plan.billing_period === 'year'
? plan.original_price / 12
: plan.original_price) * 100,
currency,
locale,
)}
</span>
)}
<span className="text-4xl font-bold tracking-tight">
{formatCurrency(plan.price * 100, currency, locale)}
{formatCurrency(
monthlyEquivalent * 100,
currency,
locale,
)}
</span>
<span className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{getBillingLabel(plan.billing_period)}
</span>
</div>
{plan.billing_period === 'year' && (
<p className="mt-1 text-xs text-[#706f6c] dark:text-[#A1A09A]">
{__('Billed annually at')}{' '}
{formatCurrency(plan.price * 100, currency, locale)}
</p>
)}
<p className="mt-3 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Everything you need to manage your finances securely.',
@ -165,7 +232,10 @@ function LandingPlanCard({
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
<ul className="flex-1 space-y-2.5">
{plan.features.map((feature) => (
{(features['open-banking']
? [__('Connect bank accounts'), ...plan.features]
: plan.features
).map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="text-sm">{__(feature)}</span>
@ -284,7 +354,7 @@ export default function Welcome({
canRegister?: boolean;
hideAuthButtons?: boolean;
}) {
const { appUrl, subscriptionsEnabled, pricing, locale } =
const { appUrl, subscriptionsEnabled, pricing, locale, features } =
usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const { isMobile } = usePwaInstall();
@ -900,14 +970,34 @@ export default function Welcome({
<div
className={cn(
'grid w-full gap-6',
planEntries.length === 1 &&
'mx-auto max-w-md',
planEntries.length === 2 &&
planEntries.length +
(features['open-banking']
? 1
: 0) ===
1 && 'mx-auto max-w-md',
planEntries.length +
(features['open-banking']
? 1
: 0) ===
2 &&
'mx-auto max-w-3xl grid-cols-1 sm:grid-cols-2',
planEntries.length >= 3 &&
planEntries.length +
(features['open-banking']
? 1
: 0) >=
3 &&
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
)}
>
{features['open-banking'] && (
<FreePlanCard
planFeatures={planEntries[0][1].features.filter(
(f) =>
f !==
'Priority support',
)}
/>
)}
{planEntries.map(([key, plan]) => (
<LandingPlanCard
key={key}