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:
parent
dc6f12989e
commit
21b03c7c36
|
|
@ -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',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue