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:
parent
2da15df96c
commit
ded1c998ec
40 changed files with 1005 additions and 647 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
59
app/Http/Controllers/App/Settings/AccountController.php
Normal file
59
app/Http/Controllers/App/Settings/AccountController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/App/Settings/UsageController.php
Normal file
52
app/Http/Controllers/App/Settings/UsageController.php
Normal 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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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
24
lang/en/usage.php
Normal 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',
|
||||
];
|
||||
|
|
@ -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
24
lang/es/usage.php
Normal 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
|
|
@ -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
24
lang/pt-br/usage.php
Normal 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',
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
70
resources/js/components/settings/UsageMetricRow.vue
Normal file
70
resources/js/components/settings/UsageMetricRow.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
78
resources/js/pages/settings/Account.vue
Normal file
78
resources/js/pages/settings/Account.vue
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
133
resources/js/pages/settings/Usage.vue
Normal file
133
resources/js/pages/settings/Usage.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue