feat: redesign billing, onboarding, sidebar, and settings architecture

- Sidebar reorganized: Workspace group (connections, hashtags, labels,
  API keys, settings) and Account group (settings, usage, billing)
- Account group only visible to owner and hidden in self-hosted mode
- Onboarding simplified: role -> account (connect socials) -> completed
  -> redirect to /subscribe. Removed Subscription setup step.
- Subscribe page redesigned with 4 plan cards, monthly/yearly toggle,
  trial info, and per-plan features list
- Billing page redesigned following Sendkit layout (sections with
  sidebar labels)
- Processing page uses usePoll with immediate watch for subscription
  activation
- Cancel URL redirects directly to /subscribe
- Account settings page with name and billing_email (syncs with Stripe)
- Usage page with ring meters for all plan limits
- Settings layout tabs only for user pages (profile, password,
  notifications). Workspace/API keys/billing are standalone pages.
- GoogleAuthButton extracted as reusable component
- WorkspaceRole TypeScript enum for type-safe role checks in frontend
- Trial period changed to 7 days
- Fixed onboarding loop when user confirms email
- All 1101 tests passing
This commit is contained in:
Paulo Castellano 2026-04-15 00:33:38 -03:00
parent 2da15df96c
commit ded1c998ec
40 changed files with 1005 additions and 647 deletions

View file

@ -23,6 +23,7 @@ public static function execute(array $data): User
$account = Account::create([
'name' => data_get($data, 'name')."'s Account",
'billing_email' => data_get($data, 'email'),
]);
$user = User::create([

View file

@ -30,15 +30,22 @@ public function subscribe(Request $request): Response|RedirectResponse
public function checkout(Request $request, Plan $plan): SymfonyResponse
{
$account = $request->user()->account;
$user = $request->user();
$account = $user->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_unless($user->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$priceId = $request->input('interval', 'monthly') === 'yearly'
? $plan->stripe_yearly_price_id
: $plan->stripe_monthly_price_id;
$request->validate([
'price_id' => ['required', 'string'],
]);
abort_if(! $priceId, 422, 'Plan price not configured');
$priceId = $request->input('price_id');
abort_unless(
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
422,
'Invalid price for this plan',
);
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
@ -50,8 +57,8 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
->trialDays(config('cashier.trial_days'));
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing').'?status=success',
'cancel_url' => route('app.billing.processing').'?status=cancelled',
'success_url' => route('app.billing.processing'),
'cancel_url' => route('app.subscribe'),
]);
$account->update(['plan_id' => $plan->id]);
@ -59,22 +66,12 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
return Inertia::location($checkoutSession->url);
}
public function processing(Request $request): Response|RedirectResponse
public function processing(Request $request): Response
{
$account = $request->user()->account;
$status = $request->query('status', 'processing');
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
if (! in_array($status, ['processing', 'success', 'cancelled'])) {
$status = 'processing';
}
return Inertia::render('billing/Processing', [
'accountId' => $account?->id,
'status' => $status,
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
]);
}
@ -119,11 +116,17 @@ public function swap(Request $request, Plan $plan): RedirectResponse
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
$priceId = $request->input('interval', 'monthly') === 'yearly'
? $plan->stripe_yearly_price_id
: $plan->stripe_monthly_price_id;
$request->validate([
'price_id' => ['required', 'string'],
]);
abort_if(! $priceId, 422, 'Plan price not configured');
$priceId = $request->input('price_id');
abort_unless(
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
422,
'Invalid price for this plan',
);
$account->subscription(Account::SUBSCRIPTION_NAME)->swap($priceId);
$account->update(['plan_id' => $plan->id]);

View file

@ -4,19 +4,15 @@
namespace App\Http\Controllers\App;
use App\Enums\Plan\Slug as PlanSlug;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
@ -43,10 +39,10 @@ public function storeRole(Request $request): RedirectResponse
'setup' => Setup::Connections,
]);
return redirect()->route('app.onboarding.connect');
return redirect()->route('app.onboarding.account');
}
public function connect(Request $request): Response|RedirectResponse
public function account(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Connections);
if ($redirect) {
@ -70,62 +66,24 @@ public function connect(Request $request): Response|RedirectResponse
])->values();
}
return Inertia::render('onboarding/Connect', [
return Inertia::render('onboarding/Account', [
'platforms' => $platforms,
'hasWorkspace' => $workspace !== null,
]);
}
public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
public function storeAccount(Request $request): RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
$request->user()->update(['setup' => Setup::Completed]);
if (config('trypost.self_hosted')) {
$user->update(['setup' => Setup::Completed]);
session()->flash('flash.banner', __('auth.flash.welcome'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.calendar');
}
$user->update(['setup' => Setup::Subscription]);
$account = $user->account;
$defaultPlan = Plan::where('slug', PlanSlug::Starter)->firstOrFail();
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $defaultPlan->stripe_monthly_price_id)
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'));
$checkoutSession = $subscription->checkout([
'success_url' => route('app.onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('app.onboarding.connect'),
]);
$account->update(['plan_id' => $defaultPlan->id]);
return Inertia::location($checkoutSession->url);
}
public function complete(Request $request): RedirectResponse
{
$user = $request->user();
$user->update([
'setup' => Setup::Completed,
]);
session()->flash('flash.banner', __('auth.flash.welcome_trial'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.calendar');
return redirect()->route('app.subscribe');
}
private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse
@ -140,8 +98,7 @@ private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse
return match ($user->setup) {
Setup::Role => redirect()->route('app.onboarding.role'),
Setup::Connections => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.subscribe'),
Setup::Connections => redirect()->route('app.onboarding.account'),
default => redirect()->route('app.onboarding.role'),
};
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\App\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AccountController extends Controller
{
public function edit(Request $request): Response
{
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$account = $request->user()->account;
return Inertia::render('settings/Account', [
'account' => [
'id' => $account->id,
'name' => $account->name,
'billing_email' => $account->billing_email,
],
]);
}
public function update(Request $request): RedirectResponse
{
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'billing_email' => ['required', 'email', 'max:255'],
]);
$account = $request->user()->account;
$account->update([
'name' => data_get($validated, 'name'),
'billing_email' => data_get($validated, 'billing_email'),
]);
if ($account->hasStripeId()) {
$account->updateStripeCustomer([
'name' => data_get($validated, 'name'),
'email' => data_get($validated, 'billing_email'),
]);
}
session()->flash('flash.banner', __('settings.flash.account_updated'));
session()->flash('flash.bannerStyle', 'success');
return back();
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Features\AiImagesLimit;
use App\Features\AiVideosLimit;
use App\Features\DataRetentionDays;
use App\Features\MemberLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Http\Controllers\App\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class UsageController extends Controller
{
public function index(Request $request): Response
{
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$account = $request->user()->account;
$totalSocialAccounts = 0;
$totalMembers = $account->users()->count();
foreach ($account->workspaces as $workspace) {
$totalSocialAccounts += $workspace->socialAccounts()->count();
}
return Inertia::render('settings/Usage', [
'plan' => $account->plan,
'usage' => [
'workspaceCount' => $account->workspaces()->count(),
'workspaceLimit' => Feature::for($account)->value(WorkspaceLimit::class),
'socialAccountCount' => $totalSocialAccounts,
'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class),
'memberCount' => $totalMembers,
'memberLimit' => Feature::for($account)->value(MemberLimit::class),
'aiImagesUsed' => 0,
'aiImagesLimit' => Feature::for($account)->value(AiImagesLimit::class),
'aiVideosUsed' => 0,
'aiVideosLimit' => Feature::for($account)->value(AiVideosLimit::class),
'dataRetentionDays' => Feature::for($account)->value(DataRetentionDays::class),
],
]);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Middleware\App;
use App\Enums\User\Setup;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -22,6 +23,11 @@ public function handle(Request $request, Closure $next): Response
return redirect()->route('login');
}
// Let onboarding middleware handle users still in setup
if ($user->setup !== Setup::Completed) {
return $next($request);
}
$account = $user->account;
if ($account && $account->hasActiveSubscription()) {

View file

@ -32,8 +32,7 @@ public function handle(Request $request, Closure $next): Response
// Map setup status to allowed routes
$allowedRoutes = match ($user->setup) {
Setup::Role => ['app.onboarding.role', 'app.onboarding.role.store'],
Setup::Connections => ['app.onboarding.connect', 'app.onboarding.connect.store', 'app.social.*'],
Setup::Subscription => ['app.subscribe', 'app.billing.*', 'app.onboarding.complete'],
Setup::Connections => ['app.onboarding.account', 'app.onboarding.account.store', 'app.social.*'],
default => ['app.onboarding.role', 'app.onboarding.role.store'],
};
@ -49,8 +48,7 @@ public function handle(Request $request, Closure $next): Response
// Redirect to appropriate step
return match ($user->setup) {
Setup::Role => redirect()->route('app.onboarding.role'),
Setup::Connections => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.subscribe'),
Setup::Connections => redirect()->route('app.onboarding.account'),
default => redirect()->route('app.onboarding.role'),
};
}

View file

@ -4,7 +4,6 @@
namespace App\Listeners;
use App\Enums\User\Setup;
use App\Events\SubscriptionCreated;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
@ -42,15 +41,9 @@ public function handle(WebhookReceived $event): void
}
}
protected function handleSubscriptionCreated(Account $workspace, array $payload): void
protected function handleSubscriptionCreated(Account $account, array $payload): void
{
$owner = $workspace->owner;
if ($owner && $owner->setup === Setup::Subscription) {
$owner->update(['setup' => Setup::Completed]);
}
SubscriptionCreated::dispatch($workspace);
SubscriptionCreated::dispatch($account);
}
protected function handleSubscriptionUpdated(Account $workspace, array $payload): void

View file

@ -22,6 +22,7 @@ class Account extends Model
protected $fillable = [
'owner_id',
'name',
'billing_email',
'plan_id',
];
@ -66,7 +67,7 @@ public function isOnTrial(): bool
public function stripeEmail(): string
{
return $this->owner?->email ?? '';
return $this->billing_email ?? $this->owner?->email ?? '';
}
public function stripeName(): string

View file

@ -14,6 +14,7 @@ public function up(): void
$table->uuid('id')->primary();
$table->uuid('owner_id')->nullable();
$table->string('name');
$table->string('billing_email')->nullable();
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();

View file

@ -1,44 +1,58 @@
<?php
return [
'title' => 'Subscription',
'description' => 'Manage your subscription and payment method',
'title' => 'Billing',
'subscribe' => [
'page_title' => 'Start your free trial',
'title' => 'Start your free trial',
'description' => ':days days free to explore all features.',
'page_title' => 'Choose your plan',
'title' => 'Choose the right plan for you',
'description' => 'Start with a :days-day free trial. No charge until your trial ends.',
'trial_info' => ':days-day free trial, then billed automatically',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'mo',
'billed_monthly' => 'billed monthly',
'billed_yearly' => 'billed yearly',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start :days-day free trial',
'cancel_anytime' => 'Cancel anytime. No questions asked.',
'switch_workspace' => 'Switch workspace',
'card_required' => 'Credit card required to start your trial.',
'cancel_anytime' => 'Cancel anytime before your trial ends — no charge.',
'features' => [
'calendar' => 'Visual drag-and-drop calendar',
'scheduling' => 'Unlimited post scheduling',
'media' => 'Images, carousels & stories',
'video' => 'Video publishing across platforms',
'team' => 'Team collaboration & workspaces',
'hashtags' => 'Hashtag groups & labels',
'social_accounts' => ':count social accounts',
'workspaces' => ':count workspaces',
'members' => ':count team members',
'ai_images' => ':count AI images/mo',
'ai_videos' => ':count AI videos/mo',
'data_retention' => ':days data retention',
],
],
'trial' => [
'title' => 'Trial period active',
'description' => 'Your trial ends on :date. After that, your subscription will be charged automatically.',
'plan' => [
'title' => 'Plan',
'description' => 'Manage your subscription plan.',
'change' => 'Change plan',
'label' => 'Plan',
'price' => 'Price',
'month' => 'month',
'trial' => 'Trial',
'active' => 'Active',
'past_due' => 'Past due',
'cancelling' => 'Cancelling',
'trial_ends' => 'Trial ends',
],
'subscription' => [
'title' => 'Your Subscription',
'status' => 'Status',
'workspaces' => 'Workspaces',
'quantity' => 'Subscription quantity',
'expires' => 'Expires :date',
'canceled_on' => 'Your subscription will be canceled on :date',
'manage' => 'Manage on Stripe',
'title' => 'Subscription',
'description' => 'Manage your payment method, billing details, and subscription.',
'payment_method' => 'Payment method',
'manage_label' => 'Subscription',
'manage_stripe' => 'Manage on Stripe',
],
'invoices' => [
'title' => 'Invoices',
'description' => 'Payment history',
'description' => 'Download your past invoices.',
'empty' => 'No invoices found',
'paid' => 'Paid',
],
@ -53,14 +67,4 @@
'cancelled_description' => 'Your checkout was cancelled. No charges were made.',
'retry' => 'Try again',
],
'status' => [
'active' => 'Active',
'canceled' => 'Canceled',
'incomplete' => 'Incomplete',
'incomplete_expired' => 'Expired',
'past_due' => 'Past due',
'trialing' => 'Trial',
'unpaid' => 'Unpaid',
],
];

View file

@ -146,7 +146,19 @@
],
],
'account' => [
'title' => 'Account Settings',
'description' => 'Manage your account name and billing email',
'name' => 'Account Name',
'name_placeholder' => 'My Company',
'billing_email' => 'Billing Email',
'billing_email_placeholder' => 'billing@company.com',
'billing_email_hint' => 'This email will be used for invoices and billing communications from Stripe.',
'submit' => 'Save',
],
'flash' => [
'account_updated' => 'Account updated successfully!',
'profile_updated' => 'Profile updated successfully!',
'language_updated' => 'Language updated successfully!',
'password_updated' => 'Password updated successfully!',

View file

@ -6,7 +6,6 @@
'create_workspace' => 'Create workspace',
'create_post' => 'Create post',
'profile' => 'Profile',
'account' => 'Account',
'log_out' => 'Log out',
'workspace' => 'Workspace: :name',
@ -22,7 +21,8 @@
'groups' => [
'posts' => 'Posts',
'configuration' => 'Configurations',
'workspace' => 'Workspace',
'account' => 'Account',
'support' => 'Support',
],
@ -36,14 +36,20 @@
'drafts' => 'Drafts',
],
'config' => [
'workspace' => [
'connections' => 'Connections',
'brands' => 'Brands',
'hashtags' => 'Hashtags',
'labels' => 'Labels',
'api_keys' => 'API Keys',
'settings' => 'Settings',
],
'account' => [
'settings' => 'Settings',
'usage' => 'Usage',
'billing' => 'Billing',
],
'notifications' => 'Notifications',
'mark_all_read' => 'Mark all as read',
'mark_as_read' => 'Mark as read',

24
lang/en/usage.php Normal file
View file

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Usage',
'section_account' => 'Account',
'section_account_description' => 'Quotas and limits for your :plan plan.',
'section_ai' => 'AI Generation',
'section_ai_description' => 'AI image and video generation usage for the current month.',
'section_data' => 'Data',
'section_data_description' => 'Data retention and storage for your plan.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Social Accounts',
'members' => 'Members',
'ai_images' => 'Images',
'ai_videos' => 'Videos',
'data_retention' => 'Data Retention',
'unlimited' => 'Unlimited',
'days' => 'days',
'year' => 'year',
'years' => 'years',
];

View file

@ -6,7 +6,6 @@
'create_workspace' => 'Crear workspace',
'create_post' => 'Crear post',
'profile' => 'Perfil',
'account' => 'Cuenta',
'log_out' => 'Cerrar sesión',
'workspace' => 'Workspace: :name',
@ -22,7 +21,8 @@
'groups' => [
'posts' => 'Posts',
'configuration' => 'Configuración',
'workspace' => 'Workspace',
'account' => 'Cuenta',
'support' => 'Soporte',
],
@ -36,14 +36,20 @@
'drafts' => 'Borradores',
],
'config' => [
'workspace' => [
'connections' => 'Conexiones',
'brands' => 'Marcas',
'hashtags' => 'Hashtags',
'labels' => 'Etiquetas',
'api_keys' => 'API Keys',
'settings' => 'Configuración',
],
'account' => [
'settings' => 'Configuración',
'usage' => 'Uso',
'billing' => 'Facturación',
],
'notifications' => 'Notificaciones',
'mark_all_read' => 'Marcar todo como leído',
'mark_as_read' => 'Marcar como leído',

24
lang/es/usage.php Normal file
View file

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Uso',
'section_account' => 'Cuenta',
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
'section_ai' => 'Generación AI',
'section_ai_description' => 'Uso de generación de imágenes y videos AI del mes actual.',
'section_data' => 'Datos',
'section_data_description' => 'Retención de datos y almacenamiento de tu plan.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Cuentas Sociales',
'members' => 'Miembros',
'ai_images' => 'Imágenes',
'ai_videos' => 'Videos',
'data_retention' => 'Retención de Datos',
'unlimited' => 'Ilimitado',
'days' => 'días',
'year' => 'año',
'years' => 'años',
];

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

@ -6,7 +6,6 @@
'create_workspace' => 'Criar workspace',
'create_post' => 'Novo post',
'profile' => 'Perfil',
'account' => 'Conta',
'log_out' => 'Sair',
'workspace' => 'Workspace: :name',
@ -22,7 +21,8 @@
'groups' => [
'posts' => 'Posts',
'configuration' => 'Configurações',
'workspace' => 'Workspace',
'account' => 'Conta',
'support' => 'Suporte',
],
@ -36,14 +36,20 @@
'drafts' => 'Rascunhos',
],
'config' => [
'workspace' => [
'connections' => 'Conexões',
'brands' => 'Marcas',
'hashtags' => 'Hashtags',
'labels' => 'Etiquetas',
'api_keys' => 'API Keys',
'settings' => 'Configurações',
],
'account' => [
'settings' => 'Configurações',
'usage' => 'Uso',
'billing' => 'Faturamento',
],
'notifications' => 'Notificações',
'mark_all_read' => 'Marcar tudo como lido',
'mark_as_read' => 'Marcar como lido',

24
lang/pt-br/usage.php Normal file
View file

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Uso',
'section_account' => 'Conta',
'section_account_description' => 'Cotas e limites do seu plano :plan.',
'section_ai' => 'Geração AI',
'section_ai_description' => 'Uso de geração de imagens e vídeos AI do mês atual.',
'section_data' => 'Dados',
'section_data_description' => 'Retenção de dados e armazenamento do seu plano.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Contas Sociais',
'members' => 'Membros',
'ai_images' => 'Imagens',
'ai_videos' => 'Vídeos',
'data_retention' => 'Retenção de Dados',
'unlimited' => 'Ilimitado',
'days' => 'dias',
'year' => 'ano',
'years' => 'anos',
];

View file

@ -6,9 +6,12 @@ import {
IconChartBar,
IconChevronRight,
IconClock,
IconChartPie,
IconCreditCard,
IconFileCheck,
IconFileText,
IconHash,
IconKey,
IconLifebuoy,
IconMessageCircle,
IconPencil,
@ -45,9 +48,13 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { accounts, analytics, calendar } from '@/routes/app';
import { edit as accountSettings } from '@/routes/app/account';
import { index as apiKeys } from '@/routes/app/api-keys';
import { index as billing } from '@/routes/app/billing';
import { index as usage } from '@/routes/app/usage';
import { index as hashtags } from '@/routes/app/hashtags';
import { index as labels } from '@/routes/app/labels';
import { edit as editProfile } from '@/routes/app/profile';
import { settings as workspaceSettings } from '@/routes/app/workspace';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
import type { NavItem } from '@/types';
@ -60,6 +67,7 @@ interface Workspace {
const page = usePage();
const auth = computed(() => page.props.auth);
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
const isSelfHosted = computed(() => page.props.selfHosted as boolean);
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
const { state: sidebarState } = useSidebar();
@ -101,41 +109,56 @@ const postsNavItems = computed<NavItem[]>(() => [
},
]);
const canManageWorkspace = computed(() => {
const isOwner = computed(() => {
const role = auth.value.currentWorkspace?.role;
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
return role === WorkspaceRole.Owner;
});
const configNavItems = computed(() => {
const items: NavItem[] = [
{
title: trans('sidebar.config.connections'),
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.config.hashtags'),
href: hashtags.url(),
icon: IconHash,
},
{
title: trans('sidebar.config.labels'),
href: labels.url(),
icon: IconTag,
},
];
const workspaceNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.workspace.connections'),
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.workspace.hashtags'),
href: hashtags.url(),
icon: IconHash,
},
{
title: trans('sidebar.workspace.labels'),
href: labels.url(),
icon: IconTag,
},
{
title: trans('sidebar.workspace.api_keys'),
href: apiKeys.url(),
icon: IconKey,
},
{
title: trans('sidebar.workspace.settings'),
href: workspaceSettings.url(),
icon: IconSettings,
},
]);
if (canManageWorkspace.value) {
items.push({
title: trans('sidebar.config.settings'),
href: editProfile.url(),
icon: IconSettings,
activePattern: '/settings',
});
}
return items;
});
const accountNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.account.settings'),
href: accountSettings.url(),
icon: IconSettings,
},
{
title: trans('sidebar.account.usage'),
href: usage.url(),
icon: IconChartPie,
},
{
title: trans('sidebar.account.billing'),
href: billing.url(),
icon: IconCreditCard,
},
]);
const switchWorkspace = (workspaceId: string) => {
router.post(switchMethod.url(workspaceId), {}, {
@ -204,7 +227,8 @@ const switchWorkspace = (workspaceId: string) => {
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
<NavMain v-if="currentWorkspace" :items="postsNavItems" :label="$t('sidebar.groups.posts')" />
<NavMain v-if="currentWorkspace" :items="configNavItems" :label="$t('sidebar.groups.configuration')" />
<NavMain v-if="currentWorkspace" :items="workspaceNavItems" :label="$t('sidebar.groups.workspace')" />
<NavMain v-if="currentWorkspace && isOwner && !isSelfHosted" :items="accountNavItems" :label="$t('sidebar.groups.account')" />
</SidebarContent>
<SidebarFooter>

View file

@ -82,7 +82,7 @@ const handleLogout = () => {
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<IconUser class="size-4" />
{{ $t('sidebar.account') }}
{{ $t('sidebar.profile') }}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatNumber } from '@/lib/utils';
type Props = {
label: string;
current: number;
limit: number;
};
const props = defineProps<Props>();
const RING_RADIUS = 8;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const percentage = computed(() => {
if (props.limit === 0) {
return 0;
}
return Math.min(100, (props.current / props.limit) * 100);
});
const isOverLimit = computed(() => props.current >= props.limit);
const dashoffset = computed(
() => RING_CIRCUMFERENCE * (1 - percentage.value / 100),
);
</script>
<template>
<div class="flex items-center gap-3 py-3">
<svg class="size-5 shrink-0 -rotate-90" viewBox="0 0 20 20">
<circle
cx="10"
cy="10"
:r="RING_RADIUS"
fill="none"
stroke="currentColor"
stroke-width="2.5"
class="opacity-10"
/>
<circle
cx="10"
cy="10"
:r="RING_RADIUS"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
class="transition-all duration-700 ease-out"
:class="isOverLimit ? 'text-destructive' : ''"
:stroke-dasharray="RING_CIRCUMFERENCE"
:stroke-dashoffset="dashoffset"
/>
</svg>
<span class="text-sm">{{ label }}</span>
<span class="ml-auto text-sm tabular-nums">
<span
class="font-medium"
:class="isOverLimit ? 'text-destructive' : ''"
>
{{ formatNumber(current) }}
</span>
<span class="text-muted-foreground">
/ {{ formatNumber(limit) }}
</span>
</span>
</div>
</template>

View file

@ -1,64 +1,29 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { WorkspaceRole } from '@/enums/workspace-role';
import { toUrl } from '@/lib/utils';
import { index as apiKeys } from '@/routes/app/api-keys';
import { index as billing } from '@/routes/app/billing';
import { preferences as notificationPreferences } from '@/routes/app/notifications';
import { edit as editProfile } from '@/routes/app/profile';
import { edit as editPassword } from '@/routes/app/user-password';
import { settings as workspaceSettings } from '@/routes/app/workspace';
import { type NavItem, type SharedData } from '@/types';
import { type NavItem } from '@/types';
const page = usePage<SharedData>();
const auth = computed(() => page.props.auth);
const canManageWorkspace = computed(() => {
const role = auth.value.currentWorkspace?.role;
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
});
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: trans('settings.nav.profile'),
href: editProfile(),
},
{
title: trans('settings.nav.password'),
href: editPassword(),
},
{
title: trans('settings.nav.notifications'),
href: notificationPreferences(),
},
];
if (canManageWorkspace.value) {
items.push(
{
title: trans('settings.nav.workspace'),
href: workspaceSettings(),
},
{
title: 'API Keys',
href: apiKeys(),
},
);
if (!page.props.selfHosted) {
items.push({
title: trans('settings.nav.billing'),
href: billing(),
});
}
}
return items;
});
const navItems = computed<NavItem[]>(() => [
{
title: trans('settings.nav.profile'),
href: editProfile(),
},
{
title: trans('settings.nav.password'),
href: editPassword(),
},
{
title: trans('settings.nav.notifications'),
href: notificationPreferences(),
},
]);
const { urlIsActive } = useActiveUrl();
</script>
@ -81,4 +46,4 @@ const { urlIsActive } = useActiveUrl();
<slot />
</section>
</div>
</template>
</template>

View file

@ -1,22 +1,24 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconCreditCard, IconDownload, IconExternalLink, IconFileText, IconSparkles } from '@tabler/icons-vue';
import { IconDownload, IconFileText } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { index as billingIndex, portal } from '@/routes/app/billing';
import { type BreadcrumbItem } from '@/types';
interface Plan {
name: string;
slug: string;
monthly_price: number;
yearly_price: number;
}
interface Subscription {
stripe_status: string;
quantity: number;
ends_at: string | null;
}
@ -35,139 +37,149 @@ interface Invoice {
invoice_pdf: string;
}
interface Props {
const props = defineProps<{
hasSubscription: boolean;
onTrial: boolean;
trialEndsAt: string | null;
subscription: Subscription | null;
workspacesCount: number;
plan: Plan | null;
plans: Plan[];
invoices: Invoice[];
defaultPaymentMethod: PaymentMethod | null;
}
}>();
defineProps<Props>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: billingIndex.url() },
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('billing.title'), href: billingIndex.url() },
]);
const openPortal = () => {
window.location.href = portal.url();
};
const getStatusLabel = (status: string): string => {
return trans(`billing.status.${status}`) || status;
};
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (status === 'active' || status === 'trialing') return 'default';
if (status === 'canceled' || status === 'past_due' || status === 'unpaid') return 'destructive';
return 'secondary';
const formatPrice = (cents: number): string => {
if (cents === 0) return 'Free';
return '$' + (cents / 100).toFixed(0);
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head :title="$t('billing.title')" />
<Head :title="$t('billing.title')" />
<h1 class="sr-only">{{ $t('billing.title') }}</h1>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-3xl space-y-0 p-6">
<!-- Plan -->
<section 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"
>
{{ $t('billing.plan.change') }}
</Button>
</div>
<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('billing.subscription.title')"
:description="$t('billing.description')"
/>
<Alert v-if="onTrial" class="border-primary/50 bg-primary/5">
<IconSparkles class="h-4 w-4 text-primary" />
<AlertTitle>{{ $t('billing.trial.title') }}</AlertTitle>
<AlertDescription>
{{ $t('billing.trial.description', { date: trialEndsAt ?? '' }) }}
</AlertDescription>
</Alert>
<div class="space-y-4">
<div class="flex items-center justify-between rounded-lg border p-4">
<div>
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.status') }}</p>
<Badge :variant="getStatusVariant(subscription?.stripe_status || '')" class="mt-1">
{{ getStatusLabel(subscription?.stripe_status || '') }}
</Badge>
</div>
<div class="text-right">
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.workspaces') }}</p>
<p class="text-lg font-semibold">{{ workspacesCount }} / {{ subscription?.quantity || 0 }}</p>
</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 v-if="defaultPaymentMethod" class="flex items-center gap-3 rounded-lg border p-4">
<IconCreditCard class="h-6 w-6 text-muted-foreground" />
<div>
<p class="text-sm font-medium capitalize">{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}</p>
<p class="text-xs text-muted-foreground">
{{ $t('billing.subscription.expires', { date: `${defaultPaymentMethod.exp_month}/${defaultPaymentMethod.exp_year}` }) }}
</p>
</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">
{{ formatPrice(plan?.monthly_price ?? 0) }}/{{ $t('billing.plan.month') }}
</span>
</div>
<div v-if="subscription?.ends_at" class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
{{ $t('billing.subscription.canceled_on', { date: subscription.ends_at }) }}
<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">{{ trialEndsAt }}</span>
</div>
</div>
</section>
<!-- Subscription Management -->
<template v-if="hasSubscription">
<hr class="my-8 border-border" />
<section 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>
<Button variant="outline" @click="openPortal">
<IconExternalLink class="h-4 w-4" />
{{ $t('billing.subscription.manage') }}
</Button>
</div>
</div>
<Separator />
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('billing.invoices.title')"
:description="$t('billing.invoices.description')"
/>
<div v-if="invoices.length === 0" class="text-sm text-muted-foreground">
{{ $t('billing.invoices.empty') }}
</div>
<div v-else class="space-y-2">
<div
v-for="invoice in invoices"
:key="invoice.id"
class="flex items-center justify-between rounded-lg border p-3"
>
<div class="flex items-center gap-3">
<IconFileText class="h-4 w-4 text-muted-foreground" />
<div>
<p class="text-sm font-medium">{{ invoice.date }}</p>
<p class="text-xs text-muted-foreground">{{ invoice.total }}</p>
</div>
<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-2">
<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>
</section>
</template>
<!-- Invoices -->
<template v-if="invoices.length > 0">
<hr class="my-8 border-border" />
<section 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
v-for="invoice in invoices"
:key="invoice.id"
class="flex items-center gap-3 py-3"
>
<IconFileText class="size-4 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<span class="text-sm">{{ invoice.date }}</span>
<span class="ml-2 text-sm text-muted-foreground">{{ invoice.total }}</span>
</div>
<Badge variant="outline">
{{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }}
</Badge>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="size-8"
as="a"
:href="invoice.invoice_pdf"
target="_blank"
>
<IconDownload class="h-4 w-4" />
<IconDownload class="size-4" />
</Button>
</div>
</div>
</div>
</div>
</SettingsLayout>
</section>
</template>
</div>
</AppLayout>
</template>

View file

@ -1,99 +1,44 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import { IconLoader2, IconCircleCheck, IconCircleX } from '@tabler/icons-vue';
import { ref, onMounted } from 'vue';
import { Head, router, usePoll } from '@inertiajs/vue3';
import { IconLoader2 } from '@tabler/icons-vue';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import { subscribe } from '@/routes/app';
import { index as workspacesIndex } from '@/routes/app/workspaces';
import { home } from '@/routes/app';
interface Props {
userId: number;
status: 'processing' | 'success' | 'cancelled';
}
const props = defineProps<{
subscriptionActive: boolean;
}>();
const props = defineProps<Props>();
const { stop } = usePoll(2000, {
only: ['subscriptionActive'],
});
const currentStatus = ref(props.status);
// Listen for subscription created event
if (props.status === 'processing') {
useEcho(
`users.${props.userId}`,
'SubscriptionCreated',
() => {
currentStatus.value = 'success';
setTimeout(() => {
router.visit(workspacesIndex.url());
}, 1500);
},
);
// Fallback: check subscription status after 10 seconds
onMounted(() => {
setTimeout(() => {
if (currentStatus.value === 'processing') {
router.visit(workspacesIndex.url());
}
}, 10000);
});
}
// If already success, redirect after a moment
if (props.status === 'success') {
onMounted(() => {
setTimeout(() => {
router.visit(workspacesIndex.url());
}, 1500);
});
}
const retry = () => {
router.visit(subscribe.url());
};
watch(
() => props.subscriptionActive,
(active) => {
if (active) {
stop();
router.visit(home.url());
}
},
{ immediate: true },
);
</script>
<template>
<Head :title="$t('billing.processing.page_title')" />
<div class="min-h-screen bg-gradient-to-b from-background via-background to-muted/30 flex items-center justify-center">
<div class="text-center max-w-md px-4">
<!-- Processing -->
<template v-if="currentStatus === 'processing'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 mb-6">
<IconLoader2 class="w-10 h-10 text-primary animate-spin" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">{{ $t('billing.processing.title') }}</h1>
<p class="text-muted-foreground">
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="flex flex-col items-center gap-4 text-center">
<IconLoader2 class="size-8 animate-spin text-muted-foreground" />
<div>
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('billing.processing.title') }}
</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('billing.processing.description') }}
</p>
</template>
<!-- Success -->
<template v-else-if="currentStatus === 'success'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-green-100 mb-6">
<IconCircleCheck class="w-10 h-10 text-green-600" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">{{ $t('billing.processing.success_title') }}</h1>
<p class="text-muted-foreground">
{{ $t('billing.processing.success_description') }}
</p>
</template>
<!-- Cancelled -->
<template v-else-if="currentStatus === 'cancelled'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mb-6">
<IconCircleX class="w-10 h-10 text-red-600" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">{{ $t('billing.processing.cancelled_title') }}</h1>
<p class="text-muted-foreground mb-6">
{{ $t('billing.processing.cancelled_description') }}
</p>
<Button @click="retry">
{{ $t('billing.processing.retry') }}
</Button>
</template>
</div>
</div>
</div>
</template>

View file

@ -1,131 +1,179 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Head, router } from '@inertiajs/vue3';
import { IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { checkout } from '@/routes/app/billing';
import { index as workspacesIndex } from '@/routes/app/workspaces';
interface Props {
trialDays: number;
interface Plan {
id: string;
slug: string;
name: string;
stripe_monthly_price_id: string | null;
stripe_yearly_price_id: string | null;
monthly_price: number;
yearly_price: number;
social_account_limit: number;
member_limit: number;
workspace_limit: number;
ai_images_limit: number;
ai_videos_limit: number;
data_retention_days: number;
}
const props = defineProps<Props>();
const props = defineProps<{
plans: Plan[];
trialDays: number;
}>();
const processing = ref(false);
const displayDays = computed(() => props.trialDays - 1);
const isYearly = ref(false);
const processing = ref<string | null>(null);
const subscribe = () => {
processing.value = true;
router.post(checkout.url());
const formatPrice = (cents: number): string => {
return '$' + (cents / 100).toFixed(0);
};
const platforms = [
{ name: 'LinkedIn', icon: '/images/accounts/linkedin.png' },
{ name: 'X', icon: '/images/accounts/x.png' },
{ name: 'Instagram', icon: '/images/accounts/instagram.png' },
{ name: 'Facebook', icon: '/images/accounts/facebook.png' },
{ name: 'TikTok', icon: '/images/accounts/tiktok.png' },
{ name: 'YouTube', icon: '/images/accounts/youtube.png' },
{ name: 'Threads', icon: '/images/accounts/threads.png' },
{ name: 'Pinterest', icon: '/images/accounts/pinterest.png' },
{ name: 'Bluesky', icon: '/images/accounts/bluesky.png' },
{ name: 'Mastodon', icon: '/images/accounts/mastodon.png' },
const getPrice = (plan: Plan): string => {
return formatPrice(isYearly.value ? plan.yearly_price / 12 : plan.monthly_price);
};
const getTotalPrice = (plan: Plan): string => {
return formatPrice(isYearly.value ? plan.yearly_price : plan.monthly_price);
};
const getSavings = (plan: Plan): number => {
const monthlyTotal = plan.monthly_price * 12;
const yearlyTotal = plan.yearly_price;
return Math.round(((monthlyTotal - yearlyTotal) / monthlyTotal) * 100);
};
const formatRetention = (days: number): string => {
if (days >= 730) return '2 years';
return `${days} days`;
};
const selectPlan = (plan: Plan) => {
processing.value = plan.id;
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
router.post(checkout.url(plan.id), {
price_id: priceId,
});
};
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.ai_images', { count: String(plan.ai_images_limit) }),
trans('billing.subscribe.features.ai_videos', { count: String(plan.ai_videos_limit) }),
trans('billing.subscribe.features.data_retention', { days: formatRetention(plan.data_retention_days) }),
];
const featureKeys = [
'calendar',
'scheduling',
'media',
'video',
'team',
'hashtags',
];
const isPopular = (plan: Plan): boolean => {
return plan.slug === 'pro';
};
</script>
<template>
<Head :title="$t('billing.subscribe.page_title')" />
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="mb-8 text-center">
<div class="min-h-screen bg-background">
<!-- Top bar -->
<div class="border-b">
<div class="mx-auto flex max-w-7xl items-center px-6 py-4">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="mx-auto h-8 w-auto dark:hidden"
class="h-7 w-auto dark:hidden"
/>
<img
src="/images/trypost/logo-dark.png"
alt="TryPost"
class="mx-auto hidden h-8 w-auto dark:block"
class="hidden h-7 w-auto dark:block"
/>
</div>
</div>
<!-- Card -->
<div class="rounded-xl border bg-card p-8">
<div class="mb-6 text-center">
<h1 class="text-2xl font-semibold tracking-tight">
{{ $t('billing.subscribe.title') }}
</h1>
<p class="mt-2 text-sm text-muted-foreground">
{{ trans('billing.subscribe.description', { days: String(displayDays) }) }}
</p>
</div>
<!-- Platforms -->
<div class="mb-6 flex flex-wrap justify-center gap-2">
<img
v-for="platform in platforms"
:key="platform.name"
:src="platform.icon"
:alt="platform.name"
:title="platform.name"
class="size-10 rounded-full border bg-background p-0.5"
/>
</div>
<Separator class="mb-6" />
<!-- Features -->
<ul class="mb-6 space-y-3">
<li
v-for="key in featureKeys"
:key="key"
class="flex items-center gap-3 text-sm"
>
<div class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
<IconCheck class="size-3 text-primary" />
</div>
{{ $t(`billing.subscribe.features.${key}`) }}
</li>
</ul>
<!-- CTA -->
<Button
class="w-full"
size="lg"
:disabled="processing"
@click="subscribe"
>
{{ trans('billing.subscribe.start_trial', { days: String(displayDays) }) }}
</Button>
</div>
<div class="mt-4 space-y-1 text-center">
<p class="text-xs text-muted-foreground">
{{ $t('billing.subscribe.cancel_anytime') }}
<!-- Content -->
<div class="mx-auto max-w-7xl px-6 py-16">
<!-- Header -->
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold tracking-tight">
{{ $t('billing.subscribe.title') }}
</h1>
<p class="mt-3 text-lg text-muted-foreground">
{{ trans('billing.subscribe.description', { days: String(trialDays) }) }}
</p>
<Link
:href="workspacesIndex.url()"
class="inline-block text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
{{ $t('billing.subscribe.switch_workspace') }}
</Link>
</div>
<!-- Billing toggle -->
<div class="mb-10 flex items-center justify-center gap-3">
<span class="text-sm" :class="!isYearly ? 'font-medium' : 'text-muted-foreground'">
{{ $t('billing.subscribe.monthly') }}
</span>
<Switch v-model="isYearly" />
<span class="flex items-center gap-2 text-sm" :class="isYearly ? 'font-medium' : 'text-muted-foreground'">
{{ $t('billing.subscribe.yearly') }}
<Badge variant="secondary" class="whitespace-nowrap">
{{ $t('billing.subscribe.save_months') }}
</Badge>
</span>
</div>
<!-- Plan cards -->
<div class="grid grid-cols-1 gap-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-6 transition-shadow hover:shadow-lg"
:class="isPopular(plan) ? 'border-primary ring-1 ring-primary' : ''"
>
<Badge v-if="isPopular(plan)" class="absolute -top-3 left-1/2 -translate-x-1/2">
{{ $t('billing.subscribe.popular') }}
</Badge>
<div class="mb-6">
<h3 class="text-xl font-semibold">{{ plan.name }}</h3>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-4xl font-bold tracking-tight">{{ getPrice(plan) }}</span>
<span class="text-muted-foreground">/{{ $t('billing.subscribe.per_month') }}</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
</div>
<ul class="mb-8 flex-1 space-y-3">
<li
v-for="feature in features(plan)"
:key="feature"
class="flex items-start gap-2.5 text-sm"
>
<IconCheck class="mt-0.5 size-4 shrink-0 text-primary" />
<span>{{ feature }}</span>
</li>
</ul>
<Button
class="w-full"
:variant="isPopular(plan) ? 'default' : 'outline'"
size="lg"
:disabled="processing !== null"
@click="selectPlan(plan)"
>
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
</Button>
</div>
</div>
<!-- Footer -->
<p class="mt-10 text-center text-sm text-muted-foreground">
{{ $t('billing.subscribe.cancel_anytime') }}
</p>
</div>
</div>
</template>

View file

@ -4,7 +4,7 @@ import { IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { storeConnect } from '@/actions/App/Http/Controllers/App/OnboardingController';
import { storeAccount } from '@/actions/App/Http/Controllers/App/OnboardingController';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { role } from '@/routes/app/onboarding';
@ -79,7 +79,7 @@ onUnmounted(() => window.removeEventListener('message', handleOAuthMessage));
const submit = () => {
isSubmitting.value = true;
router.post(storeConnect.url());
router.post(storeAccount.url());
};
</script>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as accountEdit, update as accountUpdate } from '@/routes/app/account';
import { type BreadcrumbItem } from '@/types';
interface AccountData {
id: string;
name: string;
billing_email: string;
}
defineProps<{
account: AccountData;
}>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.account.title'), href: accountEdit.url() },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head :title="$t('settings.account.title')" />
<div class="mx-auto max-w-2xl space-y-6 p-6">
<HeadingSmall
:title="$t('settings.account.title')"
:description="$t('settings.account.description')"
/>
<Form
v-bind="accountUpdate.form()"
method="put"
v-slot="{ errors, processing }"
class="space-y-6"
>
<div class="grid gap-2">
<Label for="account-name">{{ $t('settings.account.name') }}</Label>
<Input
id="account-name"
name="name"
:default-value="account.name"
:placeholder="trans('settings.account.name_placeholder')"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="billing-email">{{ $t('settings.account.billing_email') }}</Label>
<Input
id="billing-email"
name="billing_email"
type="email"
:default-value="account.billing_email"
:placeholder="trans('settings.account.billing_email_placeholder')"
/>
<p class="text-sm text-muted-foreground">
{{ $t('settings.account.billing_email_hint') }}
</p>
<InputError :message="errors.billing_email" />
</div>
<Button type="submit" :disabled="processing">
{{ $t('settings.account.submit') }}
</Button>
</Form>
</div>
</AppLayout>
</template>

View file

@ -28,7 +28,6 @@ import {
} from '@/components/ui/table';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { copyToClipboard } from '@/lib/utils';
import { index as apiKeysIndex } from '@/routes/app/api-keys';
import { type BreadcrumbItem } from '@/types';
@ -67,7 +66,7 @@ const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(n
<h1 class="sr-only">{{ $t('settings.api_keys.heading') }}</h1>
<SettingsLayout>
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
<div class="flex flex-col space-y-6">
<div class="flex items-center justify-between">
<HeadingSmall
@ -167,7 +166,7 @@ const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(n
:description="$t('settings.api_keys.empty.description')"
/>
</div>
</SettingsLayout>
</div>
</AppLayout>
<CreateApiKeyDialog v-model:open="createDialogOpen" />

View file

@ -13,7 +13,6 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { members as membersRoute } from '@/routes/app';
import { destroy as destroyInvite, store as storeInvite } from '@/routes/app/invites';
import { remove as removeMember } from '@/routes/app/members';
@ -103,7 +102,7 @@ const getRoleIcon = (role: string) => {
<h1 class="sr-only">{{ $t('settings.members.title') }}</h1>
<SettingsLayout>
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('settings.members.heading')"
@ -258,6 +257,6 @@ const getRoleIcon = (role: string) => {
</CardContent>
</Card>
</div>
</SettingsLayout>
</div>
</AppLayout>
</template>

View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import UsageMetricRow from '@/components/settings/UsageMetricRow.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as usageRoute } from '@/routes/app/usage';
import { type BreadcrumbItem } from '@/types';
interface Plan {
name: string;
slug: string;
}
interface Usage {
workspaceCount: number;
workspaceLimit: number;
socialAccountCount: number;
socialAccountLimit: number;
memberCount: number;
memberLimit: number;
aiImagesUsed: number;
aiImagesLimit: number;
aiVideosUsed: number;
aiVideosLimit: number;
dataRetentionDays: number;
}
const props = defineProps<{
plan: Plan | null;
usage: Usage;
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('usage.title'), href: usageRoute.url() },
]);
const formatRetention = (days: number): string => {
if (days >= 730) return trans('usage.unlimited');
if (days >= 365) {
const years = Math.floor(days / 365);
return `${years} ${years === 1 ? trans('usage.year') : trans('usage.years')}`;
}
return `${days} ${trans('usage.days')}`;
};
</script>
<template>
<Head :title="$t('usage.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-3xl space-y-0 p-6">
<!-- Account -->
<section 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('usage.section_account') }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('usage.section_account_description', { plan: plan?.name ?? 'Free' }) }}
</p>
</div>
<div class="divide-y">
<UsageMetricRow
:label="$t('usage.workspaces')"
:current="usage.workspaceCount"
:limit="usage.workspaceLimit"
/>
<UsageMetricRow
:label="$t('usage.social_accounts')"
:current="usage.socialAccountCount"
:limit="usage.socialAccountLimit"
/>
<UsageMetricRow
:label="$t('usage.members')"
:current="usage.memberCount"
:limit="usage.memberLimit"
/>
</div>
</section>
<hr class="my-8 border-border" />
<!-- AI -->
<section 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('usage.section_ai') }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('usage.section_ai_description') }}
</p>
</div>
<div class="divide-y">
<UsageMetricRow
:label="$t('usage.ai_images')"
:current="usage.aiImagesUsed"
:limit="usage.aiImagesLimit"
/>
<UsageMetricRow
:label="$t('usage.ai_videos')"
:current="usage.aiVideosUsed"
:limit="usage.aiVideosLimit"
/>
</div>
</section>
<hr class="my-8 border-border" />
<!-- Data -->
<section 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('usage.section_data') }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('usage.section_data_description') }}
</p>
</div>
<div class="divide-y">
<div class="flex items-center gap-3 py-3">
<div class="size-5 shrink-0" />
<span class="text-sm">{{ $t('usage.data_retention') }}</span>
<span class="ml-auto text-sm tabular-nums">
<span class="font-medium">{{ formatRetention(usage.dataRetentionDays) }}</span>
</span>
</div>
</div>
</section>
</div>
</AppLayout>
</template>

View file

@ -32,7 +32,6 @@ import {
TableRow,
} from '@/components/ui/table';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
import { settings, uploadLogo, deleteLogo } from '@/routes/app/workspace';
@ -88,7 +87,7 @@ const changeRole = (member: Member, role: string) => {
<h1 class="sr-only">{{ $t('settings.workspace.title') }}</h1>
<SettingsLayout>
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('settings.workspace.logo_heading')"
@ -262,6 +261,6 @@ const changeRole = (member: Member, role: string) => {
:description="trans('settings.members.cancel_invite_modal.description')"
:action="trans('settings.members.cancel_invite_modal.action')"
/>
</SettingsLayout>
</div>
</AppLayout>
</template>

View file

@ -9,9 +9,11 @@
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostController;
use App\Http\Controllers\App\Settings\AccountController;
use App\Http\Controllers\App\Settings\NotificationPreferenceController;
use App\Http\Controllers\App\Settings\PasswordController;
use App\Http\Controllers\App\Settings\ProfileController;
use App\Http\Controllers\App\Settings\UsageController;
use App\Http\Controllers\App\WorkspaceController;
use App\Http\Controllers\App\WorkspaceHashtagController;
use App\Http\Controllers\App\WorkspaceInviteController;
@ -48,9 +50,8 @@
Route::get('/', fn () => redirect()->route('app.onboarding.role'));
Route::get('role', [OnboardingController::class, 'role'])->name('app.onboarding.role');
Route::post('role', [OnboardingController::class, 'storeRole'])->name('app.onboarding.role.store');
Route::get('connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect');
Route::post('connect', [OnboardingController::class, 'storeConnect'])->name('app.onboarding.connect.store');
Route::get('complete', [OnboardingController::class, 'complete'])->name('app.onboarding.complete');
Route::get('account', [OnboardingController::class, 'account'])->name('app.onboarding.account');
Route::post('account', [OnboardingController::class, 'storeAccount'])->name('app.onboarding.account.store');
});
// Social Connect routes
@ -168,6 +169,11 @@
Route::post('api-keys', [ApiKeyController::class, 'store'])->name('app.api-keys.store');
Route::delete('api-keys/{apiToken}', [ApiKeyController::class, 'destroy'])->name('app.api-keys.destroy');
// Account Settings
Route::get('settings/account', [AccountController::class, 'edit'])->name('app.account.edit');
Route::put('settings/account', [AccountController::class, 'update'])->name('app.account.update');
Route::get('settings/usage', [UsageController::class, 'index'])->name('app.usage.index');
// Billing
Route::get('settings/billing', [BillingController::class, 'index'])->name('app.billing.index');
Route::get('settings/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');

View file

@ -96,28 +96,10 @@
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Processing', false)
->has('accountId')
->has('status')
->has('subscriptionActive')
);
});
test('billing processing accepts status parameter', function () {
$response = $this->actingAs($this->user)->get(route('app.billing.processing', ['status' => 'success']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('status', 'success')
);
});
test('billing processing validates status parameter', function () {
$response = $this->actingAs($this->user)->get(route('app.billing.processing', ['status' => 'invalid']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('status', 'processing')
);
});
// Checkout tests
test('checkout requires authentication', function () {

View file

@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Events\SubscriptionCreated;
use App\Listeners\StripeEventListener;
use App\Models\Account;
@ -36,54 +35,6 @@
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->account->id === $this->account->id);
});
test('subscription created marks setup as completed when on subscription step', function () {
Event::fake([SubscriptionCreated::class]);
$this->user->update(['setup' => Setup::Subscription]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
expect($this->user->fresh()->setup)->toBe(Setup::Completed);
});
test('subscription created does not change setup if already completed', function () {
Event::fake([SubscriptionCreated::class]);
$this->user->update(['setup' => Setup::Completed]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
expect($this->user->fresh()->setup)->toBe(Setup::Completed);
});
test('subscription created does not skip onboarding steps', function () {
Event::fake([SubscriptionCreated::class]);
$this->user->update(['setup' => Setup::Role]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
expect($this->user->fresh()->setup)->toBe(Setup::Role);
});
test('subscription created does not skip connections step', function () {
Event::fake([SubscriptionCreated::class]);
$this->user->update(['setup' => Setup::Connections]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
expect($this->user->fresh()->setup)->toBe(Setup::Connections);
});
// ========================================
// customer.subscription.updated
// ========================================

View file

@ -43,20 +43,7 @@
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.onboarding.connect'));
});
test('user on subscription step is redirected to subscribe', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create(['setup' => Setup::Subscription]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.subscribe'));
->assertRedirect(route('app.onboarding.account'));
});
test('user on role step can access onboarding step 1', function () {
@ -71,7 +58,7 @@
$user = User::factory()->create(['setup' => Setup::Connections]);
$this->actingAs($user)
->get(route('app.onboarding.connect'))
->get(route('app.onboarding.account'))
->assertOk();
});

View file

@ -44,7 +44,7 @@
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.account'));
$this->user->refresh();
expect($this->user->persona)->toBe(Persona::Founder);
@ -69,7 +69,7 @@
// Step 2 tests
test('step2 requires authentication', function () {
$response = $this->get(route('app.onboarding.connect'));
$response = $this->get(route('app.onboarding.account'));
$response->assertRedirect(route('login'));
});
@ -77,11 +77,11 @@
test('step2 shows social accounts connection', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect', false)
->component('onboarding/Account', false)
->has('platforms')
->has('hasWorkspace')
);
@ -97,7 +97,7 @@
'platform' => Platform::LinkedIn,
]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
@ -107,7 +107,7 @@
// Store Step 2 tests
test('store step2 requires authentication', function () {
$response = $this->post(route('app.onboarding.connect.store'));
$response = $this->post(route('app.onboarding.account.store'));
$response->assertRedirect(route('login'));
});
@ -116,7 +116,7 @@
config(['trypost.self_hosted' => true]);
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.connect.store'));
$response = $this->actingAs($this->user)->post(route('app.onboarding.account.store'));
$response->assertRedirect(route('app.calendar'));
@ -126,20 +126,17 @@
// Complete tests
test('complete requires authentication', function () {
$response = $this->get(route('app.onboarding.complete'));
$response = $this->get(route('app.onboarding.account'));
$response->assertRedirect(route('login'));
});
test('complete marks setup as completed', function () {
$this->user->update(['setup' => Setup::Subscription]);
test('completed user is redirected to calendar', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.complete'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.calendar'));
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Completed);
});
// Step enforcement tests
@ -148,15 +145,7 @@
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.onboarding.connect'));
});
test('step1 redirects to subscribe when user is on subscription step', function () {
$this->user->update(['setup' => Setup::Subscription]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.subscribe'));
$response->assertRedirect(route('app.onboarding.account'));
});
test('step1 redirects to calendar when setup is completed', function () {
@ -170,23 +159,15 @@
test('step2 redirects to role when user has not completed role step', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.onboarding.role'));
});
test('step2 redirects to subscribe when user is on subscription step', function () {
$this->user->update(['setup' => Setup::Subscription]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.subscribe'));
});
test('step2 redirects to calendar when setup is completed', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.calendar'));
});
@ -202,7 +183,7 @@
test('user on connections step can access connect page', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
});

View file

@ -4,10 +4,12 @@
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Events\SubscriptionCreated;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;
beforeEach(function () {
@ -17,7 +19,7 @@
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$this->user = User::factory()->create([
'setup' => Setup::Subscription,
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
@ -29,7 +31,9 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('webhook creates subscription and completes setup', function () {
test('webhook dispatches subscription created event', function () {
Event::fake([SubscriptionCreated::class]);
$payload = [
'type' => 'customer.subscription.created',
'data' => [
@ -41,15 +45,15 @@
],
];
$event = new WebhookReceived($payload);
$listener = new StripeEventListener;
$listener->handle($event);
$listener->handle(new WebhookReceived($payload));
expect($this->user->fresh()->setup)->toBe(Setup::Completed);
Event::assertDispatched(SubscriptionCreated::class);
});
test('webhook ignores unknown stripe customer', function () {
Event::fake([SubscriptionCreated::class]);
$payload = [
'type' => 'customer.subscription.created',
'data' => [
@ -61,10 +65,8 @@
],
];
$event = new WebhookReceived($payload);
$listener = new StripeEventListener;
$listener->handle($event);
$listener->handle(new WebhookReceived($payload));
expect($this->user->fresh()->setup)->toBe(Setup::Subscription);
Event::assertNotDispatched(SubscriptionCreated::class);
});