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:
Paulo Castellano 2026-05-06 17:00:25 -03:00
parent d83ac0612f
commit 3e642ef520
10 changed files with 319 additions and 179 deletions

View file

@ -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(),
]);
}

View file

@ -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 ?? '';

View file

@ -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',
],

View file

@ -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

View file

@ -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',
],

View file

@ -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>

View file

@ -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"