feat: translate billing page and fix flash messages

- Move billing routes from /billing to /settings/billing
- Add billing translations (en/pt-br)
- Update billing/Index.vue to use $t() for all strings
- Fix all controllers using route('dashboard') to use route('calendar') or route('accounts')
- Replace ->with('error', ...) pattern with session()->flash('flash.banner', ...)
- Add self-hosted mode support for hasActiveSubscription() and workspace methods
- Add flash translations for account connection errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paulo Castellano 2026-01-26 12:01:53 -03:00
parent 1d056f426f
commit 2239d2480c
21 changed files with 291 additions and 69 deletions

View file

@ -45,7 +45,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session([
@ -174,15 +177,19 @@ public function selectPage(Request $request)
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
session()->flash('flash.banner', __('accounts.flash.session_expired'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
return Inertia::render('accounts/FacebookPageSelect', [

View file

@ -41,7 +41,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session([

View file

@ -44,7 +44,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
return $this->redirectToProvider($request, $this->driver, $this->scopes);

View file

@ -49,7 +49,10 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session([
@ -128,15 +131,19 @@ public function selectPage(Request $request): Response|RedirectResponse
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
session()->flash('flash.banner', __('accounts.flash.session_expired'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
return Inertia::render('accounts/LinkedInPageSelect', [

View file

@ -43,7 +43,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
return $this->redirectToProvider($request, $this->driver, $this->scopes);

View file

@ -41,7 +41,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session([

View file

@ -41,7 +41,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session(['social_reconnect_id' => $existingAccount?->id]);

View file

@ -39,7 +39,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
return $this->redirectToProvider($request, $this->driver, $this->scopes);

View file

@ -41,7 +41,10 @@ public function connect(Request $request): Response|RedirectResponse
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
session()->flash('flash.banner', __('accounts.flash.already_connected'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
session([
@ -164,15 +167,19 @@ public function selectChannel(Request $request)
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
session()->flash('flash.banner', __('accounts.flash.session_expired'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('accounts');
}
// Fetch YouTube channels
@ -183,8 +190,10 @@ public function selectChannel(Request $request)
$this->forgetSocialConnectSession();
session()->forget('youtube_oauth');
return redirect()->route($redirectRoute)
->with('error', 'No YouTube channels found. Please create a channel first.');
session()->flash('flash.banner', __('accounts.flash.no_youtube_channels'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route($redirectRoute);
}
return inertia('accounts/YouTubeChannelSelect', [

View file

@ -89,9 +89,9 @@ public function processing(Request $request): Response|RedirectResponse
$user = $request->user();
$status = $request->query('status', 'processing');
// If already subscribed, redirect to dashboard
// If already subscribed, redirect to calendar
if ($user->subscribed('default')) {
return redirect()->route('dashboard');
return redirect()->route('calendar');
}
// Validate status

View file

@ -52,9 +52,14 @@ public function ownedWorkspacesCount(): int
/**
* Check if user can create more workspaces based on subscription.
* In self-hosted mode, users can create unlimited workspaces.
*/
public function canCreateWorkspace(): bool
{
if (config('trypost.self_hosted')) {
return true;
}
if (! $this->hasActiveSubscription()) {
return $this->ownedWorkspacesCount() === 0;
}
@ -66,9 +71,14 @@ public function canCreateWorkspace(): bool
/**
* Increment workspace quantity on subscription.
* Skipped in self-hosted mode (no Stripe subscription).
*/
public function incrementWorkspaceQuantity(): void
{
if (config('trypost.self_hosted')) {
return;
}
if ($this->hasActiveSubscription()) {
$this->subscription('default')->incrementQuantity();
}
@ -76,9 +86,14 @@ public function incrementWorkspaceQuantity(): void
/**
* Decrement workspace quantity on subscription.
* Skipped in self-hosted mode (no Stripe subscription).
*/
public function decrementWorkspaceQuantity(): void
{
if (config('trypost.self_hosted')) {
return;
}
if ($this->hasActiveSubscription()) {
$subscription = $this->subscription('default');
@ -90,9 +105,14 @@ public function decrementWorkspaceQuantity(): void
/**
* Sync subscription quantity with actual workspace count.
* Skipped in self-hosted mode (no Stripe subscription).
*/
public function syncWorkspaceQuantity(): void
{
if (config('trypost.self_hosted')) {
return;
}
if ($this->hasActiveSubscription()) {
$count = $this->ownedWorkspacesCount();

View file

@ -88,9 +88,14 @@ public function language(): BelongsTo
/**
* Check if user has an active subscription.
* In self-hosted mode, always returns true (no subscription required).
*/
public function hasActiveSubscription(): bool
{
if (config('trypost.self_hosted')) {
return true;
}
return $this->subscribed('default');
}

View file

@ -59,5 +59,10 @@
'flash' => [
'disconnected' => 'Account disconnected successfully!',
'connected' => 'Account connected successfully!',
'session_expired' => 'Session expired. Please try again.',
'workspace_not_found' => 'Workspace not found.',
'already_connected' => 'This platform is already connected.',
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',
],
];

37
lang/en/billing.php Normal file
View file

@ -0,0 +1,37 @@
<?php
return [
'title' => 'Subscription',
'description' => 'Manage your subscription and payment method',
'trial' => [
'title' => 'Trial period active',
'description' => 'Your trial ends on :date. After that, your subscription will be charged automatically.',
],
'subscription' => [
'title' => 'Your Subscription',
'workspaces' => 'Workspaces',
'quantity' => 'Subscription quantity',
'expires' => 'Expires :date',
'canceled_on' => 'Your subscription will be canceled on :date',
'manage' => 'Manage on Stripe',
],
'invoices' => [
'title' => 'Invoices',
'description' => 'Payment history',
'empty' => 'No invoices found',
'paid' => 'Paid',
],
'status' => [
'active' => 'Active',
'canceled' => 'Canceled',
'incomplete' => 'Incomplete',
'incomplete_expired' => 'Expired',
'past_due' => 'Past due',
'trialing' => 'Trial',
'unpaid' => 'Unpaid',
],
];

View file

@ -59,5 +59,10 @@
'flash' => [
'disconnected' => 'Conta desconectada com sucesso!',
'connected' => 'Conta conectada com sucesso!',
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
'workspace_not_found' => 'Workspace não encontrado.',
'already_connected' => 'Esta plataforma já está conectada.',
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',
],
];

37
lang/pt-br/billing.php Normal file
View file

@ -0,0 +1,37 @@
<?php
return [
'title' => 'Assinatura',
'description' => 'Gerencie sua assinatura e método de pagamento',
'trial' => [
'title' => 'Período de teste ativo',
'description' => 'Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.',
],
'subscription' => [
'title' => 'Sua Assinatura',
'workspaces' => 'Workspaces',
'quantity' => 'Quantidade da assinatura',
'expires' => 'Expira em :date',
'canceled_on' => 'Sua assinatura será cancelada em :date',
'manage' => 'Gerenciar no Stripe',
],
'invoices' => [
'title' => 'Faturas',
'description' => 'Histórico de pagamentos',
'empty' => 'Nenhuma fatura encontrada',
'paid' => 'Pago',
],
'status' => [
'active' => 'Ativo',
'canceled' => 'Cancelado',
'incomplete' => 'Incompleto',
'incomplete_expired' => 'Expirado',
'past_due' => 'Vencido',
'trialing' => 'Teste',
'unpaid' => 'Não pago',
],
];

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { IconCreditCard, IconFileText, IconBuilding, IconExternalLink, IconSparkles } from '@tabler/icons-vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@ -7,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { portal } from '@/routes/billing';
import { type BreadcrumbItemType } from '@/types';
interface Subscription {
@ -44,26 +46,17 @@ defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Subscription',
href: '/billing',
title: trans('billing.title'),
href: '/settings/billing',
},
];
function openPortal() {
window.location.href = '/billing/portal';
window.location.href = portal.url();
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Active',
canceled: 'Canceled',
incomplete: 'Incomplete',
incomplete_expired: 'Expired',
past_due: 'Past due',
trialing: 'Trial',
unpaid: 'Unpaid',
};
return labels[status] || status;
return trans(`billing.status.${status}`) || status;
}
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
@ -74,23 +67,22 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
</script>
<template>
<Head title="Subscription" />
<Head :title="$t('billing.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Subscription</h1>
<h1 class="text-2xl font-bold tracking-tight">{{ $t('billing.title') }}</h1>
<p class="text-muted-foreground">
Manage your subscription and payment method
{{ $t('billing.description') }}
</p>
</div>
<Alert v-if="onTrial" class="border-primary/50 bg-primary/5">
<IconSparkles class="h-4 w-4 text-primary" />
<AlertTitle>Trial period active</AlertTitle>
<AlertTitle>{{ $t('billing.trial.title') }}</AlertTitle>
<AlertDescription>
Your trial ends on <strong>{{ trialEndsAt }}</strong>.
After that, your subscription will be charged automatically.
{{ $t('billing.trial.description', { date: trialEndsAt }) }}
</AlertDescription>
</Alert>
@ -100,7 +92,7 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<IconBuilding class="h-5 w-5" />
Your Subscription
{{ $t('billing.subscription.title') }}
</CardTitle>
<Badge :variant="getStatusVariant(subscription?.stripe_status || '')">
{{ getStatusLabel(subscription?.stripe_status || '') }}
@ -110,11 +102,11 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
<CardContent class="space-y-4">
<div class="flex justify-between items-center p-4 bg-muted rounded-lg">
<div>
<p class="text-sm text-muted-foreground">Workspaces</p>
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.workspaces') }}</p>
<p class="text-2xl font-bold">{{ workspacesCount }}</p>
</div>
<div class="text-right">
<p class="text-sm text-muted-foreground">Subscription quantity</p>
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.quantity') }}</p>
<p class="text-2xl font-bold">{{ subscription?.quantity || 0 }}</p>
</div>
</div>
@ -124,21 +116,21 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
<div>
<p class="font-medium capitalize">{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}</p>
<p class="text-sm text-muted-foreground">
Expires {{ defaultPaymentMethod.exp_month }}/{{ defaultPaymentMethod.exp_year }}
{{ $t('billing.subscription.expires', { date: `${defaultPaymentMethod.exp_month}/${defaultPaymentMethod.exp_year}` }) }}
</p>
</div>
</div>
<div v-if="subscription?.ends_at" class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-950 dark:border-yellow-800">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
Your subscription will be canceled on {{ subscription.ends_at }}
{{ $t('billing.subscription.canceled_on', { date: subscription.ends_at }) }}
</p>
</div>
</CardContent>
<CardFooter>
<Button @click="openPortal" variant="outline" class="w-full">
<IconExternalLink class="mr-2 h-4 w-4" />
Manage on Stripe
{{ $t('billing.subscription.manage') }}
</Button>
</CardFooter>
</Card>
@ -147,15 +139,15 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconFileText class="h-5 w-5" />
Invoices
{{ $t('billing.invoices.title') }}
</CardTitle>
<CardDescription>
Payment history
{{ $t('billing.invoices.description') }}
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="invoices.length === 0" class="text-center py-6 text-muted-foreground">
No invoices found
{{ $t('billing.invoices.empty') }}
</div>
<div v-else class="space-y-3">
<a
@ -170,7 +162,7 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
<p class="text-sm text-muted-foreground">{{ invoice.total }}</p>
</div>
<Badge variant="outline">
{{ invoice.status === 'paid' ? 'Paid' : invoice.status }}
{{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }}
</Badge>
</a>
</div>

View file

@ -140,9 +140,9 @@
Route::put('labels/{label}', [WorkspaceLabelController::class, 'update'])->name('labels.update');
Route::delete('labels/{label}', [WorkspaceLabelController::class, 'destroy'])->name('labels.destroy');
// Billing
Route::get('billing', [BillingController::class, 'index'])->name('billing.index');
Route::get('billing/portal', [BillingController::class, 'portal'])->name('billing.portal');
// Billing (under settings prefix)
Route::get('settings/billing', [BillingController::class, 'index'])->name('billing.index');
Route::get('settings/billing/portal', [BillingController::class, 'portal'])->name('billing.portal');
});
require __DIR__.'/settings.php';

View file

@ -186,7 +186,8 @@
$response = $this->actingAs($this->user)->get(route('social.linkedin-page.connect'));
$response->assertRedirect();
$response->assertSessionHas('error', 'This platform is already connected.');
$response->assertSessionHas('flash.banner', __('accounts.flash.already_connected'));
$response->assertSessionHas('flash.bannerStyle', 'danger');
});
test('user can reconnect disconnected linkedin page account', function () {

View file

@ -54,10 +54,15 @@
);
});
test('create workspace redirects to billing if user already has workspace and no subscription', function () {
test('create workspace shows form when user already has workspace in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('workspaces.create'));
$response->assertRedirect(route('billing.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('workspaces/Create', false)
);
});
// Store tests
@ -84,12 +89,19 @@
]);
});
test('store workspace redirects to billing for second workspace without subscription', function () {
test('store workspace creates second workspace in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->post(route('workspaces.store'), [
'name' => 'Second Workspace',
]);
$response->assertRedirect(route('billing.index'));
$response->assertRedirect(route('calendar'));
$this->assertDatabaseHas('workspaces', [
'name' => 'Second Workspace',
'user_id' => $this->user->id,
]);
});
test('store workspace validates name is required', function () {

View file

@ -81,13 +81,17 @@
expect($user->ownedWorkspacesCount())->toBe(3);
});
test('user without subscription can create first workspace', function () {
test('user without subscription can create first workspace in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
expect($user->canCreateWorkspace())->toBeTrue();
});
test('user without subscription cannot create second workspace', function () {
test('user without subscription cannot create second workspace in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
@ -95,7 +99,22 @@
expect($user->canCreateWorkspace())->toBeFalse();
});
test('user with subscription can create workspaces up to quantity', function () {
test('user can create unlimited workspaces in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create();
$workspaces = Workspace::factory()->count(5)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
}
expect($user->canCreateWorkspace())->toBeTrue();
});
test('user with subscription can create workspaces up to quantity in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
@ -111,7 +130,9 @@
expect($user->canCreateWorkspace())->toBeTrue();
});
test('user with subscription cannot exceed workspace quantity', function () {
test('user with subscription cannot exceed workspace quantity in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
@ -130,7 +151,9 @@
expect($user->canCreateWorkspace())->toBeFalse();
});
test('increment workspace quantity does nothing without subscription', function () {
test('increment workspace quantity does nothing without subscription in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$user->incrementWorkspaceQuantity();
@ -138,7 +161,20 @@
expect($user->subscription('default'))->toBeNull();
});
test('decrement workspace quantity does nothing without subscription', function () {
test('increment workspace quantity is skipped in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create();
// Should not throw any errors even without subscription
$user->incrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
});
test('decrement workspace quantity does nothing without subscription in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$user->decrementWorkspaceQuantity();
@ -146,7 +182,20 @@
expect($user->subscription('default'))->toBeNull();
});
test('sync workspace quantity does nothing without subscription', function () {
test('decrement workspace quantity is skipped in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create();
// Should not throw any errors even without subscription
$user->decrementWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
});
test('sync workspace quantity does nothing without subscription in SaaS mode', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
@ -159,7 +208,25 @@
expect($user->subscription('default'))->toBeNull();
});
test('sync workspace quantity is skipped in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create();
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
foreach ($workspaces as $workspace) {
$workspace->members()->attach($user->id, ['role' => 'owner']);
}
// Should not throw any errors even without subscription
$user->syncWorkspaceQuantity();
expect($user->subscription('default'))->toBeNull();
});
test('sync workspace quantity does nothing with zero workspaces', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',