feat(subscriptions): show experiment trial terms and refund control
Paywall now states the exact terms for the user's variant right above the CTA: the trial length per selected plan, or "charged today + N-day money-back guarantee" for pay_now. The billing screen shows a money-back card with a confirm-to-refund button and the deadline while the pay_now refund window is open. Spanish translations included.
This commit is contained in:
parent
7c8528f48a
commit
f47a5be798
10
lang/es.json
10
lang/es.json
|
|
@ -1,6 +1,16 @@
|
|||
{
|
||||
"This subscription is no longer eligible for a self-service refund.": "Esta suscripción ya no es elegible para una devolución automática.",
|
||||
"Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Te hemos devuelto el pago, cancelado la suscripción y desconectado tus cuentas bancarias.",
|
||||
"You'll be charged today": "Se te cobrará hoy",
|
||||
"Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.": "¿Cambias de idea? Solicita tú mismo la devolución completa desde Ajustes durante los primeros :days días, sin preguntas.",
|
||||
":days-day free trial": "Prueba gratis de :days días",
|
||||
"You won't be charged until your :days-day trial ends. Cancel anytime before then.": "No se te cobrará hasta que termine tu prueba de :days días. Cancela cuando quieras antes de eso.",
|
||||
"Money-back guarantee": "Garantía de devolución",
|
||||
"Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa hasta el :date. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
|
||||
"Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
|
||||
"Confirm refund": "Confirmar devolución",
|
||||
"Keep my plan": "Mantener mi plan",
|
||||
"Request a refund": "Solicitar devolución",
|
||||
"AI Categorization": "Categorización con IA",
|
||||
"Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.",
|
||||
"Allow AI categorization": "Permitir categorización con IA",
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import {
|
|||
store as storeConsent,
|
||||
} from '@/routes/ai/consent';
|
||||
import { billing } from '@/routes/settings';
|
||||
import { portal } from '@/routes/settings/billing';
|
||||
import { portal, refund as refundRoute } from '@/routes/settings/billing';
|
||||
import { checkout } from '@/routes/subscribe';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Plan } from '@/types/pricing';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { Head, router, usePage } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
CheckIcon,
|
||||
|
|
@ -245,16 +245,95 @@ function UpgradeSection({
|
|||
);
|
||||
}
|
||||
|
||||
interface RefundInfo {
|
||||
canSelfRefund: boolean;
|
||||
deadline: string | null;
|
||||
}
|
||||
|
||||
function RefundCard({
|
||||
deadline,
|
||||
locale,
|
||||
}: {
|
||||
deadline: string | null;
|
||||
locale: string;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const deadlineLabel = deadline
|
||||
? new Date(deadline).toLocaleDateString(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
: null;
|
||||
|
||||
const submit = () => {
|
||||
setProcessing(true);
|
||||
router.post(
|
||||
refundRoute.url(),
|
||||
{},
|
||||
{ onFinish: () => setProcessing(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<h4 className="font-medium text-amber-900 dark:text-amber-200">
|
||||
{__('Money-back guarantee')}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-amber-800/80 dark:text-amber-300/80">
|
||||
{deadlineLabel
|
||||
? __(
|
||||
'Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
|
||||
{ date: deadlineLabel },
|
||||
)
|
||||
: __(
|
||||
'Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{confirming ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={processing}
|
||||
onClick={submit}
|
||||
>
|
||||
{__('Confirm refund')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={processing}
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{__('Keep my plan')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{__('Request a refund')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscribedSection({
|
||||
isDemoAccount,
|
||||
defaultPlan,
|
||||
currency,
|
||||
locale,
|
||||
refund,
|
||||
}: {
|
||||
isDemoAccount: boolean;
|
||||
defaultPlan: Plan | undefined;
|
||||
currency: string;
|
||||
locale: string;
|
||||
refund?: RefundInfo;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -302,6 +381,10 @@ function SubscribedSection({
|
|||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{refund?.canSelfRefund && (
|
||||
<RefundCard deadline={refund.deadline} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -393,8 +476,8 @@ function AiConsentSection({
|
|||
}
|
||||
|
||||
export default function Billing() {
|
||||
const { auth, pricing, locale, features, hasAiConsent } = usePage<
|
||||
SharedData & { hasAiConsent: boolean }
|
||||
const { auth, pricing, locale, features, hasAiConsent, refund } = usePage<
|
||||
SharedData & { hasAiConsent: boolean; refund?: RefundInfo }
|
||||
>().props;
|
||||
const isDemoAccount = auth?.isDemoAccount ?? false;
|
||||
const hasProPlan = auth?.hasProPlan ?? false;
|
||||
|
|
@ -413,6 +496,7 @@ export default function Billing() {
|
|||
defaultPlan={defaultPlan}
|
||||
currency={pricing.currency}
|
||||
locale={locale}
|
||||
refund={refund}
|
||||
/>
|
||||
) : (
|
||||
<UpgradeSection
|
||||
|
|
|
|||
|
|
@ -32,10 +32,62 @@ interface PaywallStats {
|
|||
balancesByCurrency: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ExperimentOffer {
|
||||
variant: string;
|
||||
payNow: boolean;
|
||||
refundWindowDays: number;
|
||||
trialDays: Record<string, number>;
|
||||
}
|
||||
|
||||
interface PaywallPageProps extends SharedData {
|
||||
stats: PaywallStats;
|
||||
canUseFreePlan: boolean;
|
||||
canManageConnectionsForFreePlan: boolean;
|
||||
offer: ExperimentOffer;
|
||||
}
|
||||
|
||||
function TrialTerms({
|
||||
offer,
|
||||
planKey,
|
||||
}: {
|
||||
offer: ExperimentOffer;
|
||||
planKey: string;
|
||||
}) {
|
||||
if (offer.payNow) {
|
||||
return (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-center text-sm dark:border-emerald-900 dark:bg-emerald-950/40">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-200">
|
||||
{__("You'll be charged today")}
|
||||
</p>
|
||||
<p className="mt-1 text-emerald-800/80 dark:text-emerald-300/80">
|
||||
{__(
|
||||
'Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.',
|
||||
{ days: offer.refundWindowDays },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const days = offer.trialDays[planKey] ?? 0;
|
||||
|
||||
if (days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-center text-sm">
|
||||
<p className="font-medium">
|
||||
{__(':days-day free trial', { days })}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{__(
|
||||
"You won't be charged until your :days-day trial ends. Cancel anytime before then.",
|
||||
{ days },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEquivalentBillingLabel(
|
||||
|
|
@ -335,12 +387,14 @@ function PricingSection({
|
|||
currency,
|
||||
canUseFreePlan,
|
||||
canManageConnectionsForFreePlan,
|
||||
offer,
|
||||
}: {
|
||||
planEntries: [string, Plan][];
|
||||
defaultPlan: string;
|
||||
currency: string;
|
||||
canUseFreePlan: boolean;
|
||||
canManageConnectionsForFreePlan: boolean;
|
||||
offer: ExperimentOffer;
|
||||
}) {
|
||||
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
|
||||
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
|
||||
|
|
@ -379,6 +433,8 @@ function PricingSection({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<TrialTerms offer={offer} planKey={selectedPlan} />
|
||||
|
||||
<a href={checkout.url({ query: { plan: selectedPlan } })}>
|
||||
<Button
|
||||
className="w-full bg-emerald-600 py-6 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||
|
|
@ -428,8 +484,13 @@ function PricingSection({
|
|||
}
|
||||
|
||||
export default function Paywall() {
|
||||
const { pricing, stats, canUseFreePlan, canManageConnectionsForFreePlan } =
|
||||
usePage<PaywallPageProps>().props;
|
||||
const {
|
||||
pricing,
|
||||
stats,
|
||||
canUseFreePlan,
|
||||
canManageConnectionsForFreePlan,
|
||||
offer,
|
||||
} = usePage<PaywallPageProps>().props;
|
||||
const planEntries = Object.entries(pricing.plans);
|
||||
|
||||
if (planEntries.length === 0) {
|
||||
|
|
@ -454,6 +515,7 @@ export default function Paywall() {
|
|||
canManageConnectionsForFreePlan={
|
||||
canManageConnectionsForFreePlan
|
||||
}
|
||||
offer={offer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue