refactor: redesign billing settings + upgrade dialog with indies aesthetic
- Billing settings page restructured around the design system: dropped
the 280px-label / 1fr-content split for stacked HeadingSmall sections;
current plan rendered as a hero card with display-font name, price,
amber sticker tile, and an inline primary "Change plan" CTA; payment
method consolidated into a single sticker row with a violet card-icon
tile and the manage CTA on the same line; empty payment-method state
handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
rounded-full CTA pill, sticker check icons, "Everything in {plan}"
copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
with rotating amber save-months badge. While a request is in flight
every plan button is disabled and the active one shows IconLoader2
spinner.
- Account model gains `displayablePaymentMethod()` returning the
card array used by the UI. Resolves the customer-level default first,
falls back to the first attached payment method (Stripe Checkout trials
anchor the card to the subscription rather than the customer, so the
customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
in en/pt-BR/es.
This commit is contained in:
parent
d83ac0612f
commit
3e642ef520
10 changed files with 319 additions and 179 deletions
|
|
@ -117,12 +117,7 @@ public function index(Request $request): Response|RedirectResponse
|
|||
'status' => $invoice->status,
|
||||
'invoice_pdf' => $invoice->invoice_pdf,
|
||||
]),
|
||||
'defaultPaymentMethod' => $account->defaultPaymentMethod()?->card?->only([
|
||||
'brand',
|
||||
'last4',
|
||||
'exp_month',
|
||||
'exp_year',
|
||||
]),
|
||||
'defaultPaymentMethod' => $account->displayablePaymentMethod(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,30 @@ public function isOnTrial(): bool
|
|||
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the displayable card for the billing UI. Falls back to the first
|
||||
* attached payment method when the customer has no `invoice_settings.default_payment_method`
|
||||
* (Stripe Checkout trials anchor the card to the subscription, not the customer).
|
||||
*
|
||||
* @return array{brand: string, last4: string, exp_month: int, exp_year: int}|null
|
||||
*/
|
||||
public function displayablePaymentMethod(): ?array
|
||||
{
|
||||
$paymentMethod = $this->defaultPaymentMethod() ?? $this->paymentMethods()->first();
|
||||
$card = $paymentMethod?->card;
|
||||
|
||||
if (! $card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'brand' => $card->brand,
|
||||
'last4' => $card->last4,
|
||||
'exp_month' => $card->exp_month,
|
||||
'exp_year' => $card->exp_year,
|
||||
];
|
||||
}
|
||||
|
||||
public function stripeEmail(): string
|
||||
{
|
||||
return $this->billing_email ?? $this->owner?->email ?? '';
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@
|
|||
'title' => 'Subscription',
|
||||
'description' => 'Manage your payment method, billing details, and subscription.',
|
||||
'payment_method' => 'Payment method',
|
||||
'no_payment_method' => 'No payment method on file yet.',
|
||||
'expires_on' => 'Expires :month/:year',
|
||||
'manage_label' => 'Subscription',
|
||||
'manage_stripe' => 'Manage on Stripe',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@
|
|||
'title' => 'Suscripción',
|
||||
'description' => 'Gestiona tu método de pago, datos de facturación y suscripción.',
|
||||
'payment_method' => 'Método de pago',
|
||||
'no_payment_method' => 'Aún no hay método de pago registrado.',
|
||||
'expires_on' => 'Vence el :month/:year',
|
||||
'manage_label' => 'Suscripción',
|
||||
'manage_stripe' => 'Gestionar en Stripe',
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -77,6 +77,8 @@
|
|||
'title' => 'Assinatura',
|
||||
'description' => 'Gerencie seu método de pagamento, dados de cobrança e assinatura.',
|
||||
'payment_method' => 'Método de pagamento',
|
||||
'no_payment_method' => 'Nenhum método de pagamento cadastrado.',
|
||||
'expires_on' => 'Expira em :month/:year',
|
||||
'manage_label' => 'Assinatura',
|
||||
'manage_stripe' => 'Gerenciar no Stripe',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { IconCheck } from '@tabler/icons-vue';
|
||||
import { IconCheck, IconInfoCircle, IconLoader2 } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useFeatureAccess } from '@/composables/useFeatureAccess';
|
||||
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
|
||||
import { checkout, swap } from '@/routes/app/billing';
|
||||
|
||||
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
slug: string;
|
||||
slug: PlanSlug;
|
||||
name: string;
|
||||
stripe_monthly_price_id: string | null;
|
||||
stripe_yearly_price_id: string | null;
|
||||
|
|
@ -23,6 +24,11 @@ interface Plan {
|
|||
monthly_credits_limit: number;
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const { open, reason, closeUpgrade } = useUpgradeDialog();
|
||||
const { usage } = useFeatureAccess();
|
||||
const page = usePage();
|
||||
|
|
@ -46,8 +52,11 @@ watch(open, (isOpen) => {
|
|||
}
|
||||
});
|
||||
|
||||
const getPrice = (plan: Plan): string => {
|
||||
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly' : 'monthly'}`;
|
||||
// Mirrors Subscribe.vue: headline price = the per-month figure regardless
|
||||
// of toggle. When yearly is selected, we read the pre-computed per-month
|
||||
// rate so the price stays comparable visually.
|
||||
const getDisplayPrice = (plan: Plan): string => {
|
||||
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`;
|
||||
return trans(key);
|
||||
};
|
||||
|
||||
|
|
@ -60,11 +69,30 @@ const isCurrent = (plan: Plan): boolean => {
|
|||
|
||||
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
|
||||
|
||||
const features = (plan: Plan): string[] => [
|
||||
trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }),
|
||||
trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }),
|
||||
trans('billing.subscribe.features.members', { count: String(plan.member_limit) }),
|
||||
trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
|
||||
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
|
||||
plus: 'starter',
|
||||
pro: 'plus',
|
||||
max: 'pro',
|
||||
};
|
||||
|
||||
const planNameBySlug = (list: Plan[], slug: PlanSlug): string =>
|
||||
list.find((plan) => plan.slug === slug)?.name ?? slug;
|
||||
|
||||
const planTones: Record<PlanSlug, string> = {
|
||||
starter: 'bg-amber-50',
|
||||
plus: 'bg-rose-50',
|
||||
pro: 'bg-violet-100',
|
||||
max: 'bg-emerald-50',
|
||||
};
|
||||
|
||||
const highlights = (plan: Plan): Highlight[] => [
|
||||
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
|
||||
{
|
||||
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
|
||||
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
|
||||
},
|
||||
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
|
||||
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
|
||||
];
|
||||
|
||||
const downgradeBlocker = (plan: Plan): string | null => {
|
||||
|
|
@ -133,91 +161,162 @@ const onOpenChange = (value: boolean) => {
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="onOpenChange">
|
||||
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-4xl">
|
||||
<div class="flex flex-col gap-4 px-6 pt-6 pb-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
||||
<div class="min-w-0">
|
||||
<DialogTitle class="text-2xl font-semibold">
|
||||
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-6xl">
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<div class="px-6 pt-6 text-center">
|
||||
<DialogTitle
|
||||
class="text-3xl font-semibold leading-tight"
|
||||
style="font-family: var(--font-display)"
|
||||
>
|
||||
{{ $t('billing.upgrade_dialog.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="mt-1">
|
||||
<DialogDescription class="mx-auto mt-2 max-w-2xl text-balance text-base text-foreground/70">
|
||||
{{ reason ?? $t('billing.upgrade_dialog.description') }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isOnYearly"
|
||||
class="inline-flex h-9 shrink-0 items-center rounded-lg bg-muted p-[3px] text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-full items-center rounded-md px-3 font-medium transition-colors"
|
||||
:class="!isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="isYearly = false"
|
||||
>
|
||||
{{ $t('billing.subscribe.monthly') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-full items-center gap-2 rounded-md px-3 font-medium transition-colors"
|
||||
:class="isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="isYearly = true"
|
||||
>
|
||||
{{ $t('billing.subscribe.yearly') }}
|
||||
<Badge variant="secondary" class="whitespace-nowrap text-[10px]">
|
||||
<!-- Billing toggle pill (hidden when user is already on yearly) -->
|
||||
<div v-if="!isOnYearly" class="flex justify-center pt-6 pb-2">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="!isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = false"
|
||||
>
|
||||
{{ $t('billing.subscribe.monthly') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = true"
|
||||
>
|
||||
{{ $t('billing.subscribe.yearly') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none absolute top-1/2 left-full ml-3 -translate-y-1/2 -rotate-3 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all',
|
||||
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
|
||||
]"
|
||||
>
|
||||
<span>←</span>
|
||||
{{ $t('billing.subscribe.save_months') }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 px-6 pt-8 pb-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="relative flex flex-col rounded-xl border bg-card p-5"
|
||||
:class="[
|
||||
isPopular(plan) ? 'border-primary ring-1 ring-primary' : '',
|
||||
isCurrent(plan) && !isPopular(plan) ? 'border-foreground/40' : '',
|
||||
]"
|
||||
>
|
||||
<Badge v-if="isPopular(plan)" class="absolute -top-3 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
{{ $t('billing.subscribe.popular') }}
|
||||
</Badge>
|
||||
<Badge v-else-if="isCurrent(plan)" variant="secondary" class="absolute -top-3 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
{{ $t('billing.upgrade_dialog.current_badge') }}
|
||||
</Badge>
|
||||
|
||||
<h3 class="text-lg font-semibold">{{ plan.name }}</h3>
|
||||
<div class="mt-2 flex items-baseline gap-1">
|
||||
<span class="text-3xl font-bold tracking-tight">{{ getPrice(plan) }}</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
/{{ isYearly ? $t('billing.subscribe.per_year') : $t('billing.subscribe.per_month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="my-5 flex-1 space-y-2">
|
||||
<li
|
||||
v-for="feature in features(plan)"
|
||||
:key="feature"
|
||||
class="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<IconCheck class="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
:variant="isCurrent(plan) || isBlocked(plan) ? 'secondary' : isPopular(plan) ? 'default' : 'outline'"
|
||||
:loading="processing === plan.id"
|
||||
:disabled="(processing !== null && processing !== plan.id) || isCurrent(plan) || isBlocked(plan)"
|
||||
:title="downgradeBlocker(plan) ?? undefined"
|
||||
@click="handleSelect(plan)"
|
||||
>
|
||||
{{ ctaLabel(plan) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan cards -->
|
||||
<div class="grid grid-cols-1 gap-5 px-6 pt-8 pb-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
planTones[plan.slug],
|
||||
'relative flex flex-col rounded-2xl border-2 border-foreground p-6 transition-shadow',
|
||||
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="isPopular(plan)"
|
||||
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
|
||||
>
|
||||
⭐ {{ $t('billing.subscribe.popular') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isCurrent(plan)"
|
||||
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs"
|
||||
>
|
||||
{{ $t('billing.upgrade_dialog.current_badge') }}
|
||||
</span>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold tracking-tight text-foreground">
|
||||
{{ plan.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black tabular-nums text-foreground">
|
||||
{{ getDisplayPrice(plan) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-foreground/60">
|
||||
{{ $t('billing.subscribe.per_month') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-tight text-foreground/60">
|
||||
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="processing !== null || isCurrent(plan) || isBlocked(plan)"
|
||||
:title="downgradeBlocker(plan) ?? undefined"
|
||||
:class="[
|
||||
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
|
||||
]"
|
||||
@click="handleSelect(plan)"
|
||||
>
|
||||
<IconLoader2 v-if="processing === plan.id" class="size-4 animate-spin" />
|
||||
{{ ctaLabel(plan) }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm font-bold leading-snug text-foreground">
|
||||
<template v-if="inheritsFrom[plan.slug]">
|
||||
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('billing.subscribe.features_included') }}
|
||||
</template>
|
||||
</p>
|
||||
<div
|
||||
v-for="highlight in highlights(plan)"
|
||||
:key="highlight.text"
|
||||
class="flex items-start gap-2.5"
|
||||
>
|
||||
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs">
|
||||
<IconCheck class="size-3 text-foreground" stroke-width="3" />
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
|
||||
{{ highlight.text }}
|
||||
<Tooltip v-if="highlight.tooltip">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
|
||||
:aria-label="highlight.tooltip"
|
||||
>
|
||||
<IconInfoCircle class="size-3.5" stroke-width="2" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs text-sm leading-snug">
|
||||
{{ highlight.tooltip }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconDownload, IconFileText } from '@tabler/icons-vue';
|
||||
import { IconCreditCard, IconDownload, IconFileText, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -79,104 +80,119 @@ const { openUpgrade } = useUpgradeDialog();
|
|||
<SettingsTabsNav :tabs="tabs" active="billing" />
|
||||
|
||||
<section class="space-y-12">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.plan.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('billing.plan.description') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="openUpgrade()"
|
||||
>
|
||||
{{ $t('billing.plan.change') }}
|
||||
<!-- ───── Current plan hero card ───── -->
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('billing.plan.title')"
|
||||
:description="$t('billing.plan.description')"
|
||||
/>
|
||||
|
||||
<div class="rounded-2xl border-2 border-foreground bg-card p-6 shadow-2xs">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
|
||||
{{ $t('billing.plan.label') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h3
|
||||
class="text-3xl font-semibold leading-tight text-foreground"
|
||||
style="font-family: var(--font-display)"
|
||||
>
|
||||
{{ plan?.name ?? 'No plan' }}
|
||||
</h3>
|
||||
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="success">{{ $t('billing.plan.active') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'past_due'" variant="destructive">{{ $t('billing.plan.past_due') }}</Badge>
|
||||
<Badge v-else-if="subscription?.ends_at" variant="secondary">{{ $t('billing.plan.cancelling') }}</Badge>
|
||||
</div>
|
||||
<p class="text-base text-foreground/70">
|
||||
<span class="text-2xl font-bold tabular-nums text-foreground">{{ monthlyPrice(plan?.slug) }}</span>
|
||||
<span class="ml-1">/{{ $t('billing.plan.month') }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="onTrial && trialEndsAt"
|
||||
class="text-sm font-semibold text-foreground/70"
|
||||
>
|
||||
{{ $t('billing.plan.trial_ends') }}: <span class="text-foreground">{{ date.formatDate(trialEndsAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-4 shrink-0">
|
||||
<span class="inline-flex size-14 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-amber-200 shadow-2xs">
|
||||
<IconSparkles class="size-7 text-foreground" stroke-width="2" />
|
||||
</span>
|
||||
<Button @click="openUpgrade()">
|
||||
{{ $t('billing.plan.change') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───── Payment method ───── -->
|
||||
<div v-if="hasSubscription" class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('billing.subscription.title')"
|
||||
:description="$t('billing.subscription.description')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-2xl border-2 border-foreground bg-card p-4 shadow-2xs">
|
||||
<span class="inline-flex size-12 rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
|
||||
<IconCreditCard class="size-6 text-foreground" stroke-width="2" />
|
||||
</span>
|
||||
<div v-if="defaultPaymentMethod" class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
|
||||
{{ $t('billing.subscription.payment_method') }}
|
||||
</p>
|
||||
<p class="text-base font-bold capitalize text-foreground">
|
||||
{{ defaultPaymentMethod.brand }} •••• {{ defaultPaymentMethod.last4 }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-foreground/60">
|
||||
{{ $t('billing.subscription.expires_on', {
|
||||
month: defaultPaymentMethod.exp_month.toString().padStart(2, '0'),
|
||||
year: defaultPaymentMethod.exp_year.toString(),
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
|
||||
{{ $t('billing.subscription.payment_method') }}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-foreground/70">
|
||||
{{ $t('billing.subscription.no_payment_method') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button as="a" :href="portal.url()" class="shrink-0">
|
||||
{{ $t('billing.subscription.manage_stripe') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<span>{{ $t('billing.plan.label') }}</span>
|
||||
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="default">{{ $t('billing.plan.active') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'past_due'" variant="destructive">{{ $t('billing.plan.past_due') }}</Badge>
|
||||
<Badge v-else-if="subscription?.ends_at" variant="secondary">{{ $t('billing.plan.cancelling') }}</Badge>
|
||||
</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ plan?.name ?? 'No plan' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.price') }}</span>
|
||||
<span class="ml-auto text-sm font-medium tabular-nums">
|
||||
{{ monthlyPrice(plan?.slug) }}/{{ $t('billing.plan.month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="onTrial && trialEndsAt" class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.trial_ends') }}</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ date.formatDate(trialEndsAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSubscription" class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.subscription.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('billing.subscription.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- ───── Invoices ───── -->
|
||||
<div v-if="invoices.length > 0" class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('billing.invoices.title')"
|
||||
:description="$t('billing.invoices.description')"
|
||||
/>
|
||||
|
||||
<div class="divide-y">
|
||||
<div v-if="defaultPaymentMethod" class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.subscription.payment_method') }}</span>
|
||||
<span class="ml-auto text-sm font-medium capitalize">
|
||||
{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}
|
||||
<span class="text-muted-foreground">
|
||||
({{ defaultPaymentMethod.exp_month }}/{{ defaultPaymentMethod.exp_year }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.subscription.manage_label') }}</span>
|
||||
<a :href="portal.url()" class="ml-auto">
|
||||
<Button variant="outline" size="sm">
|
||||
{{ $t('billing.subscription.manage_stripe') }}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="invoices.length > 0" class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.invoices.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('billing.invoices.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="invoice in invoices"
|
||||
:key="invoice.id"
|
||||
class="flex items-center gap-3 py-3"
|
||||
class="flex items-center gap-4 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs"
|
||||
>
|
||||
<IconFileText class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="inline-flex size-10 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-100 shadow-2xs">
|
||||
<IconFileText class="size-5 text-foreground" stroke-width="2" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-sm">{{ date.formatDate(invoice.date) }}</span>
|
||||
<span class="ml-2 text-sm text-muted-foreground">{{ invoice.total }}</span>
|
||||
<p class="text-sm font-bold text-foreground">{{ date.formatDate(invoice.date) }}</p>
|
||||
<p class="text-xs font-medium tabular-nums text-foreground/60">{{ invoice.total }}</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
<Badge :variant="invoice.status === 'paid' ? 'success' : 'outline'">
|
||||
{{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
as="a"
|
||||
:href="invoice.invoice_pdf"
|
||||
target="_blank"
|
||||
|
|
|
|||
Loading…
Reference in a new issue