whisper-money/resources/js/components/subscription/upgrade-dialog.tsx

163 lines
5.6 KiB
TypeScript

import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { captureEvent } from '@/lib/posthog';
import { cn } from '@/lib/utils';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { ZapIcon } from 'lucide-react';
import { useState } from 'react';
/**
* The upsell entry point a checkout starts from. Mirrors the PHP
* App\Enums\UpsellSource so revenue can be attributed per point.
*/
export type UpsellSource = 'ai_categorization' | 'connections' | 'accounts';
export function PlanCard({
plan,
isSelected,
onSelect,
currency,
locale,
}: {
plan: Plan;
isSelected: boolean;
onSelect: () => void;
currency: string;
locale: string;
}) {
const savingsPercent =
plan.original_price && plan.billing_period === 'year'
? Math.round(
((plan.original_price - plan.price) / plan.original_price) *
100,
)
: null;
const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex flex-1 flex-col rounded-lg border p-3 text-left transition-all',
isSelected
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
: 'border-border bg-card hover:border-muted-foreground/50',
)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{plan.billing_period === 'year'
? __('Annual')
: __('Monthly')}
</span>
{savingsPercent && savingsPercent > 0 && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{__('Saving')} {savingsPercent}%
</span>
)}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold">
{formatCurrency(monthlyEquivalent * 100, currency, locale)}
</span>
<span className="text-sm text-muted-foreground">
{__('/month')}
</span>
</div>
{plan.billing_period === 'year' && (
<span className="mt-2 text-xs text-muted-foreground">
{__('Billed annually at')}{' '}
{formatCurrency(plan.price * 100, currency, locale)}
</span>
)}
</button>
);
}
/**
* A contextual "this is a paid feature" dialog with a plan picker that starts
* Stripe checkout. Reused across upsell points (AI categorization, bank
* connections, connected accounts); each passes its own copy and `source`.
*/
export function UpgradeDialog({
open,
onOpenChange,
title,
description,
source,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
source: UpsellSource;
}) {
const { pricing, locale } = usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const [selectedPlan, setSelectedPlan] = useState(pricing.defaultPlan);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
currency={pricing.currency}
locale={locale}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Maybe later')}
</Button>
<a
href={checkout.url({
query: { plan: selectedPlan, source },
})}
onClick={() =>
captureEvent('upgrade_checkout_started', {
source,
plan: selectedPlan,
})
}
>
<Button className="w-full bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700">
<ZapIcon className="size-4" />
{__('Upgrade to Standard Plan')}
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}